Refactor server startup

e7fcd0072bce93f53981a40f682c0dff722bb168

Cohee <18619528+Cohee1207@users.noreply.github.com>

7 files changed, +847 -756Ignore whitespace
default/config.yaml+5 -0
@@ -28,6 +28,11 @@ port: 8000
2828# - Use -1 to use the server port.
2929# - Specify a port to override the default.
3030autorunPortOverride: -1
31+# -- SSL options --
32+ssl:
33+ enabled: false
34+ certPath: "./certs/cert.pem"
35+ keyPath: "./certs/privkey.pem"
3136# -- SECURITY CONFIGURATION --
3237# Toggle whitelist mode
3338whitelistMode: true
server.js+71 -755
@@ -1,10 +1,6 @@
11#!/usr/bin/env node
22
33// native node modules
4-import fs from 'node:fs';
5-import http from 'node:http';
6-import https from 'node:https';
7-import os from 'os';
84import path from 'node:path';
95import util from 'node:util';
106import net from 'node:net';
@@ -12,12 +8,6 @@ import dns from 'node:dns';
128import process from 'node:process';
139import { fileURLToPath } from 'node:url';
1410
15-// cli/fs related library imports
16-import open from 'open';
17-import yargs from 'yargs/yargs';
18-import { hideBin } from 'yargs/helpers';
19-
20-// express/server related library imports
2111import cors from 'cors';
2212import { csrfSync } from 'csrf-sync';
2313import express from 'express';
@@ -27,17 +17,11 @@ import multer from 'multer';
2717import responseTime from 'response-time';
2818import helmet from 'helmet';
2919import bodyParser from 'body-parser';
30-
20+import open from 'open';
31-// net related library imports
3221import fetch from 'node-fetch';
33-import ipRegex from 'ip-regex';
34-
35-// Unrestrict console logs display limit
36-util.inspect.defaultOptions.maxArrayLength = null;
37-util.inspect.defaultOptions.maxStringLength = null;
38-util.inspect.defaultOptions.depth = 4;
3922
4023// local library imports
24+import { CommandLineParser } from './src/command-line.js';
4125import { loadPlugins } from './src/plugin-loader.js';
4226import {
4327 initUserStorage,
@@ -53,6 +37,8 @@ import {
5337 shouldRedirectToLogin,
5438 tryAutoLogin,
5539 router as userDataRouter,
40+ cleanUploads,
41+ getSessionCookieAge,
5642} from './src/users.js';
5743
5844import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -62,18 +48,16 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr
6248import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
6349import initRequestProxy from './src/request-proxy.js';
6450import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
51+import corsProxyMiddleware from './src/middleware/corsProxy.js';
6552import {
6653 getVersion,
6754 getConfigValue,
6855 color,
69- forwardFetchResponse,
7056 removeColorFormatting,
7157 getSeparator,
72- stringToBool,
73- urlHostnameToIPv6,
74- canResolve,
7558 safeReadFileSync,
7659 setupLogLevel,
60+ setWindowTitle,
7761} from './src/util.js';
7862import { UPLOADS_DIRECTORY } from './src/constants.js';
7963import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -82,45 +66,15 @@ import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
8266import { router as usersPublicRouter } from './src/endpoints/users-public.js';
8367import { router as usersPrivateRouter } from './src/endpoints/users-private.js';
8468import { router as usersAdminRouter } from './src/endpoints/users-admin.js';
8569import { routerinit as movingUIRouterstatsInit, onExit as statsOnExit } from './src/endpoints/moving-uistats.js';
8670import { router as imagesRoutercheckForNewContent } from './src/endpoints/imagescontent-manager.js';
8771import { routerinit as quickRepliesRoutersettingsInit } from './src/endpoints/quick-repliessettings.js';
8872import { routerredirectDeprecatedEndpoints, asServerStartup, avatarsRoutersetupPrivateEndpoints } from './src/endpoints/avatarsserver-startup.js';
89-import { router as themesRouter } from './src/endpoints/themes.js';
73+
90-import { router as openAiRouter } from './src/endpoints/openai.js';
74+// Unrestrict console logs display limit
91-import { router as googleRouter } from './src/endpoints/google.js';
75+util.inspect.defaultOptions.maxArrayLength = null;
92-import { router as anthropicRouter } from './src/endpoints/anthropic.js';
76+util.inspect.defaultOptions.maxStringLength = null;
93-import { router as tokenizersRouter } from './src/endpoints/tokenizers.js';
77+util.inspect.defaultOptions.depth = 4;
94-import { router as presetsRouter } from './src/endpoints/presets.js';
95-import { router as secretsRouter } from './src/endpoints/secrets.js';
96-import { router as thumbnailRouter } from './src/endpoints/thumbnails.js';
97-import { router as novelAiRouter } from './src/endpoints/novelai.js';
98-import { router as extensionsRouter } from './src/endpoints/extensions.js';
99-import { router as assetsRouter } from './src/endpoints/assets.js';
100-import { router as filesRouter } from './src/endpoints/files.js';
101-import { router as charactersRouter } from './src/endpoints/characters.js';
102-import { router as chatsRouter } from './src/endpoints/chats.js';
103-import { router as groupsRouter } from './src/endpoints/groups.js';
104-import { router as worldInfoRouter } from './src/endpoints/worldinfo.js';
105-import { router as statsRouter, init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
106-import { router as backgroundsRouter } from './src/endpoints/backgrounds.js';
107-import { router as spritesRouter } from './src/endpoints/sprites.js';
108-import { router as contentManagerRouter, checkForNewContent } from './src/endpoints/content-manager.js';
109-import { router as settingsRouter, init as settingsInit } from './src/endpoints/settings.js';
110-import { router as stableDiffusionRouter } from './src/endpoints/stable-diffusion.js';
111-import { router as hordeRouter } from './src/endpoints/horde.js';
112-import { router as vectorsRouter } from './src/endpoints/vectors.js';
113-import { router as translateRouter } from './src/endpoints/translate.js';
114-import { router as classifyRouter } from './src/endpoints/classify.js';
115-import { router as captionRouter } from './src/endpoints/caption.js';
116-import { router as searchRouter } from './src/endpoints/search.js';
117-import { router as openRouterRouter } from './src/endpoints/openrouter.js';
118-import { router as chatCompletionsRouter } from './src/endpoints/backends/chat-completions.js';
119-import { router as koboldRouter } from './src/endpoints/backends/kobold.js';
120-import { router as textCompletionsRouter } from './src/endpoints/backends/text-completions.js';
121-import { router as scaleAltRouter } from './src/endpoints/backends/scale-alt.js';
122-import { router as speechRouter } from './src/endpoints/speech.js';
123-import { router as azureRouter } from './src/endpoints/azure.js';
12478
12579// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
12680// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -130,122 +84,14 @@ if (process.versions && process.versions.node && process.versions.node.match(/20
13084 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
13185}
13286
13387const DEFAULT_PORTcliParser = 8000new CommandLineParser();
13488const DEFAULT_AUTORUNcliArgs = falsecliParser.parse(process.argv);
135-const DEFAULT_LISTEN = false;
89+globalThis.DATA_ROOT = cliArgs.dataRoot;
136-const DEFAULT_LISTEN_ADDRESS_IPV6 = '[::]';
90+
137-const DEFAULT_LISTEN_ADDRESS_IPV4 = '0.0.0.0';
91+if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
138-const DEFAULT_CORS_PROXY = false;
92+ console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
139-const DEFAULT_WHITELIST = true;
93+ process.exit(1);
140-const DEFAULT_ACCOUNTS = false;
94+}
141-const DEFAULT_CSRF_DISABLED = false;
142-const DEFAULT_BASIC_AUTH = false;
143-const DEFAULT_PER_USER_BASIC_AUTH = false;
144-
145-const DEFAULT_ENABLE_IPV6 = false;
146-const DEFAULT_ENABLE_IPV4 = true;
147-
148-const DEFAULT_PREFER_IPV6 = false;
149-
150-const DEFAULT_AVOID_LOCALHOST = false;
151-
152-const DEFAULT_AUTORUN_HOSTNAME = 'auto';
153-const DEFAULT_AUTORUN_PORT = -1;
154-
155-const DEFAULT_PROXY_ENABLED = false;
156-const DEFAULT_PROXY_URL = '';
157-const DEFAULT_PROXY_BYPASS = [];
158-
159-const cliArguments = yargs(hideBin(process.argv))
160- .usage('Usage: <your-start-script> <command> [options]')
161- .option('enableIPv6', {
162- type: 'string',
163- default: null,
164- describe: `Enables IPv6.\n[config default: ${DEFAULT_ENABLE_IPV6}]`,
165- }).option('enableIPv4', {
166- type: 'string',
167- default: null,
168- describe: `Enables IPv4.\n[config default: ${DEFAULT_ENABLE_IPV4}]`,
169- }).option('port', {
170- type: 'number',
171- default: null,
172- describe: `Sets the port under which SillyTavern will run.\nIf not provided falls back to yaml config 'port'.\n[config default: ${DEFAULT_PORT}]`,
173- }).option('dnsPreferIPv6', {
174- type: 'boolean',
175- default: null,
176- describe: `Prefers IPv6 for dns\nyou should probably have the enabled if you're on an IPv6 only network\nIf not provided falls back to yaml config 'preferIPv6'.\n[config default: ${DEFAULT_PREFER_IPV6}]`,
177- }).option('autorun', {
178- type: 'boolean',
179- default: null,
180- describe: `Automatically launch SillyTavern in the browser.\nAutorun is automatically disabled if --ssl is set to true.\nIf not provided falls back to yaml config 'autorun'.\n[config default: ${DEFAULT_AUTORUN}]`,
181- }).option('autorunHostname', {
182- type: 'string',
183- default: null,
184- describe: 'the autorun hostname, probably best left on \'auto\'.\nuse values like \'localhost\', \'st.example.com\'',
185- }).option('autorunPortOverride', {
186- type: 'string',
187- default: null,
188- describe: 'Overrides the port for autorun with open your browser with this port and ignore what port the server is running on. -1 is use server port',
189- }).option('listen', {
190- type: 'boolean',
191- default: null,
192- describe: `SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If false, will limit it only to internal localhost (127.0.0.1).\nIf not provided falls back to yaml config 'listen'.\n[config default: ${DEFAULT_LISTEN}]`,
193- }).option('listenAddressIPv6', {
194- type: 'string',
195- default: null,
196- describe: 'Set SillyTavern to listen to a specific IPv6 address. If not set, it will fallback to listen to all.\n[config default: [::] ]',
197- }).option('listenAddressIPv4', {
198- type: 'string',
199- default: null,
200- describe: 'Set SillyTavern to listen to a specific IPv4 address. If not set, it will fallback to listen to all.\n[config default: 0.0.0.0 ]',
201- }).option('corsProxy', {
202- type: 'boolean',
203- default: null,
204- describe: `Enables CORS proxy\nIf not provided falls back to yaml config 'enableCorsProxy'.\n[config default: ${DEFAULT_CORS_PROXY}]`,
205- }).option('disableCsrf', {
206- type: 'boolean',
207- default: null,
208- describe: 'Disables CSRF protection',
209- }).option('ssl', {
210- type: 'boolean',
211- default: false,
212- describe: 'Enables SSL',
213- }).option('certPath', {
214- type: 'string',
215- default: 'certs/cert.pem',
216- describe: 'Path to your certificate file.',
217- }).option('keyPath', {
218- type: 'string',
219- default: 'certs/privkey.pem',
220- describe: 'Path to your private key file.',
221- }).option('whitelist', {
222- type: 'boolean',
223- default: null,
224- describe: 'Enables whitelist mode',
225- }).option('dataRoot', {
226- type: 'string',
227- default: null,
228- describe: 'Root directory for data storage',
229- }).option('avoidLocalhost', {
230- type: 'boolean',
231- default: null,
232- describe: 'Avoids using \'localhost\' for autorun in auto mode.\nuse if you don\'t have \'localhost\' in your hosts file',
233- }).option('basicAuthMode', {
234- type: 'boolean',
235- default: null,
236- describe: 'Enables basic authentication',
237- }).option('requestProxyEnabled', {
238- type: 'boolean',
239- default: null,
240- describe: 'Enables a use of proxy for outgoing requests',
241- }).option('requestProxyUrl', {
242- type: 'string',
243- default: null,
244- describe: 'Request proxy URL (HTTP or SOCKS protocols)',
245- }).option('requestProxyBypass', {
246- type: 'array',
247- describe: 'Request proxy bypass list (space separated list of hosts)',
248- }).parseSync();
24995
25096// change all relative paths
25197const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -259,53 +105,10 @@ app.use(helmet({
259105app.use(compression());
260106app.use(responseTime());
261107
262-
263-/** @type {number} */
264-const server_port = cliArguments.port ?? getConfigValue('port', DEFAULT_PORT, 'number');
265108/** @type {boolean} */
266109const autorunenableAccounts = (cliArguments.autorun ?? getConfigValue('autorunenableUserAccounts', DEFAULT_AUTORUNfalse, 'boolean')) && !cliArguments.ssl;
267-/** @type {boolean} */
268-const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN, 'boolean');
269-/** @type {string} */
270-const listenAddressIPv6 = cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', DEFAULT_LISTEN_ADDRESS_IPV6);
271-/** @type {string} */
272-const listenAddressIPv4 = cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', DEFAULT_LISTEN_ADDRESS_IPV4);
273-/** @type {boolean} */
274-const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY, 'boolean');
275-const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST, 'boolean');
276-/** @type {string} */
277-globalThis.DATA_ROOT = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
278-/** @type {boolean} */
279-const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED, 'boolean');
280-const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH, 'boolean');
281-const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH, 'boolean');
282-/** @type {boolean} */
283-const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS, 'boolean');
284-
285-const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
286110
287-
111+if (cliArgs.dnsPreferIPv6) {
288-/** @type {boolean | string} */
289-let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6)) ?? DEFAULT_ENABLE_IPV6;
290-/** @type {boolean | string} */
291-let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4)) ?? DEFAULT_ENABLE_IPV4;
292-
293-/** @type {string} */
294-const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);
295-/** @type {number} */
296-const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT, 'number');
297-
298-/** @type {boolean} */
299-const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6, 'boolean');
300-
301-/** @type {boolean} */
302-const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST, 'boolean');
303-
304-const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED, 'boolean');
305-const proxyUrl = cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', DEFAULT_PROXY_URL);
306-const proxyBypass = cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', DEFAULT_PROXY_BYPASS);
307-
308-if (dnsPreferIPv6) {
309112 // Set default DNS resolution order to IPv6 first
310113 dns.setDefaultResultOrder('ipv6first');
311114 console.log('Preferring IPv6 for DNS resolution');
@@ -315,23 +118,6 @@ if (dnsPreferIPv6) {
315118 console.log('Preferring IPv4 for DNS resolution');
316119}
317120
318-
319-const ipOptions = [true, 'auto', false];
320-
321-if (!ipOptions.includes(enableIPv6)) {
322- console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV6);
323- enableIPv6 = DEFAULT_ENABLE_IPV6;
324-}
325-if (!ipOptions.includes(enableIPv4)) {
326- console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV4);
327- enableIPv4 = DEFAULT_ENABLE_IPV4;
328-}
329-
330-if (enableIPv6 === false && enableIPv4 === false) {
331- console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
332- process.exit(1);
333-}
334-
335121// CORS Settings //
336122const CORS = cors({
337123 origin: 'null',
@@ -340,59 +126,23 @@ const CORS = cors({
340126
341127app.use(CORS);
342128
343129if (cliArgs.listen && cliArgs.basicAuthMode) {
344130 app.use(basicAuthMiddleware);
345131}
346132
347133if (enableWhitelistcliArgs.whitelistMode) {
348134 app.use(whitelistMiddleware());
349135}
350136
351137if (cliArgs.listen) {
352138 app.use(accessLoggerMiddleware());
353139}
354140
355141if (cliArgs.enableCorsProxy) {
356142 app.use(bodyParser.json({
357143 limit: '200mb',
358144 }));
359- console.log('Enabling CORS proxy');
145+ app.use('/proxy/:url(*)', corsProxyMiddleware);
360-
361- app.use('/proxy/:url(*)', async (req, res) => {
362- const url = req.params.url; // get the url from the request path
363-
364- // Disallow circular requests
365- const serverUrl = req.protocol + '://' + req.get('host');
366- if (url.startsWith(serverUrl)) {
367- return res.status(400).send('Circular requests are not allowed');
368- }
369-
370- try {
371- const headers = JSON.parse(JSON.stringify(req.headers));
372- const headersToRemove = [
373- 'x-csrf-token', 'host', 'referer', 'origin', 'cookie',
374- 'x-forwarded-for', 'x-forwarded-protocol', 'x-forwarded-proto',
375- 'x-forwarded-host', 'x-real-ip', 'sec-fetch-mode',
376- 'sec-fetch-site', 'sec-fetch-dest',
377- ];
378-
379- headersToRemove.forEach(header => delete headers[header]);
380-
381- const bodyMethods = ['POST', 'PUT', 'PATCH'];
382-
383- const response = await fetch(url, {
384- method: req.method,
385- headers: headers,
386- body: bodyMethods.includes(req.method) ? JSON.stringify(req.body) : undefined,
387- });
388-
389- // Copy over relevant response params to the proxy response
390- forwardFetchResponse(response, res);
391-
392- } catch (error) {
393- res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error);
394- }
395- });
396146} else {
397147 app.use('/proxy/:url(*)', async (_, res) => {
398148 const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
@@ -401,74 +151,6 @@ if (enableCorsProxy) {
401151 });
402152}
403153
404-function getSessionCookieAge() {
405- // Defaults to "no expiration" if not set
406- const configValue = getConfigValue('sessionTimeout', -1, 'number');
407-
408- // Convert to milliseconds
409- if (configValue > 0) {
410- return configValue * 1000;
411- }
412-
413- // "No expiration" is just 400 days as per RFC 6265
414- if (configValue < 0) {
415- return 400 * 24 * 60 * 60 * 1000;
416- }
417-
418- // 0 means session cookie is deleted when the browser session ends
419- // (depends on the implementation of the browser)
420- return undefined;
421-}
422-
423-/**
424- * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
425- *
426- * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing:
427- * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`).
428- * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`).
429- * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`).
430- * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`).
431- */
432-async function getHasIP() {
433- let hasIPv6 = false;
434- let hasIPv6Local = false;
435-
436- let hasIPv4 = false;
437- let hasIPv4Local = false;
438-
439- const interfaces = os.networkInterfaces();
440-
441- for (const iface of Object.values(interfaces)) {
442- if (iface === undefined) {
443- continue;
444- }
445-
446- for (const info of iface) {
447- if (info.family === 'IPv6') {
448- hasIPv6 = true;
449- if (info.address === '::1') {
450- hasIPv6Local = true;
451- }
452- }
453-
454- if (info.family === 'IPv4') {
455- hasIPv4 = true;
456- if (info.address === '127.0.0.1') {
457- hasIPv4Local = true;
458- }
459- }
460- if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
461- }
462- if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
463- }
464- return [
465- hasIPv6,
466- hasIPv4,
467- hasIPv6Local,
468- hasIPv4Local,
469- ];
470-}
471-
472154app.use(cookieSession({
473155 name: getCookieSessionName(),
474156 sameSite: 'strict',
@@ -480,7 +162,7 @@ app.use(cookieSession({
480162app.use(setUserDataMiddleware);
481163
482164// CSRF Protection //
483165if (!cliArgs.disableCsrf) {
484166 const csrfSyncProtection = csrfSync({
485167 getTokenFromState: (req) => {
486168 if (!req.session) {
@@ -542,7 +224,7 @@ app.get('/login', async (request, response) => {
542224 }
543225
544226 try {
545227 const autoLogin = await tryAutoLogin(request, cliArgs.basicAuthMode);
546228
547229 if (autoLogin) {
548230 return response.redirect('/');
@@ -573,6 +255,7 @@ app.get('/api/ping', (request, response) => {
573255});
574256
575257// File uploads
258+const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
576259app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
577260app.use(multerMonkeyPatch);
578261
@@ -588,170 +271,14 @@ app.get('/version', async function (_, response) {
588271 response.send(data);
589272});
590273
591-function cleanUploads() {
274+redirectDeprecatedEndpoints(app);
592- try {
275+setupPrivateEndpoints(app);
593- if (fs.existsSync(uploadsPath)) {
594- const uploads = fs.readdirSync(uploadsPath);
595-
596- if (!uploads.length) {
597- return;
598- }
599-
600- console.debug(`Cleaning uploads folder (${uploads.length} files)`);
601- uploads.forEach(file => {
602- const pathToFile = path.join(uploadsPath, file);
603- fs.unlinkSync(pathToFile);
604- });
605- }
606- } catch (err) {
607- console.error(err);
608- }
609-}
610-
611-/**
612- * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
613- * redirects, this is transparent to client-side code.
614- * @param {string} src The URL to redirect from.
615- * @param {string} destination The URL to redirect to.
616- */
617-function redirect(src, destination) {
618- app.use(src, (req, res) => {
619- console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
620- // HTTP 301 causes the request to become a GET. 308 preserves the request method.
621- res.redirect(308, destination);
622- });
623-}
624-
625-// Redirect deprecated character API endpoints
626-redirect('/createcharacter', '/api/characters/create');
627-redirect('/renamecharacter', '/api/characters/rename');
628-redirect('/editcharacter', '/api/characters/edit');
629-redirect('/editcharacterattribute', '/api/characters/edit-attribute');
630-redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
631-redirect('/deletecharacter', '/api/characters/delete');
632-redirect('/getcharacters', '/api/characters/all');
633-redirect('/getonecharacter', '/api/characters/get');
634-redirect('/getallchatsofcharacter', '/api/characters/chats');
635-redirect('/importcharacter', '/api/characters/import');
636-redirect('/dupecharacter', '/api/characters/duplicate');
637-redirect('/exportcharacter', '/api/characters/export');
638-
639-// Redirect deprecated chat API endpoints
640-redirect('/savechat', '/api/chats/save');
641-redirect('/getchat', '/api/chats/get');
642-redirect('/renamechat', '/api/chats/rename');
643-redirect('/delchat', '/api/chats/delete');
644-redirect('/exportchat', '/api/chats/export');
645-redirect('/importgroupchat', '/api/chats/group/import');
646-redirect('/importchat', '/api/chats/import');
647-redirect('/getgroupchat', '/api/chats/group/get');
648-redirect('/deletegroupchat', '/api/chats/group/delete');
649-redirect('/savegroupchat', '/api/chats/group/save');
650-
651-// Redirect deprecated group API endpoints
652-redirect('/getgroups', '/api/groups/all');
653-redirect('/creategroup', '/api/groups/create');
654-redirect('/editgroup', '/api/groups/edit');
655-redirect('/deletegroup', '/api/groups/delete');
656-
657-// Redirect deprecated worldinfo API endpoints
658-redirect('/getworldinfo', '/api/worldinfo/get');
659-redirect('/deleteworldinfo', '/api/worldinfo/delete');
660-redirect('/importworldinfo', '/api/worldinfo/import');
661-redirect('/editworldinfo', '/api/worldinfo/edit');
662-
663-// Redirect deprecated stats API endpoints
664-redirect('/getstats', '/api/stats/get');
665-redirect('/recreatestats', '/api/stats/recreate');
666-redirect('/updatestats', '/api/stats/update');
667-
668-// Redirect deprecated backgrounds API endpoints
669-redirect('/getbackgrounds', '/api/backgrounds/all');
670-redirect('/delbackground', '/api/backgrounds/delete');
671-redirect('/renamebackground', '/api/backgrounds/rename');
672-redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
673-
674-// Redirect deprecated theme API endpoints
675-redirect('/savetheme', '/api/themes/save');
676-
677-// Redirect deprecated avatar API endpoints
678-redirect('/getuseravatars', '/api/avatars/get');
679-redirect('/deleteuseravatar', '/api/avatars/delete');
680-redirect('/uploaduseravatar', '/api/avatars/upload');
681-
682-// Redirect deprecated quick reply endpoints
683-redirect('/deletequickreply', '/api/quick-replies/delete');
684-redirect('/savequickreply', '/api/quick-replies/save');
685-
686-// Redirect deprecated image endpoints
687-redirect('/uploadimage', '/api/images/upload');
688-redirect('/listimgfiles/:folder', '/api/images/list/:folder');
689-redirect('/api/content/import', '/api/content/importURL');
690-
691-// Redirect deprecated moving UI endpoints
692-redirect('/savemovingui', '/api/moving-ui/save');
693-
694-// Redirect Serp endpoints
695-redirect('/api/serpapi/search', '/api/search/serpapi');
696-redirect('/api/serpapi/visit', '/api/search/visit');
697-redirect('/api/serpapi/transcript', '/api/search/transcript');
698-
699-app.use('/api/moving-ui', movingUIRouter);
700-app.use('/api/images', imagesRouter);
701-app.use('/api/quick-replies', quickRepliesRouter);
702-app.use('/api/avatars', avatarsRouter);
703-app.use('/api/themes', themesRouter);
704-app.use('/api/openai', openAiRouter);
705-app.use('/api/google', googleRouter);
706-app.use('/api/anthropic', anthropicRouter);
707-app.use('/api/tokenizers', tokenizersRouter);
708-app.use('/api/presets', presetsRouter);
709-app.use('/api/secrets', secretsRouter);
710-app.use('/thumbnail', thumbnailRouter);
711-app.use('/api/novelai', novelAiRouter);
712-app.use('/api/extensions', extensionsRouter);
713-app.use('/api/assets', assetsRouter);
714-app.use('/api/files', filesRouter);
715-app.use('/api/characters', charactersRouter);
716-app.use('/api/chats', chatsRouter);
717-app.use('/api/groups', groupsRouter);
718-app.use('/api/worldinfo', worldInfoRouter);
719-app.use('/api/stats', statsRouter);
720-app.use('/api/backgrounds', backgroundsRouter);
721-app.use('/api/sprites', spritesRouter);
722-app.use('/api/content', contentManagerRouter);
723-app.use('/api/settings', settingsRouter);
724-app.use('/api/sd', stableDiffusionRouter);
725-app.use('/api/horde', hordeRouter);
726-app.use('/api/vector', vectorsRouter);
727-app.use('/api/translate', translateRouter);
728-app.use('/api/extra/classify', classifyRouter);
729-app.use('/api/extra/caption', captionRouter);
730-app.use('/api/search', searchRouter);
731-app.use('/api/backends/text-completions', textCompletionsRouter);
732-app.use('/api/openrouter', openRouterRouter);
733-app.use('/api/backends/kobold', koboldRouter);
734-app.use('/api/backends/chat-completions', chatCompletionsRouter);
735-app.use('/api/backends/scale-alt', scaleAltRouter);
736-app.use('/api/speech', speechRouter);
737-app.use('/api/azure', azureRouter);
738-
739-const tavernUrlV6 = new URL(
740- (cliArguments.ssl ? 'https://' : 'http://') +
741- (listen ? (ipRegex.v6({ exact: true }).test(listenAddressIPv6) ? listenAddressIPv6 : '[::]') : '[::1]') +
742- (':' + server_port),
743-);
744-
745-const tavernUrl = new URL(
746- (cliArguments.ssl ? 'https://' : 'http://') +
747- (listen ? (ipRegex.v4({ exact: true }).test(listenAddressIPv4) ? listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
748- (':' + server_port),
749-);
750276
751277/**
752278 * Tasks that need to be run before the server starts listening.
279+ * @returns {Promise<void>}
753280 */
754281const preSetupTasks = async function preSetupTasks() {
755282 const version = await getVersion();
756283
757284 // Print formatted header
@@ -797,56 +324,25 @@ const preSetupTasks = async function () {
797324 });
798325
799326 // Add request proxy.
800327 initRequestProxy({ enabled: proxyEnabledcliArgs.requestProxyEnabled, url: proxyUrlcliArgs.requestProxyUrl, bypass: proxyBypasscliArgs.requestProxyBypass });
801328
802329 // Wait for frontend libs to compile
803330 await webpackMiddleware.runWebpackCompiler();
804-};
805-
806-/**
807- * Gets the hostname to use for autorun in the browser.
808- * @param {boolean} useIPv6 If use IPv6
809- * @param {boolean} useIPv4 If use IPv4
810- * @returns Promise<string> The hostname to use for autorun
811- */
812-async function getAutorunHostname(useIPv6, useIPv4) {
813- if (autorunHostname === 'auto') {
814- let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
815-
816- if (useIPv6 && useIPv4) {
817- return (avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
818- }
819-
820- if (useIPv6) {
821- return '[::1]';
822- }
823-
824- if (useIPv4) {
825- return '127.0.0.1';
826- }
827- }
828-
829- return autorunHostname;
830331}
831332
832333/**
833334 * Tasks that need to be run after the server starts listening.
834- * @param {boolean} v6Failed If the server failed to start on IPv6
335+ * @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup
835- * @param {boolean} v4Failed If the server failed to start on IPv4
336+ * @returns {Promise<void>}
836- * @param {boolean} useIPv6 If the server is using IPv6
837- * @param {boolean} useIPv4 If the server is using IPv4
838337 */
839338const postSetupTasks = async function postSetupTasks({ v6Failed, v4Failed, useIPv6, useIPv4 }) {
840339 const autorunUrlautorunHostname = newawait URLcliArgs.getAutorunHostname(useIPv6, useIPv4);
841- (cliArguments.ssl ? 'https://' : 'http://') +
340+ const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
842- (await getAutorunHostname(useIPv6, useIPv4)) +
843- (':') +
844- ((autorunPortOverride >= 0) ? autorunPortOverride : server_port),
845- );
846-
847341 console.log('Launching...');
848342
849343 if (cliArgs.autorun) open(autorunUrl.toString());{
344+ open(autorunUrl.toString());
345+ }
850346
851347 setWindowTitle('SillyTavern WebServer');
852348
@@ -854,13 +350,13 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
854350
855351 if (useIPv6 && !v6Failed) {
856352 logListen += color.green(
857353 ' IPv6: ' + tavernUrlV6cliArgs.getIPv6ListenUrl().host,
858354 );
859355 }
860356
861357 if (useIPv4 && !v4Failed) {
862358 logListen += color.green(
863359 ' IPv4: ' + tavernUrlcliArgs.getIPv4ListenUrl().host,
864360 );
865361 }
866362
@@ -868,7 +364,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
868364 const plainGoToLog = removeColorFormatting(goToLog);
869365
870366 console.log(logListen);
871367 if (cliArgs.listen) {
872368 console.log();
873369 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
874370 console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
@@ -877,25 +373,8 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
877373 console.log(goToLog);
878374 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
879375
880-
881- if (basicAuthMode) {
882- if (perUserBasicAuth && !enableAccounts) {
883- console.error(color.red(
884- 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
885- ));
886- } else if (!perUserBasicAuth) {
887- const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
888- const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
889- if (!basicAuthUserName || !basicAuthUserPassword) {
890- console.warn(color.yellow(
891- 'Basic Authentication is enabled, but username or password is not set or empty!',
892- ));
893- }
894- }
895- }
896-
897376 setupLogLevel();
898377};
899378
900379/**
901380 * Loads server plugins from a directory.
@@ -913,25 +392,12 @@ async function initializePlugins() {
913392}
914393
915394/**
916- * Set the title of the terminal window
917- * @param {string} title Desired title for the window
918- */
919-function setWindowTitle(title) {
920- if (process.platform === 'win32') {
921- process.title = title;
922- }
923- else {
924- process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
925- }
926-}
927-
928-/**
929395 * Prints an error message and exits the process if necessary
930396 * @param {string} message The error message to print
931397 * @returns {void}
932398 */
933399function logSecurityAlert(message) {
934400 if (cliArgs.basicAuthMode || enableWhitelistcliArgs.whitelistMode) return; // safe!
935401 console.error(color.red(message));
936402 if (getConfigValue('securityOverride', false, 'boolean')) {
937403 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
@@ -940,177 +406,9 @@ function logSecurityAlert(message) {
940406 process.exit(1);
941407}
942408
943-/**
944- * Handles the case where the server failed to start on one or both protocols.
945- * @param {boolean} v6Failed If the server failed to start on IPv6
946- * @param {boolean} v4Failed If the server failed to start on IPv4
947- * @param {boolean} useIPv6 If use IPv6
948- * @param {boolean} useIPv4 If use IPv4
949- */
950-function handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) {
951- if (v6Failed && !useIPv4) {
952- console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled'));
953- process.exit(1);
954- }
955-
956- if (v4Failed && !useIPv6) {
957- console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled'));
958- process.exit(1);
959- }
960-
961- if (v6Failed && v4Failed) {
962- console.error(color.red('fatal error: Failed to start server on both IPv6 and IPv4'));
963- process.exit(1);
964- }
965-}
966-
967-/**
968- * Creates an HTTPS server.
969- * @param {URL} url The URL to listen on
970- * @param {number} ipVersion the ip version to use
971- * @returns {Promise<void>} A promise that resolves when the server is listening
972- * @throws {Error} If the server fails to start
973- */
974-function createHttpsServer(url, ipVersion) {
975- return new Promise((resolve, reject) => {
976- const server = https.createServer(
977- {
978- cert: fs.readFileSync(cliArguments.certPath),
979- key: fs.readFileSync(cliArguments.keyPath),
980- }, app);
981- server.on('error', reject);
982- server.on('listening', resolve);
983-
984- let host = url.hostname;
985- if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
986- server.listen({
987- host: host,
988- port: Number(url.port || 443),
989- // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
990- ipv6Only: true,
991- });
992- });
993-}
994-
995-/**
996- * Creates an HTTP server.
997- * @param {URL} url The URL to listen on
998- * @param {number} ipVersion the ip version to use
999- * @returns {Promise<void>} A promise that resolves when the server is listening
1000- * @throws {Error} If the server fails to start
1001- */
1002-function createHttpServer(url, ipVersion) {
1003- return new Promise((resolve, reject) => {
1004- const server = http.createServer(app);
1005- server.on('error', reject);
1006- server.on('listening', resolve);
1007-
1008- let host = url.hostname;
1009- if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
1010- server.listen({
1011- host: host,
1012- port: Number(url.port || 80),
1013- // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
1014- ipv6Only: true,
1015- });
1016- });
1017-}
1018-
1019-/**
1020- * Starts the server using http or https depending on config
1021- * @param {boolean} useIPv6 If use IPv6
1022- * @param {boolean} useIPv4 If use IPv4
1023- */
1024-async function startHTTPorHTTPS(useIPv6, useIPv4) {
1025- let v6Failed = false;
1026- let v4Failed = false;
1027-
1028- const createFunc = cliArguments.ssl ? createHttpsServer : createHttpServer;
1029-
1030- if (useIPv6) {
1031- try {
1032- await createFunc(tavernUrlV6, 6);
1033- } catch (error) {
1034- console.error('non-fatal error: failed to start server on IPv6');
1035- console.error(error);
1036-
1037- v6Failed = true;
1038- }
1039- }
1040-
1041- if (useIPv4) {
1042- try {
1043- await createFunc(tavernUrl, 4);
1044- } catch (error) {
1045- console.error('non-fatal error: failed to start server on IPv4');
1046- console.error(error);
1047-
1048- v4Failed = true;
1049- }
1050- }
1051-
1052- return [v6Failed, v4Failed];
1053-}
1054-
1055-async function startServer() {
1056- let useIPv6 = (enableIPv6 === true);
1057- let useIPv4 = (enableIPv4 === true);
1058-
1059- let hasIPv6 = false,
1060- hasIPv4 = false,
1061- hasIPv6Local = false,
1062- hasIPv4Local = false,
1063- hasIPv6Any = false,
1064- hasIPv4Any = false;
1065-
1066- if (enableIPv6 === 'auto' || enableIPv4 === 'auto') {
1067- [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP();
1068-
1069- hasIPv6 = listen ? hasIPv6Any : hasIPv6Local;
1070- if (enableIPv6 === 'auto') {
1071- useIPv6 = hasIPv6;
1072- }
1073- if (hasIPv6) {
1074- if (useIPv6) {
1075- console.log(color.green('IPv6 support detected'));
1076- } else {
1077- console.log('IPv6 support detected (but disabled)');
1078- }
1079- }
1080-
1081- hasIPv4 = listen ? hasIPv4Any : hasIPv4Local;
1082- if (enableIPv4 === 'auto') {
1083- useIPv4 = hasIPv4;
1084- }
1085- if (hasIPv4) {
1086- if (useIPv4) {
1087- console.log(color.green('IPv4 support detected'));
1088- } else {
1089- console.log('IPv4 support detected (but disabled)');
1090- }
1091- }
1092-
1093- if (enableIPv6 === 'auto' && enableIPv4 === 'auto') {
1094- if (!hasIPv6 && !hasIPv4) {
1095- console.error('Both IPv6 and IPv4 are not detected');
1096- process.exit(1);
1097- }
1098- }
1099- }
1100-
1101- if (!useIPv6 && !useIPv4) {
1102- console.error('Both IPv6 and IPv4 are disabled,\nP.S. you should never see this error, at least at one point it was checked for before this, with the rest of the config options');
1103- process.exit(1);
1104- }
1105-
1106- const [v6Failed, v4Failed] = await startHTTPorHTTPS(useIPv6, useIPv4);
1107- handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4);
1108- postSetupTasks(v6Failed, v4Failed, useIPv6, useIPv4);
1109-}
1110-
1111409async function verifySecuritySettings() {
1112410 // Skip all security checks as listen is set to false
1113411 if (!cliArgs.listen) {
1114412 return;
1115413 }
1116414
@@ -1133,6 +431,23 @@ async function verifySecuritySettings() {
1133431 logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
1134432 }
1135433 }
434+
435+ if (cliArgs.basicAuthMode) {
436+ const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean');
437+ if (perUserBasicAuth && !enableAccounts) {
438+ console.error(color.red(
439+ 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
440+ ));
441+ } else if (!perUserBasicAuth) {
442+ const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
443+ const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
444+ if (!basicAuthUserName || !basicAuthUserPassword) {
445+ console.warn(color.yellow(
446+ 'Basic Authentication is enabled, but username or password is not set or empty!',
447+ ));
448+ }
449+ }
450+ }
1136451}
1137452
1138453/**
@@ -1153,4 +468,5 @@ initUserStorage(globalThis.DATA_ROOT)
1153468 .then(verifySecuritySettings)
1154469 .then(preSetupTasks)
1155470 .then(apply404Middleware)
1156- .finally(startServer);
471+ .then(new ServerStartup(app, cliArgs).start)
472+ .then(postSetupTasks);
src/command-line.js+265 -0
@@ -0,0 +1,265 @@
1+import yargs from 'yargs/yargs';
2+import { hideBin } from 'yargs/helpers';
3+import ipRegex from 'ip-regex';
4+import { canResolve, color, getConfigValue, stringToBool } from './util.js';
5+
6+/**
7+ * @typedef {object} CommandLineArguments
8+ * @property {string} dataRoot
9+ * @property {number} port
10+ * @property {boolean} listen
11+ * @property {string} listenAddressIPv6
12+ * @property {string} listenAddressIPv4
13+ * @property {boolean|string} enableIPv4
14+ * @property {boolean|string} enableIPv6
15+ * @property {boolean} dnsPreferIPv6
16+ * @property {boolean} autorun
17+ * @property {string} autorunHostname
18+ * @property {number} autorunPortOverride
19+ * @property {boolean} enableCorsProxy
20+ * @property {boolean} disableCsrf
21+ * @property {boolean} ssl
22+ * @property {string} certPath
23+ * @property {string} keyPath
24+ * @property {boolean} whitelistMode
25+ * @property {boolean} avoidLocalhost
26+ * @property {boolean} basicAuthMode
27+ * @property {boolean} requestProxyEnabled
28+ * @property {string} requestProxyUrl
29+ * @property {string[]} requestProxyBypass
30+ * @property {function(): URL} getIPv4ListenUrl
31+ * @property {function(): URL} getIPv6ListenUrl
32+ * @property {function(boolean, boolean): Promise<string>} getAutorunHostname
33+ * @property {function(string): URL} getAutorunUrl
34+ */
35+
36+/**
37+ * Gets the hostname to use for autorun in the browser.
38+ * @param {boolean} useIPv6 If use IPv6
39+ * @param {boolean} useIPv4 If use IPv4
40+ * @returns {Promise<string>} The hostname to use for autorun
41+ */
42+
43+export class CommandLineParser {
44+ constructor() {
45+ /** @type {CommandLineArguments} */
46+ this.default = Object.freeze({
47+ dataRoot: './data',
48+ port: 8000,
49+ listen: false,
50+ listenAddressIPv6: '[::]',
51+ listenAddressIPv4: '0.0.0.0',
52+ enableIPv4: true,
53+ enableIPv6: false,
54+ dnsPreferIPv6: false,
55+ autorun: false,
56+ autorunHostname: 'auto',
57+ autorunPortOverride: -1,
58+ enableCorsProxy: false,
59+ disableCsrf: false,
60+ ssl: false,
61+ certPath: 'certs/cert.pem',
62+ keyPath: 'certs/privkey.pem',
63+ whitelistMode: true,
64+ avoidLocalhost: false,
65+ basicAuthMode: false,
66+ requestProxyEnabled: false,
67+ requestProxyUrl: '',
68+ requestProxyBypass: [],
69+ getIPv4ListenUrl: function () {
70+ throw new Error('getIPv4ListenUrl is not implemented');
71+ },
72+ getIPv6ListenUrl: function () {
73+ throw new Error('getIPv6ListenUrl is not implemented');
74+ },
75+ getAutorunHostname: async function () {
76+ throw new Error('getAutorunHostname is not implemented');
77+ },
78+ getAutorunUrl: function () {
79+ throw new Error('getAutorunUrl is not implemented');
80+ },
81+ });
82+
83+ this.booleanAutoOptions = [true, false, 'auto'];
84+ }
85+
86+ /**
87+ * Parses command line arguments.
88+ * @param {string[]} args Process startup arguments.
89+ * @returns {CommandLineArguments} Parsed command line arguments.
90+ */
91+ parse(args) {
92+ const cliArguments = yargs(hideBin(args))
93+ .usage('Usage: <your-start-script> <command> [options]')
94+ .option('enableIPv6', {
95+ type: 'string',
96+ default: null,
97+ describe: 'Enables IPv6 protocol.',
98+ }).option('enableIPv4', {
99+ type: 'string',
100+ default: null,
101+ describe: 'Enables IPv4 protocol.',
102+ }).option('port', {
103+ type: 'number',
104+ default: null,
105+ describe: 'Sets the port under which SillyTavern will run.\nIf not provided falls back to yaml config \'port\'.',
106+ }).option('dnsPreferIPv6', {
107+ type: 'boolean',
108+ default: null,
109+ describe: 'Prefers IPv6 for DNS\nyou should probably have the enabled if you\'re on an IPv6 only network\nIf not provided falls back to yaml config \'dnsPreferIPv6\'.',
110+ }).option('autorun', {
111+ type: 'boolean',
112+ default: null,
113+ describe: 'Automatically launch SillyTavern in the browser.\nAutorun is automatically disabled if --ssl is set to true.\nIf not provided falls back to yaml config \'autorun\'.',
114+ }).option('autorunHostname', {
115+ type: 'string',
116+ default: null,
117+ describe: 'Sets the autorun hostname, probably best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'',
118+ }).option('autorunPortOverride', {
119+ type: 'string',
120+ default: null,
121+ describe: 'Overrides the port for autorun with open your browser with this port and ignore what port the server is running on. -1 is use server port',
122+ }).option('listen', {
123+ type: 'boolean',
124+ default: null,
125+ describe: 'SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If false, will limit it only to internal localhost (127.0.0.1).\nIf not provided falls back to yaml config \'listen\'.',
126+ }).option('listenAddressIPv6', {
127+ type: 'string',
128+ default: null,
129+ describe: 'Set SillyTavern to listen to a specific IPv6 address. If not set, it will fallback to listen to all.',
130+ }).option('listenAddressIPv4', {
131+ type: 'string',
132+ default: null,
133+ describe: 'Set SillyTavern to listen to a specific IPv4 address. If not set, it will fallback to listen to all.',
134+ }).option('corsProxy', {
135+ type: 'boolean',
136+ default: null,
137+ describe: 'Enables CORS proxy\nIf not provided falls back to yaml config \'enableCorsProxy\'',
138+ }).option('disableCsrf', {
139+ type: 'boolean',
140+ default: null,
141+ describe: 'Disables CSRF protection',
142+ }).option('ssl', {
143+ type: 'boolean',
144+ default: false,
145+ describe: 'Enables SSL',
146+ }).option('certPath', {
147+ type: 'string',
148+ default: 'certs/cert.pem',
149+ describe: 'Path to your certificate file.',
150+ }).option('keyPath', {
151+ type: 'string',
152+ default: 'certs/privkey.pem',
153+ describe: 'Path to your private key file.',
154+ }).option('whitelist', {
155+ type: 'boolean',
156+ default: null,
157+ describe: 'Enables whitelist mode',
158+ }).option('dataRoot', {
159+ type: 'string',
160+ default: null,
161+ describe: 'Root directory for data storage',
162+ }).option('avoidLocalhost', {
163+ type: 'boolean',
164+ default: null,
165+ describe: 'Avoids using \'localhost\' for autorun in auto mode.\nuse if you don\'t have \'localhost\' in your hosts file',
166+ }).option('basicAuthMode', {
167+ type: 'boolean',
168+ default: null,
169+ describe: 'Enables basic authentication',
170+ }).option('requestProxyEnabled', {
171+ type: 'boolean',
172+ default: null,
173+ describe: 'Enables a use of proxy for outgoing requests',
174+ }).option('requestProxyUrl', {
175+ type: 'string',
176+ default: null,
177+ describe: 'Request proxy URL (HTTP or SOCKS protocols)',
178+ }).option('requestProxyBypass', {
179+ type: 'array',
180+ describe: 'Request proxy bypass list (space separated list of hosts)',
181+ }).parseSync();
182+
183+ /** @type {CommandLineArguments} */
184+ const result = {
185+ dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),
186+ port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),
187+ listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),
188+ listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', this.default.listenAddressIPv6),
189+ listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', this.default.listenAddressIPv4),
190+ enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', this.default.enableIPv4)) ?? this.default.enableIPv4,
191+ enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', this.default.enableIPv6)) ?? this.default.enableIPv6,
192+ dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', this.default.dnsPreferIPv6, 'boolean'),
193+ autorun: cliArguments.autorun ?? getConfigValue('autorun', this.default.autorun, 'boolean'),
194+ autorunHostname: cliArguments.autorunHostname ?? getConfigValue('autorunHostname', this.default.autorunHostname),
195+ autorunPortOverride: cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', this.default.autorunPortOverride, 'number'),
196+ enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', this.default.enableCorsProxy, 'boolean'),
197+ disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', this.default.disableCsrf, 'boolean'),
198+ ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', this.default.ssl, 'boolean'),
199+ certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', this.default.certPath),
200+ keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', this.default.keyPath),
201+ whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', this.default.whitelistMode, 'boolean'),
202+ avoidLocalhost: cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', this.default.avoidLocalhost, 'boolean'),
203+ basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', this.default.basicAuthMode, 'boolean'),
204+ requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', this.default.requestProxyEnabled, 'boolean'),
205+ requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', this.default.requestProxyUrl),
206+ requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', this.default.requestProxyBypass),
207+ getIPv4ListenUrl: function () {
208+ const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4);
209+ return new URL(
210+ (this.ssl ? 'https://' : 'http://') +
211+ (this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
212+ (':' + this.port),
213+ );
214+ },
215+ getIPv6ListenUrl: function () {
216+ const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6);
217+ return new URL(
218+ (this.ssl ? 'https://' : 'http://') +
219+ (this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') +
220+ (':' + this.port),
221+ );
222+ },
223+ getAutorunHostname: async function (useIPv6, useIPv4) {
224+ if (this.autorunHostname === 'auto') {
225+ let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
226+
227+ if (useIPv6 && useIPv4) {
228+ return (this.avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
229+ }
230+
231+ if (useIPv6) {
232+ return '[::1]';
233+ }
234+
235+ if (useIPv4) {
236+ return '127.0.0.1';
237+ }
238+ }
239+
240+ return this.autorunHostname;
241+ },
242+ getAutorunUrl: function (hostname) {
243+ const autorunPort = (this.autorunPortOverride >= 0) ? this.autorunPortOverride : this.port;
244+ return new URL(
245+ (this.ssl ? 'https://' : 'http://') +
246+ (hostname) +
247+ (':') +
248+ (autorunPort),
249+ );
250+ },
251+ };
252+
253+ if (!this.booleanAutoOptions.includes(result.enableIPv6)) {
254+ console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv6);
255+ result.enableIPv6 = this.default.enableIPv6;
256+ }
257+
258+ if (!this.booleanAutoOptions.includes(result.enableIPv4)) {
259+ console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv4);
260+ result.enableIPv4 = this.default.enableIPv4;
261+ }
262+
263+ return result;
264+ }
265+}
src/middleware/corsProxy.js+42 -0
@@ -0,0 +1,42 @@
1+import fetch from 'node-fetch';
2+import { forwardFetchResponse } from '../util.js';
3+
4+/**
5+ * Middleware to proxy requests to a different domain
6+ * @param {import('express').Request} req Express request object
7+ * @param {import('express').Response} res Express response object
8+ */
9+export default async function corsProxyMiddleware(req, res) {
10+ const url = req.params.url; // get the url from the request path
11+
12+ // Disallow circular requests
13+ const serverUrl = req.protocol + '://' + req.get('host');
14+ if (url.startsWith(serverUrl)) {
15+ return res.status(400).send('Circular requests are not allowed');
16+ }
17+
18+ try {
19+ const headers = JSON.parse(JSON.stringify(req.headers));
20+ const headersToRemove = [
21+ 'x-csrf-token', 'host', 'referer', 'origin', 'cookie',
22+ 'x-forwarded-for', 'x-forwarded-protocol', 'x-forwarded-proto',
23+ 'x-forwarded-host', 'x-real-ip', 'sec-fetch-mode',
24+ 'sec-fetch-site', 'sec-fetch-dest',
25+ ];
26+
27+ headersToRemove.forEach(header => delete headers[header]);
28+
29+ const bodyMethods = ['POST', 'PUT', 'PATCH'];
30+
31+ const response = await fetch(url, {
32+ method: req.method,
33+ headers: headers,
34+ body: bodyMethods.includes(req.method) ? JSON.stringify(req.body) : undefined,
35+ });
36+
37+ // Copy over relevant response params to the proxy response
38+ forwardFetchResponse(response, res);
39+ } catch (error) {
40+ res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error);
41+ }
42+}
src/server-startup.js+359 -0
@@ -0,0 +1,359 @@
1+import https from 'node:https';
2+import http from 'node:http';
3+import fs from 'node:fs';
4+import { color, urlHostnameToIPv6, getHasIP } from './util.js';
5+
6+// Express routers
7+import { router as movingUIRouter } from './endpoints/moving-ui.js';
8+import { router as imagesRouter } from './endpoints/images.js';
9+import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
10+import { router as avatarsRouter } from './endpoints/avatars.js';
11+import { router as themesRouter } from './endpoints/themes.js';
12+import { router as openAiRouter } from './endpoints/openai.js';
13+import { router as googleRouter } from './endpoints/google.js';
14+import { router as anthropicRouter } from './endpoints/anthropic.js';
15+import { router as tokenizersRouter } from './endpoints/tokenizers.js';
16+import { router as presetsRouter } from './endpoints/presets.js';
17+import { router as secretsRouter } from './endpoints/secrets.js';
18+import { router as thumbnailRouter } from './endpoints/thumbnails.js';
19+import { router as novelAiRouter } from './endpoints/novelai.js';
20+import { router as extensionsRouter } from './endpoints/extensions.js';
21+import { router as assetsRouter } from './endpoints/assets.js';
22+import { router as filesRouter } from './endpoints/files.js';
23+import { router as charactersRouter } from './endpoints/characters.js';
24+import { router as chatsRouter } from './endpoints/chats.js';
25+import { router as groupsRouter } from './endpoints/groups.js';
26+import { router as worldInfoRouter } from './endpoints/worldinfo.js';
27+import { router as statsRouter } from './endpoints/stats.js';
28+import { router as contentManagerRouter } from './endpoints/content-manager.js';
29+import { router as settingsRouter } from './endpoints/settings.js';
30+import { router as backgroundsRouter } from './endpoints/backgrounds.js';
31+import { router as spritesRouter } from './endpoints/sprites.js';
32+import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
33+import { router as hordeRouter } from './endpoints/horde.js';
34+import { router as vectorsRouter } from './endpoints/vectors.js';
35+import { router as translateRouter } from './endpoints/translate.js';
36+import { router as classifyRouter } from './endpoints/classify.js';
37+import { router as captionRouter } from './endpoints/caption.js';
38+import { router as searchRouter } from './endpoints/search.js';
39+import { router as openRouterRouter } from './endpoints/openrouter.js';
40+import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
41+import { router as koboldRouter } from './endpoints/backends/kobold.js';
42+import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
43+import { router as scaleAltRouter } from './endpoints/backends/scale-alt.js';
44+import { router as speechRouter } from './endpoints/speech.js';
45+import { router as azureRouter } from './endpoints/azure.js';
46+
47+/**
48+ * @typedef {object} ServerStartupResult
49+ * @property {boolean} v6Failed If the server failed to start on IPv6
50+ * @property {boolean} v4Failed If the server failed to start on IPv4
51+ * @property {boolean} useIPv6 If use IPv6
52+ * @property {boolean} useIPv4 If use IPv4
53+ */
54+
55+/**
56+ * Redirect deprecated API endpoints to their replacements.
57+ * @param {import('express').Express} app The Express app to use
58+ */
59+export function redirectDeprecatedEndpoints(app) {
60+ /**
61+ * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
62+ * redirects, this is transparent to client-side code.
63+ * @param {string} src The URL to redirect from.
64+ * @param {string} destination The URL to redirect to.
65+ */
66+ function redirect(src, destination) {
67+ app.use(src, (req, res) => {
68+ console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
69+ // HTTP 301 causes the request to become a GET. 308 preserves the request method.
70+ res.redirect(308, destination);
71+ });
72+ }
73+
74+ redirect('/createcharacter', '/api/characters/create');
75+ redirect('/renamecharacter', '/api/characters/rename');
76+ redirect('/editcharacter', '/api/characters/edit');
77+ redirect('/editcharacterattribute', '/api/characters/edit-attribute');
78+ redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
79+ redirect('/deletecharacter', '/api/characters/delete');
80+ redirect('/getcharacters', '/api/characters/all');
81+ redirect('/getonecharacter', '/api/characters/get');
82+ redirect('/getallchatsofcharacter', '/api/characters/chats');
83+ redirect('/importcharacter', '/api/characters/import');
84+ redirect('/dupecharacter', '/api/characters/duplicate');
85+ redirect('/exportcharacter', '/api/characters/export');
86+ redirect('/savechat', '/api/chats/save');
87+ redirect('/getchat', '/api/chats/get');
88+ redirect('/renamechat', '/api/chats/rename');
89+ redirect('/delchat', '/api/chats/delete');
90+ redirect('/exportchat', '/api/chats/export');
91+ redirect('/importgroupchat', '/api/chats/group/import');
92+ redirect('/importchat', '/api/chats/import');
93+ redirect('/getgroupchat', '/api/chats/group/get');
94+ redirect('/deletegroupchat', '/api/chats/group/delete');
95+ redirect('/savegroupchat', '/api/chats/group/save');
96+ redirect('/getgroups', '/api/groups/all');
97+ redirect('/creategroup', '/api/groups/create');
98+ redirect('/editgroup', '/api/groups/edit');
99+ redirect('/deletegroup', '/api/groups/delete');
100+ redirect('/getworldinfo', '/api/worldinfo/get');
101+ redirect('/deleteworldinfo', '/api/worldinfo/delete');
102+ redirect('/importworldinfo', '/api/worldinfo/import');
103+ redirect('/editworldinfo', '/api/worldinfo/edit');
104+ redirect('/getstats', '/api/stats/get');
105+ redirect('/recreatestats', '/api/stats/recreate');
106+ redirect('/updatestats', '/api/stats/update');
107+ redirect('/getbackgrounds', '/api/backgrounds/all');
108+ redirect('/delbackground', '/api/backgrounds/delete');
109+ redirect('/renamebackground', '/api/backgrounds/rename');
110+ redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
111+ redirect('/savetheme', '/api/themes/save');
112+ redirect('/getuseravatars', '/api/avatars/get');
113+ redirect('/deleteuseravatar', '/api/avatars/delete');
114+ redirect('/uploaduseravatar', '/api/avatars/upload');
115+ redirect('/deletequickreply', '/api/quick-replies/delete');
116+ redirect('/savequickreply', '/api/quick-replies/save');
117+ redirect('/uploadimage', '/api/images/upload');
118+ redirect('/listimgfiles/:folder', '/api/images/list/:folder');
119+ redirect('/api/content/import', '/api/content/importURL');
120+ redirect('/savemovingui', '/api/moving-ui/save');
121+ redirect('/api/serpapi/search', '/api/search/serpapi');
122+ redirect('/api/serpapi/visit', '/api/search/visit');
123+ redirect('/api/serpapi/transcript', '/api/search/transcript');
124+}
125+
126+/**
127+ * Setup the routers for the endpoints.
128+ * @param {import('express').Express} app The Express app to use
129+ */
130+export function setupPrivateEndpoints(app) {
131+ app.use('/api/moving-ui', movingUIRouter);
132+ app.use('/api/images', imagesRouter);
133+ app.use('/api/quick-replies', quickRepliesRouter);
134+ app.use('/api/avatars', avatarsRouter);
135+ app.use('/api/themes', themesRouter);
136+ app.use('/api/openai', openAiRouter);
137+ app.use('/api/google', googleRouter);
138+ app.use('/api/anthropic', anthropicRouter);
139+ app.use('/api/tokenizers', tokenizersRouter);
140+ app.use('/api/presets', presetsRouter);
141+ app.use('/api/secrets', secretsRouter);
142+ app.use('/thumbnail', thumbnailRouter);
143+ app.use('/api/novelai', novelAiRouter);
144+ app.use('/api/extensions', extensionsRouter);
145+ app.use('/api/assets', assetsRouter);
146+ app.use('/api/files', filesRouter);
147+ app.use('/api/characters', charactersRouter);
148+ app.use('/api/chats', chatsRouter);
149+ app.use('/api/groups', groupsRouter);
150+ app.use('/api/worldinfo', worldInfoRouter);
151+ app.use('/api/stats', statsRouter);
152+ app.use('/api/backgrounds', backgroundsRouter);
153+ app.use('/api/sprites', spritesRouter);
154+ app.use('/api/content', contentManagerRouter);
155+ app.use('/api/settings', settingsRouter);
156+ app.use('/api/sd', stableDiffusionRouter);
157+ app.use('/api/horde', hordeRouter);
158+ app.use('/api/vector', vectorsRouter);
159+ app.use('/api/translate', translateRouter);
160+ app.use('/api/extra/classify', classifyRouter);
161+ app.use('/api/extra/caption', captionRouter);
162+ app.use('/api/search', searchRouter);
163+ app.use('/api/backends/text-completions', textCompletionsRouter);
164+ app.use('/api/openrouter', openRouterRouter);
165+ app.use('/api/backends/kobold', koboldRouter);
166+ app.use('/api/backends/chat-completions', chatCompletionsRouter);
167+ app.use('/api/backends/scale-alt', scaleAltRouter);
168+ app.use('/api/speech', speechRouter);
169+ app.use('/api/azure', azureRouter);
170+}
171+
172+/**
173+ * Utilities for starting the express server.
174+ */
175+export class ServerStartup {
176+ /**
177+ * Creates a new ServerStartup instance.
178+ * @param {import('express').Express} app The Express app to use
179+ * @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments
180+ */
181+ constructor(app, cliArgs) {
182+ this.app = app;
183+ this.cliArgs = cliArgs;
184+ }
185+
186+ /**
187+ * Creates an HTTPS server.
188+ * @param {URL} url The URL to listen on
189+ * @param {number} ipVersion the ip version to use
190+ * @returns {Promise<void>} A promise that resolves when the server is listening
191+ * @throws {Error} If the server fails to start
192+ */
193+ #createHttpsServer(url, ipVersion) {
194+ return new Promise((resolve, reject) => {
195+ const sslOptions = {
196+ cert: fs.readFileSync(this.cliArgs.certPath),
197+ key: fs.readFileSync(this.cliArgs.keyPath),
198+ };
199+ const server = https.createServer(sslOptions, this.app);
200+ server.on('error', reject);
201+ server.on('listening', resolve);
202+
203+ let host = url.hostname;
204+ if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
205+ server.listen({
206+ host: host,
207+ port: Number(url.port || 443),
208+ // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
209+ ipv6Only: true,
210+ });
211+ });
212+ }
213+
214+ /**
215+ * Creates an HTTP server.
216+ * @param {URL} url The URL to listen on
217+ * @param {number} ipVersion the ip version to use
218+ * @returns {Promise<void>} A promise that resolves when the server is listening
219+ * @throws {Error} If the server fails to start
220+ */
221+ #createHttpServer(url, ipVersion) {
222+ return new Promise((resolve, reject) => {
223+ const server = http.createServer(this.app);
224+ server.on('error', reject);
225+ server.on('listening', resolve);
226+
227+ let host = url.hostname;
228+ if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
229+ server.listen({
230+ host: host,
231+ port: Number(url.port || 80),
232+ // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
233+ ipv6Only: true,
234+ });
235+ });
236+ }
237+
238+ /**
239+ * Starts the server using http or https depending on config
240+ * @param {boolean} useIPv6 If use IPv6
241+ * @param {boolean} useIPv4 If use IPv4
242+ * @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
243+ */
244+ async #startHTTPorHTTPS(useIPv6, useIPv4) {
245+ let v6Failed = false;
246+ let v4Failed = false;
247+
248+ const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
249+
250+ if (useIPv6) {
251+ try {
252+ await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
253+ } catch (error) {
254+ console.error('non-fatal error: failed to start server on IPv6');
255+ console.error(error);
256+
257+ v6Failed = true;
258+ }
259+ }
260+
261+ if (useIPv4) {
262+ try {
263+ await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
264+ } catch (error) {
265+ console.error('non-fatal error: failed to start server on IPv4');
266+ console.error(error);
267+
268+ v4Failed = true;
269+ }
270+ }
271+
272+ return [v6Failed, v4Failed];
273+ }
274+
275+ /**
276+ * Handles the case where the server failed to start on one or both protocols.
277+ * @param {boolean} v6Failed If the server failed to start on IPv6
278+ * @param {boolean} v4Failed If the server failed to start on IPv4
279+ * @param {boolean} useIPv6 If use IPv6
280+ * @param {boolean} useIPv4 If use IPv4
281+ * @returns {void}
282+ */
283+ #handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) {
284+ if (v6Failed && !useIPv4) {
285+ console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled'));
286+ process.exit(1);
287+ }
288+
289+ if (v4Failed && !useIPv6) {
290+ console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled'));
291+ process.exit(1);
292+ }
293+
294+ if (v6Failed && v4Failed) {
295+ console.error(color.red('fatal error: Failed to start server on both IPv6 and IPv4'));
296+ process.exit(1);
297+ }
298+ }
299+
300+ /**
301+ * Performs the server startup.
302+ * @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup
303+ */
304+ async start() {
305+ let useIPv6 = (this.cliArgs.enableIPv6 === true);
306+ let useIPv4 = (this.cliArgs.enableIPv4 === true);
307+
308+ let hasIPv6 = false,
309+ hasIPv4 = false,
310+ hasIPv6Local = false,
311+ hasIPv4Local = false,
312+ hasIPv6Any = false,
313+ hasIPv4Any = false;
314+
315+ if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') {
316+ [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP();
317+
318+ hasIPv6 = this.cliArgs.listen ? hasIPv6Any : hasIPv6Local;
319+ if (this.cliArgs.enableIPv6 === 'auto') {
320+ useIPv6 = hasIPv6;
321+ }
322+ if (hasIPv6) {
323+ if (useIPv6) {
324+ console.log(color.green('IPv6 support detected'));
325+ } else {
326+ console.log('IPv6 support detected (but disabled)');
327+ }
328+ }
329+
330+ hasIPv4 = this.cliArgs.listen ? hasIPv4Any : hasIPv4Local;
331+ if (this.cliArgs.enableIPv4 === 'auto') {
332+ useIPv4 = hasIPv4;
333+ }
334+ if (hasIPv4) {
335+ if (useIPv4) {
336+ console.log(color.green('IPv4 support detected'));
337+ } else {
338+ console.log('IPv4 support detected (but disabled)');
339+ }
340+ }
341+
342+ if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') {
343+ if (!hasIPv6 && !hasIPv4) {
344+ console.error('Both IPv6 and IPv4 are not detected');
345+ process.exit(1);
346+ }
347+ }
348+ }
349+
350+ if (!useIPv6 && !useIPv4) {
351+ console.error('Both IPv6 and IPv4 are disabled');
352+ process.exit(1);
353+ }
354+
355+ const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
356+ this.#handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4);
357+ return { v6Failed, v4Failed, useIPv6, useIPv4 };
358+ }
359+}
src/users.js+41 -1
@@ -14,7 +14,7 @@ import archiver from 'archiver';
1414import _ from 'lodash';
1515import { sync as writeFileAtomicSync } from 'write-file-atomic';
1616
1717import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
1818import { getConfigValue, color, delay, generateTimestamp } from './util.js';
1919import { readSecret, writeSecret } from './endpoints/secrets.js';
2020import { getContentOfType } from './endpoints/content-manager.js';
@@ -120,6 +120,27 @@ export async function ensurePublicDirectoriesExist() {
120120 return directoriesList;
121121}
122122
123+export function cleanUploads() {
124+ try {
125+ const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
126+ if (fs.existsSync(uploadsPath)) {
127+ const uploads = fs.readdirSync(uploadsPath);
128+
129+ if (!uploads.length) {
130+ return;
131+ }
132+
133+ console.debug(`Cleaning uploads folder (${uploads.length} files)`);
134+ uploads.forEach(file => {
135+ const pathToFile = path.join(uploadsPath, file);
136+ fs.unlinkSync(pathToFile);
137+ });
138+ }
139+ } catch (err) {
140+ console.error(err);
141+ }
142+}
143+
123144/**
124145 * Gets a list of all user directories.
125146 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
@@ -478,6 +499,25 @@ export function getCookieSessionName() {
478499 return `session-${suffix}`;
479500}
480501
502+export function getSessionCookieAge() {
503+ // Defaults to "no expiration" if not set
504+ const configValue = getConfigValue('sessionTimeout', -1, 'number');
505+
506+ // Convert to milliseconds
507+ if (configValue > 0) {
508+ return configValue * 1000;
509+ }
510+
511+ // "No expiration" is just 400 days as per RFC 6265
512+ if (configValue < 0) {
513+ return 400 * 24 * 60 * 60 * 1000;
514+ }
515+
516+ // 0 means session cookie is deleted when the browser session ends
517+ // (depends on the implementation of the browser)
518+ return undefined;
519+}
520+
481521/**
482522 * Hashes a password using scrypt with the provided salt.
483523 * @param {string} password Password to hash
src/util.js+64 -0
@@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
66import { createRequire } from 'node:module';
77import { Buffer } from 'node:buffer';
88import { promises as dnsPromise } from 'node:dns';
9+import os from 'os';
910
1011import yaml from 'yaml';
1112import { sync as commandExistsSync } from 'command-exists';
@@ -771,6 +772,56 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
771772}
772773
773774/**
775+ * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
776+ *
777+ * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing:
778+ * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`).
779+ * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`).
780+ * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`).
781+ * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`).
782+ */
783+export async function getHasIP() {
784+ let hasIPv6 = false;
785+ let hasIPv6Local = false;
786+
787+ let hasIPv4 = false;
788+ let hasIPv4Local = false;
789+
790+ const interfaces = os.networkInterfaces();
791+
792+ for (const iface of Object.values(interfaces)) {
793+ if (iface === undefined) {
794+ continue;
795+ }
796+
797+ for (const info of iface) {
798+ if (info.family === 'IPv6') {
799+ hasIPv6 = true;
800+ if (info.address === '::1') {
801+ hasIPv6Local = true;
802+ }
803+ }
804+
805+ if (info.family === 'IPv4') {
806+ hasIPv4 = true;
807+ if (info.address === '127.0.0.1') {
808+ hasIPv4Local = true;
809+ }
810+ }
811+ if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
812+ }
813+ if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
814+ }
815+ return [
816+ hasIPv6,
817+ hasIPv4,
818+ hasIPv6Local,
819+ hasIPv4Local,
820+ ];
821+}
822+
823+
824+/**
774825 * Converts various JavaScript primitives to boolean values.
775826 * Handles special case for "true"/"false" strings (case-insensitive)
776827 *
@@ -1005,3 +1056,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
10051056 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
10061057 return null;
10071058}
1059+
1060+/**
1061+ * Set the title of the terminal window
1062+ * @param {string} title Desired title for the window
1063+ */
1064+export function setWindowTitle(title) {
1065+ if (process.platform === 'win32') {
1066+ process.title = title;
1067+ }
1068+ else {
1069+ process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
1070+ }
1071+}