Blame Raw
Cohee · 51ad27fb · · 452 lines (19.3 KB)
3 contributors
1import https from 'node:https';
2import http from 'node:http';
3import fs from 'node:fs';
4import { color, urlHostnameToIPv6, getHasIP } from './util.js';
5
6// Express routers
7import { router as userDataRouter } from './users.js';
8import { router as usersPrivateRouter } from './endpoints/users-private.js';
9import { router as usersAdminRouter } from './endpoints/users-admin.js';
10import { router as movingUIRouter } from './endpoints/moving-ui.js';
11import { router as imagesRouter } from './endpoints/images.js';
12import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
13import { router as avatarsRouter } from './endpoints/avatars.js';
14import { router as themesRouter } from './endpoints/themes.js';
15import { router as openAiRouter } from './endpoints/openai.js';
16import { router as googleRouter } from './endpoints/google.js';
17import { router as anthropicRouter } from './endpoints/anthropic.js';
18import { router as tokenizersRouter } from './endpoints/tokenizers.js';
19import { router as presetsRouter } from './endpoints/presets.js';
20import { router as secretsRouter } from './endpoints/secrets.js';
21import { router as thumbnailRouter } from './endpoints/thumbnails.js';
22import { router as novelAiRouter } from './endpoints/novelai.js';
23import { router as extensionsRouter } from './endpoints/extensions.js';
24import { router as assetsRouter } from './endpoints/assets.js';
25import { router as filesRouter } from './endpoints/files.js';
26import { router as charactersRouter } from './endpoints/characters.js';
27import { router as chatsRouter } from './endpoints/chats.js';
28import { router as groupsRouter } from './endpoints/groups.js';
29import { router as worldInfoRouter } from './endpoints/worldinfo.js';
30import { router as statsRouter } from './endpoints/stats.js';
31import { router as contentManagerRouter } from './endpoints/content-manager.js';
32import { router as settingsRouter } from './endpoints/settings.js';
33import { router as backgroundsRouter } from './endpoints/backgrounds.js';
34import { router as spritesRouter } from './endpoints/sprites.js';
35import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
36import { router as hordeRouter } from './endpoints/horde.js';
37import { router as vectorsRouter } from './endpoints/vectors.js';
38import { router as translateRouter } from './endpoints/translate.js';
39import { router as classifyRouter } from './endpoints/classify.js';
40import { router as captionRouter } from './endpoints/caption.js';
41import { router as searchRouter } from './endpoints/search.js';
42import { router as openRouterRouter } from './endpoints/openrouter.js';
43import { router as nanogptRouter } from './endpoints/nanogpt.js';
44import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
45import { router as koboldRouter } from './endpoints/backends/kobold.js';
46import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
47import { router as speechRouter } from './endpoints/speech.js';
48import { router as azureRouter } from './endpoints/azure.js';
49import { router as minimaxRouter } from './endpoints/minimax.js';
50import { router as dataMaidRouter } from './endpoints/data-maid.js';
51import { router as backupsRouter } from './endpoints/backups.js';
52import { router as imageMetadataRouter } from './endpoints/image-metadata.js';
53import { router as volcengineRouter } from './endpoints/volcengine.js';
54
55/**
56 * @typedef {object} ServerStartupResult
57 * @property {boolean} v6Failed If the server failed to start on IPv6
58 * @property {boolean} v4Failed If the server failed to start on IPv4
59 * @property {unknown} [v6Error] The IPv6 server startup error
60 * @property {unknown} [v4Error] The IPv4 server startup error
61 * @property {boolean} useIPv6 If use IPv6
62 * @property {boolean} useIPv4 If use IPv4
63 */
64
65/**
66 * Redirect deprecated API endpoints to their replacements.
67 * @param {import('express').Express} app The Express app to use
68 */
69export function redirectDeprecatedEndpoints(app) {
70 /**
71 * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
72 * redirects, this is transparent to client-side code.
73 * @param {string} src The URL to redirect from.
74 * @param {string} destination The URL to redirect to.
75 */
76 function redirect(src, destination) {
77 app.use(src, (req, res) => {
78 console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
79 // HTTP 301 causes the request to become a GET. 308 preserves the request method.
80 res.redirect(308, destination);
81 });
82 }
83
84 redirect('/createcharacter', '/api/characters/create');
85 redirect('/renamecharacter', '/api/characters/rename');
86 redirect('/editcharacter', '/api/characters/edit');
87 redirect('/editcharacterattribute', '/api/characters/edit-attribute');
88 redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
89 redirect('/deletecharacter', '/api/characters/delete');
90 redirect('/getcharacters', '/api/characters/all');
91 redirect('/getonecharacter', '/api/characters/get');
92 redirect('/getallchatsofcharacter', '/api/characters/chats');
93 redirect('/importcharacter', '/api/characters/import');
94 redirect('/dupecharacter', '/api/characters/duplicate');
95 redirect('/exportcharacter', '/api/characters/export');
96 redirect('/savechat', '/api/chats/save');
97 redirect('/getchat', '/api/chats/get');
98 redirect('/renamechat', '/api/chats/rename');
99 redirect('/delchat', '/api/chats/delete');
100 redirect('/exportchat', '/api/chats/export');
101 redirect('/importgroupchat', '/api/chats/group/import');
102 redirect('/importchat', '/api/chats/import');
103 redirect('/getgroupchat', '/api/chats/group/get');
104 redirect('/deletegroupchat', '/api/chats/group/delete');
105 redirect('/savegroupchat', '/api/chats/group/save');
106 redirect('/getgroups', '/api/groups/all');
107 redirect('/creategroup', '/api/groups/create');
108 redirect('/editgroup', '/api/groups/edit');
109 redirect('/deletegroup', '/api/groups/delete');
110 redirect('/getworldinfo', '/api/worldinfo/get');
111 redirect('/deleteworldinfo', '/api/worldinfo/delete');
112 redirect('/importworldinfo', '/api/worldinfo/import');
113 redirect('/editworldinfo', '/api/worldinfo/edit');
114 redirect('/getstats', '/api/stats/get');
115 redirect('/recreatestats', '/api/stats/recreate');
116 redirect('/updatestats', '/api/stats/update');
117 redirect('/getbackgrounds', '/api/backgrounds/all');
118 redirect('/delbackground', '/api/backgrounds/delete');
119 redirect('/renamebackground', '/api/backgrounds/rename');
120 redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
121 redirect('/savetheme', '/api/themes/save');
122 redirect('/getuseravatars', '/api/avatars/get');
123 redirect('/deleteuseravatar', '/api/avatars/delete');
124 redirect('/uploaduseravatar', '/api/avatars/upload');
125 redirect('/deletequickreply', '/api/quick-replies/delete');
126 redirect('/savequickreply', '/api/quick-replies/save');
127 redirect('/uploadimage', '/api/images/upload');
128 redirect('/listimgfiles/:folder', '/api/images/list/:folder');
129 redirect('/api/content/import', '/api/content/importURL');
130 redirect('/savemovingui', '/api/moving-ui/save');
131 redirect('/api/serpapi/search', '/api/search/serpapi');
132 redirect('/api/serpapi/visit', '/api/search/visit');
133 redirect('/api/serpapi/transcript', '/api/search/transcript');
134}
135
136/**
137 * Setup the routers for the endpoints.
138 * @param {import('express').Express} app The Express app to use
139 */
140export function setupPrivateEndpoints(app) {
141 app.use('/', userDataRouter);
142 app.use('/api/users', usersPrivateRouter);
143 app.use('/api/users', usersAdminRouter);
144 app.use('/api/moving-ui', movingUIRouter);
145 app.use('/api/images', imagesRouter);
146 app.use('/api/quick-replies', quickRepliesRouter);
147 app.use('/api/avatars', avatarsRouter);
148 app.use('/api/themes', themesRouter);
149 app.use('/api/openai', openAiRouter);
150 app.use('/api/google', googleRouter);
151 app.use('/api/anthropic', anthropicRouter);
152 app.use('/api/tokenizers', tokenizersRouter);
153 app.use('/api/presets', presetsRouter);
154 app.use('/api/secrets', secretsRouter);
155 app.use('/thumbnail', thumbnailRouter);
156 app.use('/api/novelai', novelAiRouter);
157 app.use('/api/extensions', extensionsRouter);
158 app.use('/api/assets', assetsRouter);
159 app.use('/api/files', filesRouter);
160 app.use('/api/characters', charactersRouter);
161 app.use('/api/chats', chatsRouter);
162 app.use('/api/groups', groupsRouter);
163 app.use('/api/worldinfo', worldInfoRouter);
164 app.use('/api/stats', statsRouter);
165 app.use('/api/backgrounds', backgroundsRouter);
166 app.use('/api/sprites', spritesRouter);
167 app.use('/api/content', contentManagerRouter);
168 app.use('/api/settings', settingsRouter);
169 app.use('/api/sd', stableDiffusionRouter);
170 app.use('/api/horde', hordeRouter);
171 app.use('/api/vector', vectorsRouter);
172 app.use('/api/translate', translateRouter);
173 app.use('/api/extra/classify', classifyRouter);
174 app.use('/api/extra/caption', captionRouter);
175 app.use('/api/search', searchRouter);
176 app.use('/api/backends/text-completions', textCompletionsRouter);
177 app.use('/api/openrouter', openRouterRouter);
178 app.use('/api/nanogpt', nanogptRouter);
179 app.use('/api/backends/kobold', koboldRouter);
180 app.use('/api/backends/chat-completions', chatCompletionsRouter);
181 app.use('/api/speech', speechRouter);
182 app.use('/api/azure', azureRouter);
183 app.use('/api/volcengine', volcengineRouter);
184 app.use('/api/minimax', minimaxRouter);
185 app.use('/api/data-maid', dataMaidRouter);
186 app.use('/api/backups', backupsRouter);
187 app.use('/api/image-metadata', imageMetadataRouter);
188}
189
190/**
191 * Utilities for starting the express server.
192 */
193export class ServerStartup {
194 /**
195 * Creates a new ServerStartup instance.
196 * @param {import('express').Express} app The Express app to use
197 * @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments
198 */
199 constructor(app, cliArgs) {
200 this.app = app;
201 this.cliArgs = cliArgs;
202 }
203
204 /**
205 * Prints a fatal error message and exits the process.
206 * @param {string} message
207 */
208 #fatal(message) {
209 console.error(color.red(message));
210 process.exit(1);
211 }
212
213 /**
214 * Checks if the error was caused by an occupied port.
215 * @param {unknown} error
216 * @returns {error is NodeJS.ErrnoException}
217 */
218 #isAddressInUseError(error) {
219 return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EADDRINUSE';
220 }
221
222 /**
223 * Gets a readable listen address for an IP version.
224 * @param {URL} url The URL to listen on
225 * @param {number} ipVersion The IP version to use
226 * @returns {string}
227 */
228 #getListenAddress(url, ipVersion) {
229 const host = ipVersion === 6 ? urlHostnameToIPv6(url.hostname) : url.hostname;
230 return `${host}:${Number(url.port || (this.cliArgs.ssl ? 443 : 80))}`;
231 }
232
233 /**
234 * Builds a user-facing error for an occupied port.
235 * @param {URL} url The URL that failed to bind
236 * @param {number} ipVersion The IP version that failed
237 * @returns {string}
238 */
239 #getAddressInUseMessage(url, ipVersion) {
240 const listenAddress = this.#getListenAddress(url, ipVersion);
241 return `Address ${listenAddress} is already in use. Another SillyTavern instance may already be running. Stop the other process or change "port" in config.yaml.`;
242 }
243
244 /**
245 * Checks if SSL options are valid. If not, it will print an error message and exit the process.
246 * @returns {void}
247 */
248 #verifySslOptions() {
249 if (!this.cliArgs.ssl) return;
250
251 if (!this.cliArgs.certPath) {
252 this.#fatal('Error: SSL certificate path is required when using HTTPS. Check your config');
253 }
254
255 if (!this.cliArgs.keyPath) {
256 this.#fatal('Error: SSL key path is required when using HTTPS. Check your config');
257 }
258
259 if (!fs.existsSync(this.cliArgs.certPath)) {
260 this.#fatal('Error: SSL certificate path does not exist');
261 }
262
263 if (!fs.existsSync(this.cliArgs.keyPath)) {
264 this.#fatal('Error: SSL key path does not exist');
265 }
266 }
267
268 /**
269 * Creates an HTTPS server.
270 * @param {URL} url The URL to listen on
271 * @param {number} ipVersion the ip version to use
272 * @returns {Promise<void>} A promise that resolves when the server is listening
273 */
274 #createHttpsServer(url, ipVersion) {
275 this.#verifySslOptions();
276 return new Promise((resolve, reject) => {
277 /** @type {import('https').ServerOptions} */
278 const sslOptions = {
279 cert: fs.readFileSync(this.cliArgs.certPath),
280 key: fs.readFileSync(this.cliArgs.keyPath),
281 passphrase: String(this.cliArgs.keyPassphrase ?? ''),
282 };
283 const server = https.createServer(sslOptions, this.app);
284 server.on('error', reject);
285 server.on('listening', resolve);
286
287 let host = url.hostname;
288 if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
289 server.listen({
290 host: host,
291 port: Number(url.port || 443),
292 // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
293 ipv6Only: true,
294 });
295 });
296 }
297
298 /**
299 * Creates an HTTP server.
300 * @param {URL} url The URL to listen on
301 * @param {number} ipVersion the ip version to use
302 * @returns {Promise<void>} A promise that resolves when the server is listening
303 */
304 #createHttpServer(url, ipVersion) {
305 return new Promise((resolve, reject) => {
306 const server = http.createServer(this.app);
307 server.on('error', reject);
308 server.on('listening', resolve);
309
310 let host = url.hostname;
311 if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
312 server.listen({
313 host: host,
314 port: Number(url.port || 80),
315 // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
316 ipv6Only: true,
317 });
318 });
319 }
320
321 /**
322 * Starts the server using http or https depending on config
323 * @param {boolean} useIPv6 If use IPv6
324 * @param {boolean} useIPv4 If use IPv4
325 * @returns {Promise<[boolean, boolean, unknown, unknown]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively, and the corresponding errors
326 */
327 async #startHTTPorHTTPS(useIPv6, useIPv4) {
328 let v6Failed = false;
329 let v4Failed = false;
330 let v6Error;
331 let v4Error;
332
333 const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
334
335 if (useIPv6) {
336 try {
337 await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
338 } catch (error) {
339 console.error('Warning: failed to start server on IPv6');
340 if (this.#isAddressInUseError(error)) {
341 console.error(this.#getAddressInUseMessage(this.cliArgs.getIPv6ListenUrl(), 6));
342 } else {
343 console.error(error);
344 }
345
346 v6Failed = true;
347 v6Error = error;
348 }
349 }
350
351 if (useIPv4) {
352 try {
353 await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
354 } catch (error) {
355 console.error('Warning: failed to start server on IPv4');
356 if (this.#isAddressInUseError(error)) {
357 console.error(this.#getAddressInUseMessage(this.cliArgs.getIPv4ListenUrl(), 4));
358 } else {
359 console.error(error);
360 }
361
362 v4Failed = true;
363 v4Error = error;
364 }
365 }
366
367 return [v6Failed, v4Failed, v6Error, v4Error];
368 }
369
370 /**
371 * Handles the case where the server failed to start on one or both protocols.
372 * @param {ServerStartupResult} result The results of the server startup
373 * @returns {void}
374 */
375 #handleServerListenFail({ v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 }) {
376 if (v6Failed && !useIPv4) {
377 if (this.#isAddressInUseError(v6Error)) {
378 this.#fatal('Error: Startup aborted because IPv6 is the only enabled protocol and its listen port is already in use.');
379 }
380 this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');
381 }
382
383 if (v4Failed && !useIPv6) {
384 if (this.#isAddressInUseError(v4Error)) {
385 this.#fatal('Error: Startup aborted because IPv4 is the only enabled protocol and its listen port is already in use.');
386 }
387 this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');
388 }
389
390 if (v6Failed && v4Failed) {
391 if (this.#isAddressInUseError(v6Error) && this.#isAddressInUseError(v4Error)) {
392 this.#fatal('Error: Failed to start server because the configured IPv6 and IPv4 listen ports are already in use.');
393 }
394 this.#fatal('Error: Failed to start server on both IPv6 and IPv4');
395 }
396 }
397
398 /**
399 * Performs the server startup.
400 * @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup
401 */
402 async start() {
403 let useIPv6 = (this.cliArgs.enableIPv6 === true);
404 let useIPv4 = (this.cliArgs.enableIPv4 === true);
405
406 if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') {
407 const ipQuery = await getHasIP();
408 let hasIPv6 = false, hasIPv4 = false;
409
410 hasIPv6 = this.cliArgs.listen ? ipQuery.hasIPv6Any : ipQuery.hasIPv6Local;
411 if (this.cliArgs.enableIPv6 === 'auto') {
412 useIPv6 = hasIPv6;
413 }
414 if (hasIPv6) {
415 if (useIPv6) {
416 console.log(color.green('IPv6 support detected'));
417 } else {
418 console.log('IPv6 support detected (but disabled)');
419 }
420 }
421
422 hasIPv4 = this.cliArgs.listen ? ipQuery.hasIPv4Any : ipQuery.hasIPv4Local;
423 if (this.cliArgs.enableIPv4 === 'auto') {
424 useIPv4 = hasIPv4;
425 }
426 if (hasIPv4) {
427 if (useIPv4) {
428 console.log(color.green('IPv4 support detected'));
429 } else {
430 console.log('IPv4 support detected (but disabled)');
431 }
432 }
433
434 if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') {
435 if (!hasIPv6 && !hasIPv4) {
436 console.error('Both IPv6 and IPv4 are not detected');
437 process.exit(1);
438 }
439 }
440 }
441
442 if (!useIPv6 && !useIPv4) {
443 console.error('Both IPv6 and IPv4 are disabled or not detected');
444 process.exit(1);
445 }
446
447 const [v6Failed, v4Failed, v6Error, v4Error] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
448 const result = { v6Failed, v4Failed, v6Error, v4Error, useIPv6, useIPv4 };
449 this.#handleServerListenFail(result);
450 return result;
451 }
452}