393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
import https from 'node:https';
|
|
import http from 'node:http';
|
|
import fs from 'node:fs';
|
|
import { color, urlHostnameToIPv6, getHasIP } from './util.js';
|
|
|
|
// Express routers
|
|
import { router as userDataRouter } from './users.js';
|
|
import { router as usersPrivateRouter } from './endpoints/users-private.js';
|
|
import { router as usersAdminRouter } from './endpoints/users-admin.js';
|
|
import { router as movingUIRouter } from './endpoints/moving-ui.js';
|
|
import { router as imagesRouter } from './endpoints/images.js';
|
|
import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
|
|
import { router as avatarsRouter } from './endpoints/avatars.js';
|
|
import { router as themesRouter } from './endpoints/themes.js';
|
|
import { router as openAiRouter } from './endpoints/openai.js';
|
|
import { router as googleRouter } from './endpoints/google.js';
|
|
import { router as anthropicRouter } from './endpoints/anthropic.js';
|
|
import { router as tokenizersRouter } from './endpoints/tokenizers.js';
|
|
import { router as presetsRouter } from './endpoints/presets.js';
|
|
import { router as secretsRouter } from './endpoints/secrets.js';
|
|
import { router as thumbnailRouter } from './endpoints/thumbnails.js';
|
|
import { router as novelAiRouter } from './endpoints/novelai.js';
|
|
import { router as extensionsRouter } from './endpoints/extensions.js';
|
|
import { router as assetsRouter } from './endpoints/assets.js';
|
|
import { router as filesRouter } from './endpoints/files.js';
|
|
import { router as charactersRouter } from './endpoints/characters.js';
|
|
import { router as chatsRouter } from './endpoints/chats.js';
|
|
import { router as groupsRouter } from './endpoints/groups.js';
|
|
import { router as worldInfoRouter } from './endpoints/worldinfo.js';
|
|
import { router as statsRouter } from './endpoints/stats.js';
|
|
import { router as contentManagerRouter } from './endpoints/content-manager.js';
|
|
import { router as settingsRouter } from './endpoints/settings.js';
|
|
import { router as backgroundsRouter } from './endpoints/backgrounds.js';
|
|
import { router as spritesRouter } from './endpoints/sprites.js';
|
|
import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
|
|
import { router as hordeRouter } from './endpoints/horde.js';
|
|
import { router as vectorsRouter } from './endpoints/vectors.js';
|
|
import { router as translateRouter } from './endpoints/translate.js';
|
|
import { router as classifyRouter } from './endpoints/classify.js';
|
|
import { router as captionRouter } from './endpoints/caption.js';
|
|
import { router as searchRouter } from './endpoints/search.js';
|
|
import { router as openRouterRouter } from './endpoints/openrouter.js';
|
|
import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
|
|
import { router as koboldRouter } from './endpoints/backends/kobold.js';
|
|
import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
|
|
import { router as speechRouter } from './endpoints/speech.js';
|
|
import { router as azureRouter } from './endpoints/azure.js';
|
|
import { router as minimaxRouter } from './endpoints/minimax.js';
|
|
import { router as dataMaidRouter } from './endpoints/data-maid.js';
|
|
import { router as backupsRouter } from './endpoints/backups.js';
|
|
|
|
/**
|
|
* @typedef {object} ServerStartupResult
|
|
* @property {boolean} v6Failed If the server failed to start on IPv6
|
|
* @property {boolean} v4Failed If the server failed to start on IPv4
|
|
* @property {boolean} useIPv6 If use IPv6
|
|
* @property {boolean} useIPv4 If use IPv4
|
|
*/
|
|
|
|
/**
|
|
* Redirect deprecated API endpoints to their replacements.
|
|
* @param {import('express').Express} app The Express app to use
|
|
*/
|
|
export function redirectDeprecatedEndpoints(app) {
|
|
/**
|
|
* Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
|
|
* redirects, this is transparent to client-side code.
|
|
* @param {string} src The URL to redirect from.
|
|
* @param {string} destination The URL to redirect to.
|
|
*/
|
|
function redirect(src, destination) {
|
|
app.use(src, (req, res) => {
|
|
console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
|
|
// HTTP 301 causes the request to become a GET. 308 preserves the request method.
|
|
res.redirect(308, destination);
|
|
});
|
|
}
|
|
|
|
redirect('/createcharacter', '/api/characters/create');
|
|
redirect('/renamecharacter', '/api/characters/rename');
|
|
redirect('/editcharacter', '/api/characters/edit');
|
|
redirect('/editcharacterattribute', '/api/characters/edit-attribute');
|
|
redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
|
|
redirect('/deletecharacter', '/api/characters/delete');
|
|
redirect('/getcharacters', '/api/characters/all');
|
|
redirect('/getonecharacter', '/api/characters/get');
|
|
redirect('/getallchatsofcharacter', '/api/characters/chats');
|
|
redirect('/importcharacter', '/api/characters/import');
|
|
redirect('/dupecharacter', '/api/characters/duplicate');
|
|
redirect('/exportcharacter', '/api/characters/export');
|
|
redirect('/savechat', '/api/chats/save');
|
|
redirect('/getchat', '/api/chats/get');
|
|
redirect('/renamechat', '/api/chats/rename');
|
|
redirect('/delchat', '/api/chats/delete');
|
|
redirect('/exportchat', '/api/chats/export');
|
|
redirect('/importgroupchat', '/api/chats/group/import');
|
|
redirect('/importchat', '/api/chats/import');
|
|
redirect('/getgroupchat', '/api/chats/group/get');
|
|
redirect('/deletegroupchat', '/api/chats/group/delete');
|
|
redirect('/savegroupchat', '/api/chats/group/save');
|
|
redirect('/getgroups', '/api/groups/all');
|
|
redirect('/creategroup', '/api/groups/create');
|
|
redirect('/editgroup', '/api/groups/edit');
|
|
redirect('/deletegroup', '/api/groups/delete');
|
|
redirect('/getworldinfo', '/api/worldinfo/get');
|
|
redirect('/deleteworldinfo', '/api/worldinfo/delete');
|
|
redirect('/importworldinfo', '/api/worldinfo/import');
|
|
redirect('/editworldinfo', '/api/worldinfo/edit');
|
|
redirect('/getstats', '/api/stats/get');
|
|
redirect('/recreatestats', '/api/stats/recreate');
|
|
redirect('/updatestats', '/api/stats/update');
|
|
redirect('/getbackgrounds', '/api/backgrounds/all');
|
|
redirect('/delbackground', '/api/backgrounds/delete');
|
|
redirect('/renamebackground', '/api/backgrounds/rename');
|
|
redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
|
|
redirect('/savetheme', '/api/themes/save');
|
|
redirect('/getuseravatars', '/api/avatars/get');
|
|
redirect('/deleteuseravatar', '/api/avatars/delete');
|
|
redirect('/uploaduseravatar', '/api/avatars/upload');
|
|
redirect('/deletequickreply', '/api/quick-replies/delete');
|
|
redirect('/savequickreply', '/api/quick-replies/save');
|
|
redirect('/uploadimage', '/api/images/upload');
|
|
redirect('/listimgfiles/:folder', '/api/images/list/:folder');
|
|
redirect('/api/content/import', '/api/content/importURL');
|
|
redirect('/savemovingui', '/api/moving-ui/save');
|
|
redirect('/api/serpapi/search', '/api/search/serpapi');
|
|
redirect('/api/serpapi/visit', '/api/search/visit');
|
|
redirect('/api/serpapi/transcript', '/api/search/transcript');
|
|
}
|
|
|
|
/**
|
|
* Setup the routers for the endpoints.
|
|
* @param {import('express').Express} app The Express app to use
|
|
*/
|
|
export function setupPrivateEndpoints(app) {
|
|
app.use('/', userDataRouter);
|
|
app.use('/api/users', usersPrivateRouter);
|
|
app.use('/api/users', usersAdminRouter);
|
|
app.use('/api/moving-ui', movingUIRouter);
|
|
app.use('/api/images', imagesRouter);
|
|
app.use('/api/quick-replies', quickRepliesRouter);
|
|
app.use('/api/avatars', avatarsRouter);
|
|
app.use('/api/themes', themesRouter);
|
|
app.use('/api/openai', openAiRouter);
|
|
app.use('/api/google', googleRouter);
|
|
app.use('/api/anthropic', anthropicRouter);
|
|
app.use('/api/tokenizers', tokenizersRouter);
|
|
app.use('/api/presets', presetsRouter);
|
|
app.use('/api/secrets', secretsRouter);
|
|
app.use('/thumbnail', thumbnailRouter);
|
|
app.use('/api/novelai', novelAiRouter);
|
|
app.use('/api/extensions', extensionsRouter);
|
|
app.use('/api/assets', assetsRouter);
|
|
app.use('/api/files', filesRouter);
|
|
app.use('/api/characters', charactersRouter);
|
|
app.use('/api/chats', chatsRouter);
|
|
app.use('/api/groups', groupsRouter);
|
|
app.use('/api/worldinfo', worldInfoRouter);
|
|
app.use('/api/stats', statsRouter);
|
|
app.use('/api/backgrounds', backgroundsRouter);
|
|
app.use('/api/sprites', spritesRouter);
|
|
app.use('/api/content', contentManagerRouter);
|
|
app.use('/api/settings', settingsRouter);
|
|
app.use('/api/sd', stableDiffusionRouter);
|
|
app.use('/api/horde', hordeRouter);
|
|
app.use('/api/vector', vectorsRouter);
|
|
app.use('/api/translate', translateRouter);
|
|
app.use('/api/extra/classify', classifyRouter);
|
|
app.use('/api/extra/caption', captionRouter);
|
|
app.use('/api/search', searchRouter);
|
|
app.use('/api/backends/text-completions', textCompletionsRouter);
|
|
app.use('/api/openrouter', openRouterRouter);
|
|
app.use('/api/backends/kobold', koboldRouter);
|
|
app.use('/api/backends/chat-completions', chatCompletionsRouter);
|
|
app.use('/api/speech', speechRouter);
|
|
app.use('/api/azure', azureRouter);
|
|
app.use('/api/minimax', minimaxRouter);
|
|
app.use('/api/data-maid', dataMaidRouter);
|
|
app.use('/api/backups', backupsRouter);
|
|
}
|
|
|
|
/**
|
|
* Utilities for starting the express server.
|
|
*/
|
|
export class ServerStartup {
|
|
/**
|
|
* Creates a new ServerStartup instance.
|
|
* @param {import('express').Express} app The Express app to use
|
|
* @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments
|
|
*/
|
|
constructor(app, cliArgs) {
|
|
this.app = app;
|
|
this.cliArgs = cliArgs;
|
|
}
|
|
|
|
/**
|
|
* Prints a fatal error message and exits the process.
|
|
* @param {string} message
|
|
*/
|
|
#fatal(message) {
|
|
console.error(color.red(message));
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Checks if SSL options are valid. If not, it will print an error message and exit the process.
|
|
* @returns {void}
|
|
*/
|
|
#verifySslOptions() {
|
|
if (!this.cliArgs.ssl) return;
|
|
|
|
if (!this.cliArgs.certPath) {
|
|
this.#fatal('Error: SSL certificate path is required when using HTTPS. Check your config');
|
|
}
|
|
|
|
if (!this.cliArgs.keyPath) {
|
|
this.#fatal('Error: SSL key path is required when using HTTPS. Check your config');
|
|
}
|
|
|
|
if (!fs.existsSync(this.cliArgs.certPath)) {
|
|
this.#fatal('Error: SSL certificate path does not exist');
|
|
}
|
|
|
|
if (!fs.existsSync(this.cliArgs.keyPath)) {
|
|
this.#fatal('Error: SSL key path does not exist');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an HTTPS server.
|
|
* @param {URL} url The URL to listen on
|
|
* @param {number} ipVersion the ip version to use
|
|
* @returns {Promise<void>} A promise that resolves when the server is listening
|
|
*/
|
|
#createHttpsServer(url, ipVersion) {
|
|
this.#verifySslOptions();
|
|
return new Promise((resolve, reject) => {
|
|
/** @type {import('https').ServerOptions} */
|
|
const sslOptions = {
|
|
cert: fs.readFileSync(this.cliArgs.certPath),
|
|
key: fs.readFileSync(this.cliArgs.keyPath),
|
|
passphrase: String(this.cliArgs.keyPassphrase ?? ''),
|
|
};
|
|
const server = https.createServer(sslOptions, this.app);
|
|
server.on('error', reject);
|
|
server.on('listening', resolve);
|
|
|
|
let host = url.hostname;
|
|
if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
|
|
server.listen({
|
|
host: host,
|
|
port: Number(url.port || 443),
|
|
// see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
|
|
ipv6Only: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates an HTTP server.
|
|
* @param {URL} url The URL to listen on
|
|
* @param {number} ipVersion the ip version to use
|
|
* @returns {Promise<void>} A promise that resolves when the server is listening
|
|
*/
|
|
#createHttpServer(url, ipVersion) {
|
|
return new Promise((resolve, reject) => {
|
|
const server = http.createServer(this.app);
|
|
server.on('error', reject);
|
|
server.on('listening', resolve);
|
|
|
|
let host = url.hostname;
|
|
if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
|
|
server.listen({
|
|
host: host,
|
|
port: Number(url.port || 80),
|
|
// see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
|
|
ipv6Only: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Starts the server using http or https depending on config
|
|
* @param {boolean} useIPv6 If use IPv6
|
|
* @param {boolean} useIPv4 If use IPv4
|
|
* @returns {Promise<[boolean, boolean]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively
|
|
*/
|
|
async #startHTTPorHTTPS(useIPv6, useIPv4) {
|
|
let v6Failed = false;
|
|
let v4Failed = false;
|
|
|
|
const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
|
|
|
|
if (useIPv6) {
|
|
try {
|
|
await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
|
|
} catch (error) {
|
|
console.error('Warning: failed to start server on IPv6');
|
|
console.error(error);
|
|
|
|
v6Failed = true;
|
|
}
|
|
}
|
|
|
|
if (useIPv4) {
|
|
try {
|
|
await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
|
|
} catch (error) {
|
|
console.error('Warning: failed to start server on IPv4');
|
|
console.error(error);
|
|
|
|
v4Failed = true;
|
|
}
|
|
}
|
|
|
|
return [v6Failed, v4Failed];
|
|
}
|
|
|
|
/**
|
|
* Handles the case where the server failed to start on one or both protocols.
|
|
* @param {ServerStartupResult} result The results of the server startup
|
|
* @returns {void}
|
|
*/
|
|
#handleServerListenFail({ v6Failed, v4Failed, useIPv6, useIPv4 }) {
|
|
if (v6Failed && !useIPv4) {
|
|
this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');
|
|
}
|
|
|
|
if (v4Failed && !useIPv6) {
|
|
this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');
|
|
}
|
|
|
|
if (v6Failed && v4Failed) {
|
|
this.#fatal('Error: Failed to start server on both IPv6 and IPv4');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performs the server startup.
|
|
* @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup
|
|
*/
|
|
async start() {
|
|
let useIPv6 = (this.cliArgs.enableIPv6 === true);
|
|
let useIPv4 = (this.cliArgs.enableIPv4 === true);
|
|
|
|
if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') {
|
|
const ipQuery = await getHasIP();
|
|
let hasIPv6 = false, hasIPv4 = false;
|
|
|
|
hasIPv6 = this.cliArgs.listen ? ipQuery.hasIPv6Any : ipQuery.hasIPv6Local;
|
|
if (this.cliArgs.enableIPv6 === 'auto') {
|
|
useIPv6 = hasIPv6;
|
|
}
|
|
if (hasIPv6) {
|
|
if (useIPv6) {
|
|
console.log(color.green('IPv6 support detected'));
|
|
} else {
|
|
console.log('IPv6 support detected (but disabled)');
|
|
}
|
|
}
|
|
|
|
hasIPv4 = this.cliArgs.listen ? ipQuery.hasIPv4Any : ipQuery.hasIPv4Local;
|
|
if (this.cliArgs.enableIPv4 === 'auto') {
|
|
useIPv4 = hasIPv4;
|
|
}
|
|
if (hasIPv4) {
|
|
if (useIPv4) {
|
|
console.log(color.green('IPv4 support detected'));
|
|
} else {
|
|
console.log('IPv4 support detected (but disabled)');
|
|
}
|
|
}
|
|
|
|
if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') {
|
|
if (!hasIPv6 && !hasIPv4) {
|
|
console.error('Both IPv6 and IPv4 are not detected');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!useIPv6 && !useIPv4) {
|
|
console.error('Both IPv6 and IPv4 are disabled or not detected');
|
|
process.exit(1);
|
|
}
|
|
|
|
const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
|
|
const result = { v6Failed, v4Failed, useIPv6, useIPv4 };
|
|
this.#handleServerListenFail(result);
|
|
return result;
|
|
}
|
|
}
|