Refactor server startup

e7fcd0072bce93f53981a40f682c0dff722bb168

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

7 files changed, +847 -756Showing whitespace changes
default/config.yaml+5 -0
@@ -28,6 +28,11 @@ port: 8000
28# - Use -1 to use the server port.28# - Use -1 to use the server port.
29# - Specify a port to override the default.29# - Specify a port to override the default.
30autorunPortOverride: -130autorunPortOverride: -1
31# -- SSL options --
32ssl:
33 enabled: false
34 certPath: "./certs/cert.pem"
35 keyPath: "./certs/privkey.pem"
31# -- SECURITY CONFIGURATION --36# -- SECURITY CONFIGURATION --
32# Toggle whitelist mode37# Toggle whitelist mode
33whitelistMode: true38whitelistMode: true
server.js+71 -755
@@ -1,10 +1,6 @@
1#!/usr/bin/env node1#!/usr/bin/env node
22
3// native node modules3// native node modules
4import fs from 'node:fs';
5import http from 'node:http';
6import https from 'node:https';
7import os from 'os';
8import path from 'node:path';4import path from 'node:path';
9import util from 'node:util';5import util from 'node:util';
10import net from 'node:net';6import net from 'node:net';
@@ -12,12 +8,6 @@ import dns from 'node:dns';
12import process from 'node:process';8import process from 'node:process';
13import { fileURLToPath } from 'node:url';9import { fileURLToPath } from 'node:url';
1410
15// cli/fs related library imports
16import open from 'open';
17import yargs from 'yargs/yargs';
18import { hideBin } from 'yargs/helpers';
19
20// express/server related library imports
21import cors from 'cors';11import cors from 'cors';
22import { csrfSync } from 'csrf-sync';12import { csrfSync } from 'csrf-sync';
23import express from 'express';13import express from 'express';
@@ -27,17 +17,11 @@ import multer from 'multer';
27import responseTime from 'response-time';17import responseTime from 'response-time';
28import helmet from 'helmet';18import helmet from 'helmet';
29import bodyParser from 'body-parser';19import bodyParser from 'body-parser';
3020import open from 'open';
31// net related library imports
32import fetch from 'node-fetch';21import fetch from 'node-fetch';
33import ipRegex from 'ip-regex';
34
35// Unrestrict console logs display limit
36util.inspect.defaultOptions.maxArrayLength = null;
37util.inspect.defaultOptions.maxStringLength = null;
38util.inspect.defaultOptions.depth = 4;
3922
40// local library imports23// local library imports
24import { CommandLineParser } from './src/command-line.js';
41import { loadPlugins } from './src/plugin-loader.js';25import { loadPlugins } from './src/plugin-loader.js';
42import {26import {
43 initUserStorage,27 initUserStorage,
@@ -53,6 +37,8 @@ import {
53 shouldRedirectToLogin,37 shouldRedirectToLogin,
54 tryAutoLogin,38 tryAutoLogin,
55 router as userDataRouter,39 router as userDataRouter,
40 cleanUploads,
41 getSessionCookieAge,
56} from './src/users.js';42} from './src/users.js';
5743
58import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';44import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -62,18 +48,16 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr
62import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';48import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
63import initRequestProxy from './src/request-proxy.js';49import initRequestProxy from './src/request-proxy.js';
64import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';50import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
51import corsProxyMiddleware from './src/middleware/corsProxy.js';
65import {52import {
66 getVersion,53 getVersion,
67 getConfigValue,54 getConfigValue,
68 color,55 color,
69 forwardFetchResponse,
70 removeColorFormatting,56 removeColorFormatting,
71 getSeparator,57 getSeparator,
72 stringToBool,
73 urlHostnameToIPv6,
74 canResolve,
75 safeReadFileSync,58 safeReadFileSync,
76 setupLogLevel,59 setupLogLevel,
60 setWindowTitle,
77} from './src/util.js';61} from './src/util.js';
78import { UPLOADS_DIRECTORY } from './src/constants.js';62import { UPLOADS_DIRECTORY } from './src/constants.js';
79import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';63import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
@@ -82,45 +66,15 @@ import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
82import { router as usersPublicRouter } from './src/endpoints/users-public.js';66import { router as usersPublicRouter } from './src/endpoints/users-public.js';
83import { router as usersPrivateRouter } from './src/endpoints/users-private.js';67import { router as usersPrivateRouter } from './src/endpoints/users-private.js';
84import { router as usersAdminRouter } from './src/endpoints/users-admin.js';68import { router as usersAdminRouter } from './src/endpoints/users-admin.js';
85import { router as movingUIRouter } from './src/endpoints/moving-ui.js';69import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
86import { router as imagesRouter } from './src/endpoints/images.js';70import { checkForNewContent } from './src/endpoints/content-manager.js';
87import { router as quickRepliesRouter } from './src/endpoints/quick-replies.js';71import { init as settingsInit } from './src/endpoints/settings.js';
88import { router as avatarsRouter } from './src/endpoints/avatars.js';72import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
89import { router as themesRouter } from './src/endpoints/themes.js';73
90import { router as openAiRouter } from './src/endpoints/openai.js';74// Unrestrict console logs display limit
91import { router as googleRouter } from './src/endpoints/google.js';75util.inspect.defaultOptions.maxArrayLength = null;
92import { router as anthropicRouter } from './src/endpoints/anthropic.js';76util.inspect.defaultOptions.maxStringLength = null;
93import { router as tokenizersRouter } from './src/endpoints/tokenizers.js';77util.inspect.defaultOptions.depth = 4;
94import { router as presetsRouter } from './src/endpoints/presets.js';
95import { router as secretsRouter } from './src/endpoints/secrets.js';
96import { router as thumbnailRouter } from './src/endpoints/thumbnails.js';
97import { router as novelAiRouter } from './src/endpoints/novelai.js';
98import { router as extensionsRouter } from './src/endpoints/extensions.js';
99import { router as assetsRouter } from './src/endpoints/assets.js';
100import { router as filesRouter } from './src/endpoints/files.js';
101import { router as charactersRouter } from './src/endpoints/characters.js';
102import { router as chatsRouter } from './src/endpoints/chats.js';
103import { router as groupsRouter } from './src/endpoints/groups.js';
104import { router as worldInfoRouter } from './src/endpoints/worldinfo.js';
105import { router as statsRouter, init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
106import { router as backgroundsRouter } from './src/endpoints/backgrounds.js';
107import { router as spritesRouter } from './src/endpoints/sprites.js';
108import { router as contentManagerRouter, checkForNewContent } from './src/endpoints/content-manager.js';
109import { router as settingsRouter, init as settingsInit } from './src/endpoints/settings.js';
110import { router as stableDiffusionRouter } from './src/endpoints/stable-diffusion.js';
111import { router as hordeRouter } from './src/endpoints/horde.js';
112import { router as vectorsRouter } from './src/endpoints/vectors.js';
113import { router as translateRouter } from './src/endpoints/translate.js';
114import { router as classifyRouter } from './src/endpoints/classify.js';
115import { router as captionRouter } from './src/endpoints/caption.js';
116import { router as searchRouter } from './src/endpoints/search.js';
117import { router as openRouterRouter } from './src/endpoints/openrouter.js';
118import { router as chatCompletionsRouter } from './src/endpoints/backends/chat-completions.js';
119import { router as koboldRouter } from './src/endpoints/backends/kobold.js';
120import { router as textCompletionsRouter } from './src/endpoints/backends/text-completions.js';
121import { router as scaleAltRouter } from './src/endpoints/backends/scale-alt.js';
122import { router as speechRouter } from './src/endpoints/speech.js';
123import { router as azureRouter } from './src/endpoints/azure.js';
12478
125// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.79// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
126// https://github.com/nodejs/node/issues/47822#issuecomment-156470887080// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -130,122 +84,14 @@ if (process.versions && process.versions.node && process.versions.node.match(/20
130 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);84 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
131}85}
13286
133const DEFAULT_PORT = 8000;87const cliParser = new CommandLineParser();
134const DEFAULT_AUTORUN = false;88const cliArgs = cliParser.parse(process.argv);
135const DEFAULT_LISTEN = false;89globalThis.DATA_ROOT = cliArgs.dataRoot;
136const DEFAULT_LISTEN_ADDRESS_IPV6 = '[::]';90
137const DEFAULT_LISTEN_ADDRESS_IPV4 = '0.0.0.0';91if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
138const DEFAULT_CORS_PROXY = false;92 console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
139const DEFAULT_WHITELIST = true;93 process.exit(1);
140const DEFAULT_ACCOUNTS = false;94}
141const DEFAULT_CSRF_DISABLED = false;
142const DEFAULT_BASIC_AUTH = false;
143const DEFAULT_PER_USER_BASIC_AUTH = false;
144
145const DEFAULT_ENABLE_IPV6 = false;
146const DEFAULT_ENABLE_IPV4 = true;
147
148const DEFAULT_PREFER_IPV6 = false;
149
150const DEFAULT_AVOID_LOCALHOST = false;
151
152const DEFAULT_AUTORUN_HOSTNAME = 'auto';
153const DEFAULT_AUTORUN_PORT = -1;
154
155const DEFAULT_PROXY_ENABLED = false;
156const DEFAULT_PROXY_URL = '';
157const DEFAULT_PROXY_BYPASS = [];
158
159const 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
250// change all relative paths96// change all relative paths
251const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));97const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -259,53 +105,10 @@ app.use(helmet({
259app.use(compression());105app.use(compression());
260app.use(responseTime());106app.use(responseTime());
261107
262
263/** @type {number} */
264const server_port = cliArguments.port ?? getConfigValue('port', DEFAULT_PORT, 'number');
265/** @type {boolean} */
266const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN, 'boolean')) && !cliArguments.ssl;
267/** @type {boolean} */
268const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN, 'boolean');
269/** @type {string} */
270const listenAddressIPv6 = cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', DEFAULT_LISTEN_ADDRESS_IPV6);
271/** @type {string} */
272const listenAddressIPv4 = cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', DEFAULT_LISTEN_ADDRESS_IPV4);
273/** @type {boolean} */
274const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY, 'boolean');
275const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST, 'boolean');
276/** @type {string} */
277globalThis.DATA_ROOT = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
278/** @type {boolean} */
279const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED, 'boolean');
280const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH, 'boolean');
281const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH, 'boolean');
282/** @type {boolean} */
283const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS, 'boolean');
284
285const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
286
287
288/** @type {boolean | string} */
289let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6)) ?? DEFAULT_ENABLE_IPV6;
290/** @type {boolean | string} */
291let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4)) ?? DEFAULT_ENABLE_IPV4;
292
293/** @type {string} */
294const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);
295/** @type {number} */
296const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT, 'number');
297
298/** @type {boolean} */108/** @type {boolean} */
299const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6, 'boolean');109const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean');
300110
301/** @type {boolean} */111if (cliArgs.dnsPreferIPv6) {
302const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST, 'boolean');
303
304const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED, 'boolean');
305const proxyUrl = cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', DEFAULT_PROXY_URL);
306const proxyBypass = cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', DEFAULT_PROXY_BYPASS);
307
308if (dnsPreferIPv6) {
309 // Set default DNS resolution order to IPv6 first112 // Set default DNS resolution order to IPv6 first
310 dns.setDefaultResultOrder('ipv6first');113 dns.setDefaultResultOrder('ipv6first');
311 console.log('Preferring IPv6 for DNS resolution');114 console.log('Preferring IPv6 for DNS resolution');
@@ -315,23 +118,6 @@ if (dnsPreferIPv6) {
315 console.log('Preferring IPv4 for DNS resolution');118 console.log('Preferring IPv4 for DNS resolution');
316}119}
317120
318
319const ipOptions = [true, 'auto', false];
320
321if (!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}
325if (!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
330if (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
335// CORS Settings //121// CORS Settings //
336const CORS = cors({122const CORS = cors({
337 origin: 'null',123 origin: 'null',
@@ -340,59 +126,23 @@ const CORS = cors({
340126
341app.use(CORS);127app.use(CORS);
342128
343if (listen && basicAuthMode) {129if (cliArgs.listen && cliArgs.basicAuthMode) {
344 app.use(basicAuthMiddleware);130 app.use(basicAuthMiddleware);
345}131}
346132
347if (enableWhitelist) {133if (cliArgs.whitelistMode) {
348 app.use(whitelistMiddleware());134 app.use(whitelistMiddleware());
349}135}
350136
351if (listen) {137if (cliArgs.listen) {
352 app.use(accessLoggerMiddleware());138 app.use(accessLoggerMiddleware());
353}139}
354140
355if (enableCorsProxy) {141if (cliArgs.enableCorsProxy) {
356 app.use(bodyParser.json({142 app.use(bodyParser.json({
357 limit: '200mb',143 limit: '200mb',
358 }));144 }));
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 });
396} else {146} else {
397 app.use('/proxy/:url(*)', async (_, res) => {147 app.use('/proxy/:url(*)', async (_, res) => {
398 const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';148 const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
@@ -401,74 +151,6 @@ if (enableCorsProxy) {
401 });151 });
402}152}
403153
404function 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 */
432async 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
472app.use(cookieSession({154app.use(cookieSession({
473 name: getCookieSessionName(),155 name: getCookieSessionName(),
474 sameSite: 'strict',156 sameSite: 'strict',
@@ -480,7 +162,7 @@ app.use(cookieSession({
480app.use(setUserDataMiddleware);162app.use(setUserDataMiddleware);
481163
482// CSRF Protection //164// CSRF Protection //
483if (!disableCsrf) {165if (!cliArgs.disableCsrf) {
484 const csrfSyncProtection = csrfSync({166 const csrfSyncProtection = csrfSync({
485 getTokenFromState: (req) => {167 getTokenFromState: (req) => {
486 if (!req.session) {168 if (!req.session) {
@@ -542,7 +224,7 @@ app.get('/login', async (request, response) => {
542 }224 }
543225
544 try {226 try {
545 const autoLogin = await tryAutoLogin(request, basicAuthMode);227 const autoLogin = await tryAutoLogin(request, cliArgs.basicAuthMode);
546228
547 if (autoLogin) {229 if (autoLogin) {
548 return response.redirect('/');230 return response.redirect('/');
@@ -573,6 +255,7 @@ app.get('/api/ping', (request, response) => {
573});255});
574256
575// File uploads257// File uploads
258const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
576app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));259app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
577app.use(multerMonkeyPatch);260app.use(multerMonkeyPatch);
578261
@@ -588,170 +271,14 @@ app.get('/version', async function (_, response) {
588 response.send(data);271 response.send(data);
589});272});
590273
591function cleanUploads() {274redirectDeprecatedEndpoints(app);
592 try {275setupPrivateEndpoints(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 */
617function 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
626redirect('/createcharacter', '/api/characters/create');
627redirect('/renamecharacter', '/api/characters/rename');
628redirect('/editcharacter', '/api/characters/edit');
629redirect('/editcharacterattribute', '/api/characters/edit-attribute');
630redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
631redirect('/deletecharacter', '/api/characters/delete');
632redirect('/getcharacters', '/api/characters/all');
633redirect('/getonecharacter', '/api/characters/get');
634redirect('/getallchatsofcharacter', '/api/characters/chats');
635redirect('/importcharacter', '/api/characters/import');
636redirect('/dupecharacter', '/api/characters/duplicate');
637redirect('/exportcharacter', '/api/characters/export');
638
639// Redirect deprecated chat API endpoints
640redirect('/savechat', '/api/chats/save');
641redirect('/getchat', '/api/chats/get');
642redirect('/renamechat', '/api/chats/rename');
643redirect('/delchat', '/api/chats/delete');
644redirect('/exportchat', '/api/chats/export');
645redirect('/importgroupchat', '/api/chats/group/import');
646redirect('/importchat', '/api/chats/import');
647redirect('/getgroupchat', '/api/chats/group/get');
648redirect('/deletegroupchat', '/api/chats/group/delete');
649redirect('/savegroupchat', '/api/chats/group/save');
650
651// Redirect deprecated group API endpoints
652redirect('/getgroups', '/api/groups/all');
653redirect('/creategroup', '/api/groups/create');
654redirect('/editgroup', '/api/groups/edit');
655redirect('/deletegroup', '/api/groups/delete');
656
657// Redirect deprecated worldinfo API endpoints
658redirect('/getworldinfo', '/api/worldinfo/get');
659redirect('/deleteworldinfo', '/api/worldinfo/delete');
660redirect('/importworldinfo', '/api/worldinfo/import');
661redirect('/editworldinfo', '/api/worldinfo/edit');
662
663// Redirect deprecated stats API endpoints
664redirect('/getstats', '/api/stats/get');
665redirect('/recreatestats', '/api/stats/recreate');
666redirect('/updatestats', '/api/stats/update');
667
668// Redirect deprecated backgrounds API endpoints
669redirect('/getbackgrounds', '/api/backgrounds/all');
670redirect('/delbackground', '/api/backgrounds/delete');
671redirect('/renamebackground', '/api/backgrounds/rename');
672redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
673
674// Redirect deprecated theme API endpoints
675redirect('/savetheme', '/api/themes/save');
676
677// Redirect deprecated avatar API endpoints
678redirect('/getuseravatars', '/api/avatars/get');
679redirect('/deleteuseravatar', '/api/avatars/delete');
680redirect('/uploaduseravatar', '/api/avatars/upload');
681
682// Redirect deprecated quick reply endpoints
683redirect('/deletequickreply', '/api/quick-replies/delete');
684redirect('/savequickreply', '/api/quick-replies/save');
685
686// Redirect deprecated image endpoints
687redirect('/uploadimage', '/api/images/upload');
688redirect('/listimgfiles/:folder', '/api/images/list/:folder');
689redirect('/api/content/import', '/api/content/importURL');
690
691// Redirect deprecated moving UI endpoints
692redirect('/savemovingui', '/api/moving-ui/save');
693
694// Redirect Serp endpoints
695redirect('/api/serpapi/search', '/api/search/serpapi');
696redirect('/api/serpapi/visit', '/api/search/visit');
697redirect('/api/serpapi/transcript', '/api/search/transcript');
698
699app.use('/api/moving-ui', movingUIRouter);
700app.use('/api/images', imagesRouter);
701app.use('/api/quick-replies', quickRepliesRouter);
702app.use('/api/avatars', avatarsRouter);
703app.use('/api/themes', themesRouter);
704app.use('/api/openai', openAiRouter);
705app.use('/api/google', googleRouter);
706app.use('/api/anthropic', anthropicRouter);
707app.use('/api/tokenizers', tokenizersRouter);
708app.use('/api/presets', presetsRouter);
709app.use('/api/secrets', secretsRouter);
710app.use('/thumbnail', thumbnailRouter);
711app.use('/api/novelai', novelAiRouter);
712app.use('/api/extensions', extensionsRouter);
713app.use('/api/assets', assetsRouter);
714app.use('/api/files', filesRouter);
715app.use('/api/characters', charactersRouter);
716app.use('/api/chats', chatsRouter);
717app.use('/api/groups', groupsRouter);
718app.use('/api/worldinfo', worldInfoRouter);
719app.use('/api/stats', statsRouter);
720app.use('/api/backgrounds', backgroundsRouter);
721app.use('/api/sprites', spritesRouter);
722app.use('/api/content', contentManagerRouter);
723app.use('/api/settings', settingsRouter);
724app.use('/api/sd', stableDiffusionRouter);
725app.use('/api/horde', hordeRouter);
726app.use('/api/vector', vectorsRouter);
727app.use('/api/translate', translateRouter);
728app.use('/api/extra/classify', classifyRouter);
729app.use('/api/extra/caption', captionRouter);
730app.use('/api/search', searchRouter);
731app.use('/api/backends/text-completions', textCompletionsRouter);
732app.use('/api/openrouter', openRouterRouter);
733app.use('/api/backends/kobold', koboldRouter);
734app.use('/api/backends/chat-completions', chatCompletionsRouter);
735app.use('/api/backends/scale-alt', scaleAltRouter);
736app.use('/api/speech', speechRouter);
737app.use('/api/azure', azureRouter);
738
739const tavernUrlV6 = new URL(
740 (cliArguments.ssl ? 'https://' : 'http://') +
741 (listen ? (ipRegex.v6({ exact: true }).test(listenAddressIPv6) ? listenAddressIPv6 : '[::]') : '[::1]') +
742 (':' + server_port),
743);
744
745const 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
751/**277/**
752 * Tasks that need to be run before the server starts listening.278 * Tasks that need to be run before the server starts listening.
279 * @returns {Promise<void>}
753 */280 */
754const preSetupTasks = async function () {281async function preSetupTasks() {
755 const version = await getVersion();282 const version = await getVersion();
756283
757 // Print formatted header284 // Print formatted header
@@ -797,56 +324,25 @@ const preSetupTasks = async function () {
797 });324 });
798325
799 // Add request proxy.326 // Add request proxy.
800 initRequestProxy({ enabled: proxyEnabled, url: proxyUrl, bypass: proxyBypass });327 initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass });
801328
802 // Wait for frontend libs to compile329 // Wait for frontend libs to compile
803 await webpackMiddleware.runWebpackCompiler();330 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 */
812async 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;
830}331}
831332
832/**333/**
833 * Tasks that need to be run after the server starts listening.334 * Tasks that need to be run after the server starts listening.
834 * @param {boolean} v6Failed If the server failed to start on IPv6335 * @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 IPv4336 * @returns {Promise<void>}
836 * @param {boolean} useIPv6 If the server is using IPv6
837 * @param {boolean} useIPv4 If the server is using IPv4
838 */337 */
839const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {338async function postSetupTasks({ v6Failed, v4Failed, useIPv6, useIPv4 }) {
840 const autorunUrl = new URL(339 const autorunHostname = await cliArgs.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
847 console.log('Launching...');341 console.log('Launching...');
848342
849 if (autorun) open(autorunUrl.toString());343 if (cliArgs.autorun) {
344 open(autorunUrl.toString());
345 }
850346
851 setWindowTitle('SillyTavern WebServer');347 setWindowTitle('SillyTavern WebServer');
852348
@@ -854,13 +350,13 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
854350
855 if (useIPv6 && !v6Failed) {351 if (useIPv6 && !v6Failed) {
856 logListen += color.green(352 logListen += color.green(
857 ' IPv6: ' + tavernUrlV6.host,353 ' IPv6: ' + cliArgs.getIPv6ListenUrl().host,
858 );354 );
859 }355 }
860356
861 if (useIPv4 && !v4Failed) {357 if (useIPv4 && !v4Failed) {
862 logListen += color.green(358 logListen += color.green(
863 ' IPv4: ' + tavernUrl.host,359 ' IPv4: ' + cliArgs.getIPv4ListenUrl().host,
864 );360 );
865 }361 }
866362
@@ -868,7 +364,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
868 const plainGoToLog = removeColorFormatting(goToLog);364 const plainGoToLog = removeColorFormatting(goToLog);
869365
870 console.log(logListen);366 console.log(logListen);
871 if (listen) {367 if (cliArgs.listen) {
872 console.log();368 console.log();
873 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');369 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
874 console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));370 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) {
877 console.log(goToLog);373 console.log(goToLog);
878 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');374 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
897 setupLogLevel();376 setupLogLevel();
898};377}
899378
900/**379/**
901 * Loads server plugins from a directory.380 * Loads server plugins from a directory.
@@ -913,25 +392,12 @@ async function initializePlugins() {
913}392}
914393
915/**394/**
916 * Set the title of the terminal window
917 * @param {string} title Desired title for the window
918 */
919function 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/**
929 * Prints an error message and exits the process if necessary395 * Prints an error message and exits the process if necessary
930 * @param {string} message The error message to print396 * @param {string} message The error message to print
931 * @returns {void}397 * @returns {void}
932 */398 */
933function logSecurityAlert(message) {399function logSecurityAlert(message) {
934 if (basicAuthMode || enableWhitelist) return; // safe!400 if (cliArgs.basicAuthMode || cliArgs.whitelistMode) return; // safe!
935 console.error(color.red(message));401 console.error(color.red(message));
936 if (getConfigValue('securityOverride', false, 'boolean')) {402 if (getConfigValue('securityOverride', false, 'boolean')) {
937 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));403 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) {
940 process.exit(1);406 process.exit(1);
941}407}
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 */
950function 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 */
974function 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 */
1002function 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 */
1024async 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
1055async 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
1111async function verifySecuritySettings() {409async function verifySecuritySettings() {
1112 // Skip all security checks as listen is set to false410 // Skip all security checks as listen is set to false
1113 if (!listen) {411 if (!cliArgs.listen) {
1114 return;412 return;
1115 }413 }
1116414
@@ -1133,6 +431,23 @@ async function verifySecuritySettings() {
1133 logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');431 logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
1134 }432 }
1135 }433 }
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 }
1136}451}
1137452
1138/**453/**
@@ -1153,4 +468,5 @@ initUserStorage(globalThis.DATA_ROOT)
1153 .then(verifySecuritySettings)468 .then(verifySecuritySettings)
1154 .then(preSetupTasks)469 .then(preSetupTasks)
1155 .then(apply404Middleware)470 .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 @@
1import yargs from 'yargs/yargs';
2import { hideBin } from 'yargs/helpers';
3import ipRegex from 'ip-regex';
4import { 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
43export 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 @@
1import fetch from 'node-fetch';
2import { 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 */
9export 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 @@
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 movingUIRouter } from './endpoints/moving-ui.js';
8import { router as imagesRouter } from './endpoints/images.js';
9import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
10import { router as avatarsRouter } from './endpoints/avatars.js';
11import { router as themesRouter } from './endpoints/themes.js';
12import { router as openAiRouter } from './endpoints/openai.js';
13import { router as googleRouter } from './endpoints/google.js';
14import { router as anthropicRouter } from './endpoints/anthropic.js';
15import { router as tokenizersRouter } from './endpoints/tokenizers.js';
16import { router as presetsRouter } from './endpoints/presets.js';
17import { router as secretsRouter } from './endpoints/secrets.js';
18import { router as thumbnailRouter } from './endpoints/thumbnails.js';
19import { router as novelAiRouter } from './endpoints/novelai.js';
20import { router as extensionsRouter } from './endpoints/extensions.js';
21import { router as assetsRouter } from './endpoints/assets.js';
22import { router as filesRouter } from './endpoints/files.js';
23import { router as charactersRouter } from './endpoints/characters.js';
24import { router as chatsRouter } from './endpoints/chats.js';
25import { router as groupsRouter } from './endpoints/groups.js';
26import { router as worldInfoRouter } from './endpoints/worldinfo.js';
27import { router as statsRouter } from './endpoints/stats.js';
28import { router as contentManagerRouter } from './endpoints/content-manager.js';
29import { router as settingsRouter } from './endpoints/settings.js';
30import { router as backgroundsRouter } from './endpoints/backgrounds.js';
31import { router as spritesRouter } from './endpoints/sprites.js';
32import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
33import { router as hordeRouter } from './endpoints/horde.js';
34import { router as vectorsRouter } from './endpoints/vectors.js';
35import { router as translateRouter } from './endpoints/translate.js';
36import { router as classifyRouter } from './endpoints/classify.js';
37import { router as captionRouter } from './endpoints/caption.js';
38import { router as searchRouter } from './endpoints/search.js';
39import { router as openRouterRouter } from './endpoints/openrouter.js';
40import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
41import { router as koboldRouter } from './endpoints/backends/kobold.js';
42import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
43import { router as scaleAltRouter } from './endpoints/backends/scale-alt.js';
44import { router as speechRouter } from './endpoints/speech.js';
45import { 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 */
59export 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 */
130export 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 */
175export 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';
14import _ from 'lodash';14import _ from 'lodash';
15import { sync as writeFileAtomicSync } from 'write-file-atomic';15import { sync as writeFileAtomicSync } from 'write-file-atomic';
1616
17import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE } from './constants.js';17import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
18import { getConfigValue, color, delay, generateTimestamp } from './util.js';18import { getConfigValue, color, delay, generateTimestamp } from './util.js';
19import { readSecret, writeSecret } from './endpoints/secrets.js';19import { readSecret, writeSecret } from './endpoints/secrets.js';
20import { getContentOfType } from './endpoints/content-manager.js';20import { getContentOfType } from './endpoints/content-manager.js';
@@ -120,6 +120,27 @@ export async function ensurePublicDirectoriesExist() {
120 return directoriesList;120 return directoriesList;
121}121}
122122
123export 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
123/**144/**
124 * Gets a list of all user directories.145 * Gets a list of all user directories.
125 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories146 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
@@ -478,6 +499,25 @@ export function getCookieSessionName() {
478 return `session-${suffix}`;499 return `session-${suffix}`;
479}500}
480501
502export 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
481/**521/**
482 * Hashes a password using scrypt with the provided salt.522 * Hashes a password using scrypt with the provided salt.
483 * @param {string} password Password to hash523 * @param {string} password Password to hash
src/util.js+64 -0
@@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
6import { createRequire } from 'node:module';6import { createRequire } from 'node:module';
7import { Buffer } from 'node:buffer';7import { Buffer } from 'node:buffer';
8import { promises as dnsPromise } from 'node:dns';8import { promises as dnsPromise } from 'node:dns';
9import os from 'os';
910
10import yaml from 'yaml';11import yaml from 'yaml';
11import { sync as commandExistsSync } from 'command-exists';12import { sync as commandExistsSync } from 'command-exists';
@@ -771,6 +772,56 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
771}772}
772773
773/**774/**
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 */
783export 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/**
774 * Converts various JavaScript primitives to boolean values.825 * Converts various JavaScript primitives to boolean values.
775 * Handles special case for "true"/"false" strings (case-insensitive)826 * Handles special case for "true"/"false" strings (case-insensitive)
776 *827 *
@@ -1005,3 +1056,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
1005 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);1056 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
1006 return null;1057 return null;
1007}1058}
1059
1060/**
1061 * Set the title of the terminal window
1062 * @param {string} title Desired title for the window
1063 */
1064export 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}