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 scaleAltRouter } from './endpoints/backends/scale-alt.js'; import { router as speechRouter } from './endpoints/speech.js'; import { router as azureRouter } from './endpoints/azure.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/backends/scale-alt', scaleAltRouter); app.use('/api/speech', speechRouter); app.use('/api/azure', azureRouter); } /** * 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} A promise that resolves when the server is listening */ #createHttpsServer(url, ipVersion) { this.#verifySslOptions(); return new Promise((resolve, reject) => { const sslOptions = { cert: fs.readFileSync(this.cliArgs.certPath), key: fs.readFileSync(this.cliArgs.keyPath), }; 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} 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} 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; } }