Merge pull request #3567 from SillyTavern/cli-args-refactor Refactor server startup

f43b42544b39d21ef50323660f044e46eb5fd220

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

Signed
10 files changed, +1053 -918Ignore whitespace
.github/readme.md+47 -23
@@ -317,29 +317,34 @@ Start.bat --port 8000 --listen false
317317
318### Supported arguments318### Supported arguments
319319
320| Option | Description | Type |320> \[!TIP]
321|-------------------------|------------------------------------------------------------------------------------------------------|----------|321> None of the arguments are required. If you don't provide them, SillyTavern will use the settings in `config.yaml`.
322| `--version` | Show version number | boolean |322
323| `--enableIPv6` | Enables IPv6. | boolean |323| Option | Description | Type |
324| `--enableIPv4` | Enables IPv4. | boolean |324|-------------------------|----------------------------------------------------------------------|----------|
325| `--port` | Sets the port under which SillyTavern will run. If not provided falls back to yaml config 'port'. | number |325| `--version` | Show version number | boolean |
326| `--dnsPreferIPv6` | Prefers IPv6 for dns. If not provided falls back to yaml config 'preferIPv6'. | boolean |326| `--dataRoot` | Root directory for data storage | string |
327| `--autorun` | Automatically launch SillyTavern in the browser. If not provided falls back to yaml config 'autorun'.| boolean |327| `--port` | Sets the port under which SillyTavern will run | number |
328| `--autorunHostname` | The autorun hostname, probably best left on 'auto'. | string |328| `--listen` | SillyTavern will listen on all network interfaces | boolean |
329| `--autorunPortOverride` | Overrides the port for autorun. | string |329| `--whitelist` | Enables whitelist mode | boolean |
330| `--listen` | SillyTavern is listening on all network interfaces. If not provided falls back to yaml config 'listen'.| boolean |330| `--basicAuthMode` | Enables basic authentication | boolean |
331| `--corsProxy` | Enables CORS proxy. If not provided falls back to yaml config 'enableCorsProxy'. | boolean |331| `--enableIPv4` | Enables IPv4 protocol | boolean |
332| `--disableCsrf` | Disables CSRF protection | boolean |332| `--enableIPv6` | Enables IPv6 protocol | boolean |
333| `--ssl` | Enables SSL | boolean |333| `--listenAddressIPv4` | Specific IPv4 address to listen to | string |
334| `--certPath` | Path to your certificate file. | string |334| `--listenAddressIPv6` | Specific IPv6 address to listen to | string |
335| `--keyPath` | Path to your private key file. | string |335| `--dnsPreferIPv6` | Prefers IPv6 for DNS | boolean |
336| `--whitelist` | Enables whitelist mode | boolean |336| `--ssl` | Enables SSL | boolean |
337| `--dataRoot` | Root directory for data storage | string |337| `--certPath` | Path to your certificate file | string |
338| `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode. | boolean |338| `--keyPath` | Path to your private key file | string |
339| `--basicAuthMode` | Enables basic authentication | boolean |339| `--autorun` | Automatically launch SillyTavern in the browser | boolean |
340| `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean |340| `--autorunHostname` | Autorun hostname | string |
341| `--requestProxyUrl` | Request proxy URL (HTTP or SOCKS protocols) | string |341| `--autorunPortOverride` | Overrides the port for autorun | string |
342| `--requestProxyBypass` | Request proxy bypass list (space separated list of hosts) | array |342| `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode | boolean |
343| `--corsProxy` | Enables CORS proxy | boolean |
344| `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean |
345| `--requestProxyUrl` | Request proxy URL (HTTP or SOCKS protocols) | string |
346| `--requestProxyBypass` | Request proxy bypass list (space separated list of hosts) | array |
347| `--disableCsrf` | Disables CSRF protection (NOT RECOMMENDED) | boolean |
343348
344## Remote connections349## Remote connections
345350
@@ -351,10 +356,29 @@ You may also want to configure SillyTavern user profiles with (optional) passwor
351356
352## Performance issues?357## Performance issues?
353358
359### General tips
360
3541. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category).3611. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category).
3552. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended).3622. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended).
3563. Make sure the browser is enabled to use GPU acceleration for rendering.3633. Make sure the browser is enabled to use GPU acceleration for rendering.
357364
365### Input lag
366
367Performance degradation, particularly input lag, is most commonly attributed to browser extensions. Known problematic extensions include:
368
369* iCloud Password Manager
370* DeepL Translation
371* AI-based grammar correction tools
372* Various ad-blocking extensions
373
374If you experience performance issues and cannot identify the cause, or suspect an issue with SillyTavern itself, please:
375
3761. [Record a performance profile](https://developer.chrome.com/docs/devtools/performance/reference)
3772. Export the profile as a JSON file
3783. Submit it to the development team for analysis
379
380We recommend first testing with all browser extensions and third-party SillyTavern extensions disabled to isolate the source of the performance degradation.
381
358## License and credits382## License and credits
359383
360**This program is distributed in the hope that it will be useful,384**This program is distributed in the hope that it will be useful,
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
index.d.ts+6 -0
@@ -1,4 +1,5 @@
1import { UserDirectoryList, User } from "./src/users";1import { UserDirectoryList, User } from "./src/users";
2import { CommandLineArguments } from "./src/command-line";
2import { CsrfSyncedToken } from "csrf-sync";3import { CsrfSyncedToken } from "csrf-sync";
34
4declare global {5declare global {
@@ -32,4 +33,9 @@ declare global {
32 * The root directory for user data.33 * The root directory for user data.
33 */34 */
34 var DATA_ROOT: string;35 var DATA_ROOT: string;
36
37 /**
38 * Parsed command line arguments.
39 */
40 var COMMAND_LINE_ARGS: CommandLineArguments;
35}41}
server.js+71 -858
@@ -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,23 +17,15 @@ 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';
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;
3921
40// local library imports22// local library imports
23import { CommandLineParser } from './src/command-line.js';
41import { loadPlugins } from './src/plugin-loader.js';24import { loadPlugins } from './src/plugin-loader.js';
42import {25import {
43 initUserStorage,26 initUserStorage,
44 getCookieSecret,27 getCookieSecret,
45 getCookieSessionName,28 getCookieSessionName,
46 getAllEnabledUsers,
47 ensurePublicDirectoriesExist,29 ensurePublicDirectoriesExist,
48 getUserDirectoriesList,30 getUserDirectoriesList,
49 migrateSystemPrompts,31 migrateSystemPrompts,
@@ -51,8 +33,10 @@ import {
51 requireLoginMiddleware,33 requireLoginMiddleware,
52 setUserDataMiddleware,34 setUserDataMiddleware,
53 shouldRedirectToLogin,35 shouldRedirectToLogin,
54 tryAutoLogin,36 cleanUploads,
55 router as userDataRouter,37 getSessionCookieAge,
38 verifySecuritySettings,
39 loginPageMiddleware,
56} from './src/users.js';40} from './src/users.js';
5741
58import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';42import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -62,65 +46,35 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr
62import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';46import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
63import initRequestProxy from './src/request-proxy.js';47import initRequestProxy from './src/request-proxy.js';
64import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';48import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
49import corsProxyMiddleware from './src/middleware/corsProxy.js';
65import {50import {
66 getVersion,51 getVersion,
67 getConfigValue,
68 color,52 color,
69 forwardFetchResponse,
70 removeColorFormatting,53 removeColorFormatting,
71 getSeparator,54 getSeparator,
72 stringToBool,
73 urlHostnameToIPv6,
74 canResolve,
75 safeReadFileSync,55 safeReadFileSync,
76 setupLogLevel,56 setupLogLevel,
57 setWindowTitle,
77} from './src/util.js';58} from './src/util.js';
78import { UPLOADS_DIRECTORY } from './src/constants.js';59import { UPLOADS_DIRECTORY } from './src/constants.js';
79import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';60import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
8061
81// Routers62// Routers
82import { router as usersPublicRouter } from './src/endpoints/users-public.js';63import { router as usersPublicRouter } from './src/endpoints/users-public.js';
83import { router as usersPrivateRouter } from './src/endpoints/users-private.js';64import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
84import { router as usersAdminRouter } from './src/endpoints/users-admin.js';65import { checkForNewContent } from './src/endpoints/content-manager.js';
85import { router as movingUIRouter } from './src/endpoints/moving-ui.js';66import { init as settingsInit } from './src/endpoints/settings.js';
86import { router as imagesRouter } from './src/endpoints/images.js';67import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
87import { router as quickRepliesRouter } from './src/endpoints/quick-replies.js';68
88import { router as avatarsRouter } from './src/endpoints/avatars.js';69// Unrestrict console logs display limit
89import { router as themesRouter } from './src/endpoints/themes.js';70util.inspect.defaultOptions.maxArrayLength = null;
90import { router as openAiRouter } from './src/endpoints/openai.js';71util.inspect.defaultOptions.maxStringLength = null;
91import { router as googleRouter } from './src/endpoints/google.js';72util.inspect.defaultOptions.depth = 4;
92import { router as anthropicRouter } from './src/endpoints/anthropic.js';73
93import { router as tokenizersRouter } from './src/endpoints/tokenizers.js';74// Set a working directory for the server
94import { router as presetsRouter } from './src/endpoints/presets.js';75const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
95import { router as secretsRouter } from './src/endpoints/secrets.js';76console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
96import { router as thumbnailRouter } from './src/endpoints/thumbnails.js';77process.chdir(serverDirectory);
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,127 +84,26 @@ 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 cliArgs = new CommandLineParser().parse(process.argv);
134const DEFAULT_AUTORUN = false;88globalThis.DATA_ROOT = cliArgs.dataRoot;
135const DEFAULT_LISTEN = false;89globalThis.COMMAND_LINE_ARGS = cliArgs;
136const DEFAULT_LISTEN_ADDRESS_IPV6 = '[::]';
137const DEFAULT_LISTEN_ADDRESS_IPV4 = '0.0.0.0';
138const DEFAULT_CORS_PROXY = false;
139const DEFAULT_WHITELIST = true;
140const DEFAULT_ACCOUNTS = false;
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;
15190
152const DEFAULT_AUTORUN_HOSTNAME = 'auto';91if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
153const DEFAULT_AUTORUN_PORT = -1;92 console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
15493 process.exit(1);
155const DEFAULT_PROXY_ENABLED = false;94}
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 paths96try {
251const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));97 if (cliArgs.dnsPreferIPv6) {
252console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);98 dns.setDefaultResultOrder('ipv6first');
253process.chdir(serverDirectory);99 console.log('Preferring IPv6 for DNS resolution');
100 } else {
101 dns.setDefaultResultOrder('ipv4first');
102 console.log('Preferring IPv4 for DNS resolution');
103 }
104} catch (error) {
105 console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.');
106}
254107
255const app = express();108const app = express();
256app.use(helmet({109app.use(helmet({
@@ -259,79 +112,6 @@ app.use(helmet({
259app.use(compression());112app.use(compression());
260app.use(responseTime());113app.use(responseTime());
261114
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} */
299const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6, 'boolean');
300
301/** @type {boolean} */
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 first
310 dns.setDefaultResultOrder('ipv6first');
311 console.log('Preferring IPv6 for DNS resolution');
312} else {
313 // Set default DNS resolution order to IPv4 first
314 dns.setDefaultResultOrder('ipv4first');
315 console.log('Preferring IPv4 for DNS resolution');
316}
317
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 //115// CORS Settings //
336const CORS = cors({116const CORS = cors({
337 origin: 'null',117 origin: 'null',
@@ -340,59 +120,23 @@ const CORS = cors({
340120
341app.use(CORS);121app.use(CORS);
342122
343if (listen && basicAuthMode) {123if (cliArgs.listen && cliArgs.basicAuthMode) {
344 app.use(basicAuthMiddleware);124 app.use(basicAuthMiddleware);
345}125}
346126
347if (enableWhitelist) {127if (cliArgs.whitelistMode) {
348 app.use(whitelistMiddleware());128 app.use(whitelistMiddleware());
349}129}
350130
351if (listen) {131if (cliArgs.listen) {
352 app.use(accessLoggerMiddleware());132 app.use(accessLoggerMiddleware());
353}133}
354134
355if (enableCorsProxy) {135if (cliArgs.enableCorsProxy) {
356 app.use(bodyParser.json({136 app.use(bodyParser.json({
357 limit: '200mb',137 limit: '200mb',
358 }));138 }));
359 console.log('Enabling CORS proxy');139 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 {140} else {
397 app.use('/proxy/:url(*)', async (_, res) => {141 app.use('/proxy/:url(*)', async (_, res) => {
398 const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';142 const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
@@ -401,74 +145,6 @@ if (enableCorsProxy) {
401 });145 });
402}146}
403147
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({148app.use(cookieSession({
473 name: getCookieSessionName(),149 name: getCookieSessionName(),
474 sameSite: 'strict',150 sameSite: 'strict',
@@ -480,7 +156,7 @@ app.use(cookieSession({
480app.use(setUserDataMiddleware);156app.use(setUserDataMiddleware);
481157
482// CSRF Protection //158// CSRF Protection //
483if (!disableCsrf) {159if (!cliArgs.disableCsrf) {
484 const csrfSyncProtection = csrfSync({160 const csrfSyncProtection = csrfSync({
485 getTokenFromState: (req) => {161 getTokenFromState: (req) => {
486 if (!req.session) {162 if (!req.session) {
@@ -535,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
535});211});
536212
537// Host login page213// Host login page
538app.get('/login', async (request, response) => {214app.get('/login', loginPageMiddleware);
539 if (!enableAccounts) {
540 console.log('User accounts are disabled. Redirecting to index page.');
541 return response.redirect('/');
542 }
543
544 try {
545 const autoLogin = await tryAutoLogin(request, basicAuthMode);
546
547 if (autoLogin) {
548 return response.redirect('/');
549 }
550 } catch (error) {
551 console.error('Error during auto-login:', error);
552 }
553
554 return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
555});
556215
557// Host frontend assets216// Host frontend assets
558const webpackMiddleware = getWebpackServeMiddleware();217const webpackMiddleware = getWebpackServeMiddleware();
@@ -573,185 +232,23 @@ app.get('/api/ping', (request, response) => {
573});232});
574233
575// File uploads234// File uploads
235const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
576app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));236app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
577app.use(multerMonkeyPatch);237app.use(multerMonkeyPatch);
578238
579// User data mount
580app.use('/', userDataRouter);
581// Private endpoints
582app.use('/api/users', usersPrivateRouter);
583// Admin endpoints
584app.use('/api/users', usersAdminRouter);
585
586app.get('/version', async function (_, response) {239app.get('/version', async function (_, response) {
587 const data = await getVersion();240 const data = await getVersion();
588 response.send(data);241 response.send(data);
589});242});
590243
591function cleanUploads() {244redirectDeprecatedEndpoints(app);
592 try {245setupPrivateEndpoints(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);
750246
751/**247/**
752 * Tasks that need to be run before the server starts listening.248 * Tasks that need to be run before the server starts listening.
249 * @returns {Promise<void>}
753 */250 */
754const preSetupTasks = async function () {251async function preSetupTasks() {
755 const version = await getVersion();252 const version = await getVersion();
756253
757 // Print formatted header254 // Print formatted header
@@ -773,7 +270,8 @@ const preSetupTasks = async function () {
773 await settingsInit();270 await settingsInit();
774 await statsInit();271 await statsInit();
775272
776 const cleanupPlugins = await initializePlugins();273 const pluginsDirectory = path.join(serverDirectory, 'plugins');
274 const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
777 const consoleTitle = process.title;275 const consoleTitle = process.title;
778276
779 let isExiting = false;277 let isExiting = false;
@@ -797,70 +295,39 @@ const preSetupTasks = async function () {
797 });295 });
798296
799 // Add request proxy.297 // Add request proxy.
800 initRequestProxy({ enabled: proxyEnabled, url: proxyUrl, bypass: proxyBypass });298 initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass });
801299
802 // Wait for frontend libs to compile300 // Wait for frontend libs to compile
803 await webpackMiddleware.runWebpackCompiler();301 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}302}
831303
832/**304/**
833 * Tasks that need to be run after the server starts listening.305 * Tasks that need to be run after the server starts listening.
834 * @param {boolean} v6Failed If the server failed to start on IPv6306 * @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 IPv4307 * @returns {Promise<void>}
836 * @param {boolean} useIPv6 If the server is using IPv6
837 * @param {boolean} useIPv4 If the server is using IPv4
838 */308 */
839const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {309async function postSetupTasks(result) {
840 const autorunUrl = new URL(310 const autorunHostname = await cliArgs.getAutorunHostname(result);
841 (cliArguments.ssl ? 'https://' : 'http://') +311 const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
842 (await getAutorunHostname(useIPv6, useIPv4)) +
843 (':') +
844 ((autorunPortOverride >= 0) ? autorunPortOverride : server_port),
845 );
846
847 console.log('Launching...');
848312
849 if (autorun) open(autorunUrl.toString());313 if (cliArgs.autorun) {
314 console.log('Launching in a browser...');
315 await open(autorunUrl.toString());
316 }
850317
851 setWindowTitle('SillyTavern WebServer');318 setWindowTitle('SillyTavern WebServer');
852319
853 let logListen = 'SillyTavern is listening on';320 let logListen = 'SillyTavern is listening on';
854321
855 if (useIPv6 && !v6Failed) {322 if (result.useIPv6 && !result.v6Failed) {
856 logListen += color.green(323 logListen += color.green(
857 ' IPv6: ' + tavernUrlV6.host,324 ' IPv6: ' + cliArgs.getIPv6ListenUrl().host,
858 );325 );
859 }326 }
860327
861 if (useIPv4 && !v4Failed) {328 if (result.useIPv4 && !result.v4Failed) {
862 logListen += color.green(329 logListen += color.green(
863 ' IPv4: ' + tavernUrl.host,330 ' IPv4: ' + cliArgs.getIPv4ListenUrl().host,
864 );331 );
865 }332 }
866333
@@ -868,7 +335,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
868 const plainGoToLog = removeColorFormatting(goToLog);335 const plainGoToLog = removeColorFormatting(goToLog);
869336
870 console.log(logListen);337 console.log(logListen);
871 if (listen) {338 if (cliArgs.listen) {
872 console.log();339 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".');340 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()));341 console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
@@ -877,262 +344,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
877 console.log(goToLog);344 console.log(goToLog);
878 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');345 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
879346
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();347 setupLogLevel();
898};
899
900/**
901 * Loads server plugins from a directory.
902 * @returns {Promise<Function>} Function to be run on server exit
903 */
904async function initializePlugins() {
905 try {
906 const pluginDirectory = path.join(serverDirectory, 'plugins');
907 const cleanupPlugins = await loadPlugins(app, pluginDirectory);
908 return cleanupPlugins;
909 } catch {
910 console.log('Plugin loading failed.');
911 return () => { };
912 }
913}
914
915/**
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 necessary
930 * @param {string} message The error message to print
931 * @returns {void}
932 */
933function logSecurityAlert(message) {
934 if (basicAuthMode || enableWhitelist) return; // safe!
935 console.error(color.red(message));
936 if (getConfigValue('securityOverride', false, 'boolean')) {
937 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
938 return;
939 }
940 process.exit(1);
941}
942
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() {
1112 // Skip all security checks as listen is set to false
1113 if (!listen) {
1114 return;
1115 }
1116
1117 if (!enableAccounts) {
1118 logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.');
1119 }
1120
1121 const users = await getAllEnabledUsers();
1122 const unprotectedUsers = users.filter(x => !x.password);
1123 const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin);
1124
1125 if (unprotectedUsers.length > 0) {
1126 console.warn(color.blue('A friendly reminder that the following users are not password protected:'));
1127 unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x));
1128 console.log();
1129 console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`);
1130 console.log();
1131
1132 if (unprotectedAdminUsers.length > 0) {
1133 logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
1134 }
1135 }
1136}348}
1137349
1138/**350/**
@@ -1153,4 +365,5 @@ initUserStorage(globalThis.DATA_ROOT)
1153 .then(verifySecuritySettings)365 .then(verifySecuritySettings)
1154 .then(preSetupTasks)366 .then(preSetupTasks)
1155 .then(apply404Middleware)367 .then(apply404Middleware)
1156 .finally(startServer);368 .then(() => new ServerStartup(app, cliArgs).start())
369 .then(postSetupTasks);
src/command-line.js+262 -0
@@ -0,0 +1,262 @@
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 Parsed command line arguments
8 * @property {string} dataRoot Data root directory
9 * @property {number} port Port number
10 * @property {boolean} listen If SillyTavern is listening on all network interfaces
11 * @property {string} listenAddressIPv6 IPv6 address to listen to
12 * @property {string} listenAddressIPv4 IPv4 address to listen to
13 * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
14 * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
15 * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
16 * @property {boolean} autorun If automatically launch SillyTavern in the browser
17 * @property {string} autorunHostname Autorun hostname
18 * @property {number} autorunPortOverride Autorun port override (-1 is use server port)
19 * @property {boolean} enableCorsProxy If enable CORS proxy
20 * @property {boolean} disableCsrf If disable CSRF protection
21 * @property {boolean} ssl If enable SSL
22 * @property {string} certPath Path to certificate
23 * @property {string} keyPath Path to private key
24 * @property {boolean} whitelistMode If enable whitelist mode
25 * @property {boolean} avoidLocalhost If avoid using 'localhost' for autorun in auto mode
26 * @property {boolean} basicAuthMode If enable basic authentication
27 * @property {boolean} requestProxyEnabled If enable outgoing request proxy
28 * @property {string} requestProxyUrl Request proxy URL
29 * @property {string[]} requestProxyBypass Request proxy bypass list
30 * @property {function(): URL} getIPv4ListenUrl Get IPv4 listen URL
31 * @property {function(): URL} getIPv6ListenUrl Get IPv6 listen URL
32 * @property {function(import('./server-startup.js').ServerStartupResult): Promise<string>} getAutorunHostname Get autorun hostname
33 * @property {function(string): URL} getAutorunUrl Get autorun URL
34 */
35
36/**
37 * Provides a command line arguments parser.
38 */
39export class CommandLineParser {
40 constructor() {
41 /** @type {CommandLineArguments} */
42 this.default = Object.freeze({
43 dataRoot: './data',
44 port: 8000,
45 listen: false,
46 listenAddressIPv6: '[::]',
47 listenAddressIPv4: '0.0.0.0',
48 enableIPv4: true,
49 enableIPv6: false,
50 dnsPreferIPv6: false,
51 autorun: false,
52 autorunHostname: 'auto',
53 autorunPortOverride: -1,
54 enableCorsProxy: false,
55 disableCsrf: false,
56 ssl: false,
57 certPath: 'certs/cert.pem',
58 keyPath: 'certs/privkey.pem',
59 whitelistMode: true,
60 avoidLocalhost: false,
61 basicAuthMode: false,
62 requestProxyEnabled: false,
63 requestProxyUrl: '',
64 requestProxyBypass: [],
65 getIPv4ListenUrl: function () {
66 throw new Error('getIPv4ListenUrl is not implemented');
67 },
68 getIPv6ListenUrl: function () {
69 throw new Error('getIPv6ListenUrl is not implemented');
70 },
71 getAutorunHostname: async function () {
72 throw new Error('getAutorunHostname is not implemented');
73 },
74 getAutorunUrl: function () {
75 throw new Error('getAutorunUrl is not implemented');
76 },
77 });
78
79 this.booleanAutoOptions = [true, false, 'auto'];
80 }
81
82 /**
83 * Parses command line arguments.
84 * Arguments that are not provided will be filled with config values.
85 * @param {string[]} args Process startup arguments.
86 * @returns {CommandLineArguments} Parsed command line arguments.
87 */
88 parse(args) {
89 const cliArguments = yargs(hideBin(args))
90 .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')
91 .option('enableIPv6', {
92 type: 'string',
93 default: null,
94 describe: 'Enables IPv6 protocol',
95 }).option('enableIPv4', {
96 type: 'string',
97 default: null,
98 describe: 'Enables IPv4 protocol',
99 }).option('port', {
100 type: 'number',
101 default: null,
102 describe: 'Sets the server listening port',
103 }).option('dnsPreferIPv6', {
104 type: 'boolean',
105 default: null,
106 describe: 'Prefers IPv6 for DNS\nYou should probably have the enabled if you\'re on an IPv6 only network',
107 }).option('autorun', {
108 type: 'boolean',
109 default: null,
110 describe: 'Automatically launch SillyTavern in the browser',
111 }).option('autorunHostname', {
112 type: 'string',
113 default: null,
114 describe: 'Sets the autorun hostname, probably best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'',
115 }).option('autorunPortOverride', {
116 type: 'string',
117 default: null,
118 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',
119 }).option('listen', {
120 type: 'boolean',
121 default: null,
122 describe: 'Whether to listen on all network interfaces',
123 }).option('listenAddressIPv6', {
124 type: 'string',
125 default: null,
126 describe: 'Specific IPv6 address to listen to',
127 }).option('listenAddressIPv4', {
128 type: 'string',
129 default: null,
130 describe: 'Specific IPv4 address to listen to',
131 }).option('corsProxy', {
132 type: 'boolean',
133 default: null,
134 describe: 'Enables CORS proxy',
135 }).option('disableCsrf', {
136 type: 'boolean',
137 default: null,
138 describe: 'Disables CSRF protection - NOT RECOMMENDED',
139 }).option('ssl', {
140 type: 'boolean',
141 default: null,
142 describe: 'Enables SSL',
143 }).option('certPath', {
144 type: 'string',
145 default: null,
146 describe: 'Path to SSL certificate file',
147 }).option('keyPath', {
148 type: 'string',
149 default: null,
150 describe: 'Path to SSL private key file',
151 }).option('whitelist', {
152 type: 'boolean',
153 default: null,
154 describe: 'Enables whitelist mode',
155 }).option('dataRoot', {
156 type: 'string',
157 default: null,
158 describe: 'Root directory for data storage',
159 }).option('avoidLocalhost', {
160 type: 'boolean',
161 default: null,
162 describe: 'Avoids using \'localhost\' for autorun in auto mode.\nUse if you don\'t have \'localhost\' in your hosts file',
163 }).option('basicAuthMode', {
164 type: 'boolean',
165 default: null,
166 describe: 'Enables basic authentication',
167 }).option('requestProxyEnabled', {
168 type: 'boolean',
169 default: null,
170 describe: 'Enables a use of proxy for outgoing requests',
171 }).option('requestProxyUrl', {
172 type: 'string',
173 default: null,
174 describe: 'Request proxy URL (HTTP or SOCKS protocols)',
175 }).option('requestProxyBypass', {
176 type: 'array',
177 describe: 'Request proxy bypass list (space separated list of hosts)',
178 }).parseSync();
179
180 /** @type {CommandLineArguments} */
181 const result = {
182 dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),
183 port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),
184 listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),
185 listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', this.default.listenAddressIPv6),
186 listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', this.default.listenAddressIPv4),
187 enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', this.default.enableIPv4)) ?? this.default.enableIPv4,
188 enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', this.default.enableIPv6)) ?? this.default.enableIPv6,
189 dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', this.default.dnsPreferIPv6, 'boolean'),
190 autorun: cliArguments.autorun ?? getConfigValue('autorun', this.default.autorun, 'boolean'),
191 autorunHostname: cliArguments.autorunHostname ?? getConfigValue('autorunHostname', this.default.autorunHostname),
192 autorunPortOverride: cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', this.default.autorunPortOverride, 'number'),
193 enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', this.default.enableCorsProxy, 'boolean'),
194 disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', this.default.disableCsrf, 'boolean'),
195 ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', this.default.ssl, 'boolean'),
196 certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', this.default.certPath),
197 keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', this.default.keyPath),
198 whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', this.default.whitelistMode, 'boolean'),
199 avoidLocalhost: cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', this.default.avoidLocalhost, 'boolean'),
200 basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', this.default.basicAuthMode, 'boolean'),
201 requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', this.default.requestProxyEnabled, 'boolean'),
202 requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', this.default.requestProxyUrl),
203 requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', this.default.requestProxyBypass),
204 getIPv4ListenUrl: function () {
205 const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4);
206 return new URL(
207 (this.ssl ? 'https://' : 'http://') +
208 (this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
209 (':' + this.port),
210 );
211 },
212 getIPv6ListenUrl: function () {
213 const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6);
214 return new URL(
215 (this.ssl ? 'https://' : 'http://') +
216 (this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') +
217 (':' + this.port),
218 );
219 },
220 getAutorunHostname: async function ({ useIPv6, useIPv4 }) {
221 if (this.autorunHostname === 'auto') {
222 let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
223
224 if (useIPv6 && useIPv4) {
225 return (this.avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
226 }
227
228 if (useIPv6) {
229 return '[::1]';
230 }
231
232 if (useIPv4) {
233 return '127.0.0.1';
234 }
235 }
236
237 return this.autorunHostname;
238 },
239 getAutorunUrl: function (hostname) {
240 const autorunPort = (this.autorunPortOverride >= 0) ? this.autorunPortOverride : this.port;
241 return new URL(
242 (this.ssl ? 'https://' : 'http://') +
243 (hostname) +
244 (':') +
245 (autorunPort),
246 );
247 },
248 };
249
250 if (!this.booleanAutoOptions.includes(result.enableIPv6)) {
251 console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv6);
252 result.enableIPv6 = this.default.enableIPv6;
253 }
254
255 if (!this.booleanAutoOptions.includes(result.enableIPv4)) {
256 console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv4);
257 result.enableIPv4 = this.default.enableIPv4;
258 }
259
260 return result;
261 }
262}
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/plugin-loader.js+37 -32
@@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs';
38 * be called before the server shuts down.38 * be called before the server shuts down.
39 */39 */
40export async function loadPlugins(app, pluginsPath) {40export async function loadPlugins(app, pluginsPath) {
41 const exitHooks = [];41 try {
42 const emptyFn = () => { };42 const exitHooks = [];
43 const emptyFn = () => { };
4344
44 // Server plugins are disabled.45 // Server plugins are disabled.
45 if (!enableServerPlugins) {46 if (!enableServerPlugins) {
46 return emptyFn;47 return emptyFn;
47 }48 }
4849
49 // Plugins directory does not exist.50 // Plugins directory does not exist.
50 if (!fs.existsSync(pluginsPath)) {51 if (!fs.existsSync(pluginsPath)) {
51 return emptyFn;52 return emptyFn;
52 }53 }
5354
54 const files = fs.readdirSync(pluginsPath);55 const files = fs.readdirSync(pluginsPath);
5556
56 // No plugins to load.57 // No plugins to load.
57 if (files.length === 0) {58 if (files.length === 0) {
58 return emptyFn;59 return emptyFn;
59 }60 }
6061
61 await updatePlugins(pluginsPath);62 await updatePlugins(pluginsPath);
6263
63 for (const file of files) {64 for (const file of files) {
64 const pluginFilePath = path.join(pluginsPath, file);65 const pluginFilePath = path.join(pluginsPath, file);
6566
66 if (fs.statSync(pluginFilePath).isDirectory()) {67 if (fs.statSync(pluginFilePath).isDirectory()) {
67 await loadFromDirectory(app, pluginFilePath, exitHooks);68 await loadFromDirectory(app, pluginFilePath, exitHooks);
68 continue;69 continue;
69 }70 }
71
72 // Not a JavaScript file.
73 if (!isCommonJS(file) && !isESModule(file)) {
74 continue;
75 }
7076
71 // Not a JavaScript file.77 await loadFromFile(app, pluginFilePath, exitHooks);
72 if (!isCommonJS(file) && !isESModule(file)) {
73 continue;
74 }78 }
7579
76 await loadFromFile(app, pluginFilePath, exitHooks);80 if (loadedPlugins.size > 0) {
77 }81 console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`);
82 }
7883
79 if (loadedPlugins.size > 0) {84 // Call all plugin "exit" functions at once and wait for them to finish
80 console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`);85 return () => Promise.all(exitHooks.map(exitFn => exitFn()));
86 } catch (error) {
87 console.error('Plugin loading failed.', error);
88 return () => { };
81 }89 }
82
83 // Call all plugin "exit" functions at once and wait for them to finish
84 return () => Promise.all(exitHooks.map(exitFn => exitFn()));
85}90}
8691
87async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {92async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
src/server-startup.js+386 -0
@@ -0,0 +1,386 @@
1import https from 'node:https';
2import http from 'node:http';
3import fs from 'node:fs';
4import { color, urlHostnameToIPv6, getHasIP } from './util.js';
5
6// Express routers
7import { router as userDataRouter } from './users.js';
8import { router as usersPrivateRouter } from './endpoints/users-private.js';
9import { router as usersAdminRouter } from './endpoints/users-admin.js';
10import { router as movingUIRouter } from './endpoints/moving-ui.js';
11import { router as imagesRouter } from './endpoints/images.js';
12import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
13import { router as avatarsRouter } from './endpoints/avatars.js';
14import { router as themesRouter } from './endpoints/themes.js';
15import { router as openAiRouter } from './endpoints/openai.js';
16import { router as googleRouter } from './endpoints/google.js';
17import { router as anthropicRouter } from './endpoints/anthropic.js';
18import { router as tokenizersRouter } from './endpoints/tokenizers.js';
19import { router as presetsRouter } from './endpoints/presets.js';
20import { router as secretsRouter } from './endpoints/secrets.js';
21import { router as thumbnailRouter } from './endpoints/thumbnails.js';
22import { router as novelAiRouter } from './endpoints/novelai.js';
23import { router as extensionsRouter } from './endpoints/extensions.js';
24import { router as assetsRouter } from './endpoints/assets.js';
25import { router as filesRouter } from './endpoints/files.js';
26import { router as charactersRouter } from './endpoints/characters.js';
27import { router as chatsRouter } from './endpoints/chats.js';
28import { router as groupsRouter } from './endpoints/groups.js';
29import { router as worldInfoRouter } from './endpoints/worldinfo.js';
30import { router as statsRouter } from './endpoints/stats.js';
31import { router as contentManagerRouter } from './endpoints/content-manager.js';
32import { router as settingsRouter } from './endpoints/settings.js';
33import { router as backgroundsRouter } from './endpoints/backgrounds.js';
34import { router as spritesRouter } from './endpoints/sprites.js';
35import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
36import { router as hordeRouter } from './endpoints/horde.js';
37import { router as vectorsRouter } from './endpoints/vectors.js';
38import { router as translateRouter } from './endpoints/translate.js';
39import { router as classifyRouter } from './endpoints/classify.js';
40import { router as captionRouter } from './endpoints/caption.js';
41import { router as searchRouter } from './endpoints/search.js';
42import { router as openRouterRouter } from './endpoints/openrouter.js';
43import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
44import { router as koboldRouter } from './endpoints/backends/kobold.js';
45import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
46import { router as scaleAltRouter } from './endpoints/backends/scale-alt.js';
47import { router as speechRouter } from './endpoints/speech.js';
48import { router as azureRouter } from './endpoints/azure.js';
49
50/**
51 * @typedef {object} ServerStartupResult
52 * @property {boolean} v6Failed If the server failed to start on IPv6
53 * @property {boolean} v4Failed If the server failed to start on IPv4
54 * @property {boolean} useIPv6 If use IPv6
55 * @property {boolean} useIPv4 If use IPv4
56 */
57
58/**
59 * Redirect deprecated API endpoints to their replacements.
60 * @param {import('express').Express} app The Express app to use
61 */
62export function redirectDeprecatedEndpoints(app) {
63 /**
64 * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
65 * redirects, this is transparent to client-side code.
66 * @param {string} src The URL to redirect from.
67 * @param {string} destination The URL to redirect to.
68 */
69 function redirect(src, destination) {
70 app.use(src, (req, res) => {
71 console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
72 // HTTP 301 causes the request to become a GET. 308 preserves the request method.
73 res.redirect(308, destination);
74 });
75 }
76
77 redirect('/createcharacter', '/api/characters/create');
78 redirect('/renamecharacter', '/api/characters/rename');
79 redirect('/editcharacter', '/api/characters/edit');
80 redirect('/editcharacterattribute', '/api/characters/edit-attribute');
81 redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
82 redirect('/deletecharacter', '/api/characters/delete');
83 redirect('/getcharacters', '/api/characters/all');
84 redirect('/getonecharacter', '/api/characters/get');
85 redirect('/getallchatsofcharacter', '/api/characters/chats');
86 redirect('/importcharacter', '/api/characters/import');
87 redirect('/dupecharacter', '/api/characters/duplicate');
88 redirect('/exportcharacter', '/api/characters/export');
89 redirect('/savechat', '/api/chats/save');
90 redirect('/getchat', '/api/chats/get');
91 redirect('/renamechat', '/api/chats/rename');
92 redirect('/delchat', '/api/chats/delete');
93 redirect('/exportchat', '/api/chats/export');
94 redirect('/importgroupchat', '/api/chats/group/import');
95 redirect('/importchat', '/api/chats/import');
96 redirect('/getgroupchat', '/api/chats/group/get');
97 redirect('/deletegroupchat', '/api/chats/group/delete');
98 redirect('/savegroupchat', '/api/chats/group/save');
99 redirect('/getgroups', '/api/groups/all');
100 redirect('/creategroup', '/api/groups/create');
101 redirect('/editgroup', '/api/groups/edit');
102 redirect('/deletegroup', '/api/groups/delete');
103 redirect('/getworldinfo', '/api/worldinfo/get');
104 redirect('/deleteworldinfo', '/api/worldinfo/delete');
105 redirect('/importworldinfo', '/api/worldinfo/import');
106 redirect('/editworldinfo', '/api/worldinfo/edit');
107 redirect('/getstats', '/api/stats/get');
108 redirect('/recreatestats', '/api/stats/recreate');
109 redirect('/updatestats', '/api/stats/update');
110 redirect('/getbackgrounds', '/api/backgrounds/all');
111 redirect('/delbackground', '/api/backgrounds/delete');
112 redirect('/renamebackground', '/api/backgrounds/rename');
113 redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
114 redirect('/savetheme', '/api/themes/save');
115 redirect('/getuseravatars', '/api/avatars/get');
116 redirect('/deleteuseravatar', '/api/avatars/delete');
117 redirect('/uploaduseravatar', '/api/avatars/upload');
118 redirect('/deletequickreply', '/api/quick-replies/delete');
119 redirect('/savequickreply', '/api/quick-replies/save');
120 redirect('/uploadimage', '/api/images/upload');
121 redirect('/listimgfiles/:folder', '/api/images/list/:folder');
122 redirect('/api/content/import', '/api/content/importURL');
123 redirect('/savemovingui', '/api/moving-ui/save');
124 redirect('/api/serpapi/search', '/api/search/serpapi');
125 redirect('/api/serpapi/visit', '/api/search/visit');
126 redirect('/api/serpapi/transcript', '/api/search/transcript');
127}
128
129/**
130 * Setup the routers for the endpoints.
131 * @param {import('express').Express} app The Express app to use
132 */
133export function setupPrivateEndpoints(app) {
134 app.use('/', userDataRouter);
135 app.use('/api/users', usersPrivateRouter);
136 app.use('/api/users', usersAdminRouter);
137 app.use('/api/moving-ui', movingUIRouter);
138 app.use('/api/images', imagesRouter);
139 app.use('/api/quick-replies', quickRepliesRouter);
140 app.use('/api/avatars', avatarsRouter);
141 app.use('/api/themes', themesRouter);
142 app.use('/api/openai', openAiRouter);
143 app.use('/api/google', googleRouter);
144 app.use('/api/anthropic', anthropicRouter);
145 app.use('/api/tokenizers', tokenizersRouter);
146 app.use('/api/presets', presetsRouter);
147 app.use('/api/secrets', secretsRouter);
148 app.use('/thumbnail', thumbnailRouter);
149 app.use('/api/novelai', novelAiRouter);
150 app.use('/api/extensions', extensionsRouter);
151 app.use('/api/assets', assetsRouter);
152 app.use('/api/files', filesRouter);
153 app.use('/api/characters', charactersRouter);
154 app.use('/api/chats', chatsRouter);
155 app.use('/api/groups', groupsRouter);
156 app.use('/api/worldinfo', worldInfoRouter);
157 app.use('/api/stats', statsRouter);
158 app.use('/api/backgrounds', backgroundsRouter);
159 app.use('/api/sprites', spritesRouter);
160 app.use('/api/content', contentManagerRouter);
161 app.use('/api/settings', settingsRouter);
162 app.use('/api/sd', stableDiffusionRouter);
163 app.use('/api/horde', hordeRouter);
164 app.use('/api/vector', vectorsRouter);
165 app.use('/api/translate', translateRouter);
166 app.use('/api/extra/classify', classifyRouter);
167 app.use('/api/extra/caption', captionRouter);
168 app.use('/api/search', searchRouter);
169 app.use('/api/backends/text-completions', textCompletionsRouter);
170 app.use('/api/openrouter', openRouterRouter);
171 app.use('/api/backends/kobold', koboldRouter);
172 app.use('/api/backends/chat-completions', chatCompletionsRouter);
173 app.use('/api/backends/scale-alt', scaleAltRouter);
174 app.use('/api/speech', speechRouter);
175 app.use('/api/azure', azureRouter);
176}
177
178/**
179 * Utilities for starting the express server.
180 */
181export class ServerStartup {
182 /**
183 * Creates a new ServerStartup instance.
184 * @param {import('express').Express} app The Express app to use
185 * @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments
186 */
187 constructor(app, cliArgs) {
188 this.app = app;
189 this.cliArgs = cliArgs;
190 }
191
192 /**
193 * Prints a fatal error message and exits the process.
194 * @param {string} message
195 */
196 #fatal(message) {
197 console.error(color.red(message));
198 process.exit(1);
199 }
200
201 /**
202 * Checks if SSL options are valid. If not, it will print an error message and exit the process.
203 * @returns {void}
204 */
205 #verifySslOptions() {
206 if (!this.cliArgs.ssl) return;
207
208 if (!this.cliArgs.certPath) {
209 this.#fatal('Error: SSL certificate path is required when using HTTPS. Check your config');
210 }
211
212 if (!this.cliArgs.keyPath) {
213 this.#fatal('Error: SSL key path is required when using HTTPS. Check your config');
214 }
215
216 if (!fs.existsSync(this.cliArgs.certPath)) {
217 this.#fatal('Error: SSL certificate path does not exist');
218 }
219
220 if (!fs.existsSync(this.cliArgs.keyPath)) {
221 this.#fatal('Error: SSL key path does not exist');
222 }
223 }
224
225 /**
226 * Creates an HTTPS server.
227 * @param {URL} url The URL to listen on
228 * @param {number} ipVersion the ip version to use
229 * @returns {Promise<void>} A promise that resolves when the server is listening
230 */
231 #createHttpsServer(url, ipVersion) {
232 this.#verifySslOptions();
233 return new Promise((resolve, reject) => {
234 const sslOptions = {
235 cert: fs.readFileSync(this.cliArgs.certPath),
236 key: fs.readFileSync(this.cliArgs.keyPath),
237 };
238 const server = https.createServer(sslOptions, this.app);
239 server.on('error', reject);
240 server.on('listening', resolve);
241
242 let host = url.hostname;
243 if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
244 server.listen({
245 host: host,
246 port: Number(url.port || 443),
247 // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
248 ipv6Only: true,
249 });
250 });
251 }
252
253 /**
254 * Creates an HTTP server.
255 * @param {URL} url The URL to listen on
256 * @param {number} ipVersion the ip version to use
257 * @returns {Promise<void>} A promise that resolves when the server is listening
258 */
259 #createHttpServer(url, ipVersion) {
260 return new Promise((resolve, reject) => {
261 const server = http.createServer(this.app);
262 server.on('error', reject);
263 server.on('listening', resolve);
264
265 let host = url.hostname;
266 if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
267 server.listen({
268 host: host,
269 port: Number(url.port || 80),
270 // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
271 ipv6Only: true,
272 });
273 });
274 }
275
276 /**
277 * Starts the server using http or https depending on config
278 * @param {boolean} useIPv6 If use IPv6
279 * @param {boolean} useIPv4 If use IPv4
280 * @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
281 */
282 async #startHTTPorHTTPS(useIPv6, useIPv4) {
283 let v6Failed = false;
284 let v4Failed = false;
285
286 const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
287
288 if (useIPv6) {
289 try {
290 await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
291 } catch (error) {
292 console.error('Warning: failed to start server on IPv6');
293 console.error(error);
294
295 v6Failed = true;
296 }
297 }
298
299 if (useIPv4) {
300 try {
301 await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
302 } catch (error) {
303 console.error('Warning: failed to start server on IPv4');
304 console.error(error);
305
306 v4Failed = true;
307 }
308 }
309
310 return [v6Failed, v4Failed];
311 }
312
313 /**
314 * Handles the case where the server failed to start on one or both protocols.
315 * @param {ServerStartupResult} result The results of the server startup
316 * @returns {void}
317 */
318 #handleServerListenFail({ v6Failed, v4Failed, useIPv6, useIPv4 }) {
319 if (v6Failed && !useIPv4) {
320 this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');
321 }
322
323 if (v4Failed && !useIPv6) {
324 this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');
325 }
326
327 if (v6Failed && v4Failed) {
328 this.#fatal('Error: Failed to start server on both IPv6 and IPv4');
329 }
330 }
331
332 /**
333 * Performs the server startup.
334 * @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup
335 */
336 async start() {
337 let useIPv6 = (this.cliArgs.enableIPv6 === true);
338 let useIPv4 = (this.cliArgs.enableIPv4 === true);
339
340 if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') {
341 const ipQuery = await getHasIP();
342 let hasIPv6 = false, hasIPv4 = false;
343
344 hasIPv6 = this.cliArgs.listen ? ipQuery.hasIPv6Any : ipQuery.hasIPv6Local;
345 if (this.cliArgs.enableIPv6 === 'auto') {
346 useIPv6 = hasIPv6;
347 }
348 if (hasIPv6) {
349 if (useIPv6) {
350 console.log(color.green('IPv6 support detected'));
351 } else {
352 console.log('IPv6 support detected (but disabled)');
353 }
354 }
355
356 hasIPv4 = this.cliArgs.listen ? ipQuery.hasIPv4Any : ipQuery.hasIPv4Local;
357 if (this.cliArgs.enableIPv4 === 'auto') {
358 useIPv4 = hasIPv4;
359 }
360 if (hasIPv4) {
361 if (useIPv4) {
362 console.log(color.green('IPv4 support detected'));
363 } else {
364 console.log('IPv4 support detected (but disabled)');
365 }
366 }
367
368 if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') {
369 if (!hasIPv6 && !hasIPv4) {
370 console.error('Both IPv6 and IPv4 are not detected');
371 process.exit(1);
372 }
373 }
374 }
375
376 if (!useIPv6 && !useIPv4) {
377 console.error('Both IPv6 and IPv4 are disabled or not detected');
378 process.exit(1);
379 }
380
381 const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
382 const result = { v6Failed, v4Failed, useIPv6, useIPv4 };
383 this.#handleServerListenFail(result);
384 return result;
385 }
386}
src/users.js+132 -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';
@@ -121,6 +121,93 @@ export async function ensurePublicDirectoriesExist() {
121}121}
122122
123/**123/**
124 * Prints an error message and exits the process if necessary
125 * @param {string} message The error message to print
126 * @returns {void}
127 */
128function logSecurityAlert(message) {
129 const { basicAuthMode, whitelistMode } = globalThis.COMMAND_LINE_ARGS;
130 if (basicAuthMode || whitelistMode) return; // safe!
131 console.error(color.red(message));
132 if (getConfigValue('securityOverride', false, 'boolean')) {
133 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
134 return;
135 }
136 process.exit(1);
137}
138
139/**
140 * Verifies the security settings and prints warnings if necessary
141 * @returns {Promise<void>}
142 */
143export async function verifySecuritySettings() {
144 const { listen, basicAuthMode } = globalThis.COMMAND_LINE_ARGS;
145
146 // Skip all security checks as listen is set to false
147 if (!listen) {
148 return;
149 }
150
151 if (!ENABLE_ACCOUNTS) {
152 logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.');
153 }
154
155 const users = await getAllEnabledUsers();
156 const unprotectedUsers = users.filter(x => !x.password);
157 const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin);
158
159 if (unprotectedUsers.length > 0) {
160 console.warn(color.blue('A friendly reminder that the following users are not password protected:'));
161 unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x));
162 console.log();
163 console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`);
164 console.log();
165
166 if (unprotectedAdminUsers.length > 0) {
167 logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.');
168 }
169 }
170
171 if (basicAuthMode) {
172 const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean');
173 if (perUserBasicAuth && !ENABLE_ACCOUNTS) {
174 console.error(color.red(
175 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
176 ));
177 } else if (!perUserBasicAuth) {
178 const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
179 const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
180 if (!basicAuthUserName || !basicAuthUserPassword) {
181 console.warn(color.yellow(
182 'Basic Authentication is enabled, but username or password is not set or empty!',
183 ));
184 }
185 }
186 }
187}
188
189export function cleanUploads() {
190 try {
191 const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
192 if (fs.existsSync(uploadsPath)) {
193 const uploads = fs.readdirSync(uploadsPath);
194
195 if (!uploads.length) {
196 return;
197 }
198
199 console.debug(`Cleaning uploads folder (${uploads.length} files)`);
200 uploads.forEach(file => {
201 const pathToFile = path.join(uploadsPath, file);
202 fs.unlinkSync(pathToFile);
203 });
204 }
205 } catch (err) {
206 console.error(err);
207 }
208}
209
210/**
124 * Gets a list of all user directories.211 * Gets a list of all user directories.
125 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories212 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
126 */213 */
@@ -478,6 +565,25 @@ export function getCookieSessionName() {
478 return `session-${suffix}`;565 return `session-${suffix}`;
479}566}
480567
568export function getSessionCookieAge() {
569 // Defaults to "no expiration" if not set
570 const configValue = getConfigValue('sessionTimeout', -1, 'number');
571
572 // Convert to milliseconds
573 if (configValue > 0) {
574 return configValue * 1000;
575 }
576
577 // "No expiration" is just 400 days as per RFC 6265
578 if (configValue < 0) {
579 return 400 * 24 * 60 * 60 * 1000;
580 }
581
582 // 0 means session cookie is deleted when the browser session ends
583 // (depends on the implementation of the browser)
584 return undefined;
585}
586
481/**587/**
482 * Hashes a password using scrypt with the provided salt.588 * Hashes a password using scrypt with the provided salt.
483 * @param {string} password Password to hash589 * @param {string} password Password to hash
@@ -778,6 +884,31 @@ export function requireLoginMiddleware(request, response, next) {
778}884}
779885
780/**886/**
887 * Middleware to host the login page.
888 * @param {import('express').Request} request Request object
889 * @param {import('express').Response} response Response object
890 */
891export async function loginPageMiddleware(request, response) {
892 if (!ENABLE_ACCOUNTS) {
893 console.log('User accounts are disabled. Redirecting to index page.');
894 return response.redirect('/');
895 }
896
897 try {
898 const { basicAuthMode } = globalThis.COMMAND_LINE_ARGS;
899 const autoLogin = await tryAutoLogin(request, basicAuthMode);
900
901 if (autoLogin) {
902 return response.redirect('/');
903 }
904 } catch (error) {
905 console.error('Error during auto-login:', error);
906 }
907
908 return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
909}
910
911/**
781 * Creates a route handler for serving files from a specific directory.912 * Creates a route handler for serving files from a specific directory.
782 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from913 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
783 * @returns {import('express').RequestHandler}914 * @returns {import('express').RequestHandler}
src/util.js+65 -4
@@ -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 'node: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,53 @@ 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 * @typedef {object} IPQueryResult
778 * @property {boolean} hasIPv6Any - Whether the computer has any IPv6 address, including (`::1`).
779 * @property {boolean} hasIPv4Any - Whether the computer has any IPv4 address, including (`127.0.0.1`).
780 * @property {boolean} hasIPv6Local - Whether the computer has local IPv6 address (`::1`).
781 * @property {boolean} hasIPv4Local - Whether the computer has local IPv4 address (`127.0.0.1`).
782 * @returns {Promise<IPQueryResult>} A promise that resolves to an array containing:
783 */
784export async function getHasIP() {
785 let hasIPv6Any = false;
786 let hasIPv6Local = false;
787
788 let hasIPv4Any = false;
789 let hasIPv4Local = false;
790
791 const interfaces = os.networkInterfaces();
792
793 for (const iface of Object.values(interfaces)) {
794 if (iface === undefined) {
795 continue;
796 }
797
798 for (const info of iface) {
799 if (info.family === 'IPv6') {
800 hasIPv6Any = true;
801 if (info.address === '::1') {
802 hasIPv6Local = true;
803 }
804 }
805
806 if (info.family === 'IPv4') {
807 hasIPv4Any = true;
808 if (info.address === '127.0.0.1') {
809 hasIPv4Local = true;
810 }
811 }
812 if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
813 }
814 if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
815 }
816
817 return { hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local };
818}
819
820
821/**
774 * Converts various JavaScript primitives to boolean values.822 * Converts various JavaScript primitives to boolean values.
775 * Handles special case for "true"/"false" strings (case-insensitive)823 * Handles special case for "true"/"false" strings (case-insensitive)
776 *824 *
@@ -809,10 +857,10 @@ export function stringToBool(str) {
809export function setupLogLevel() {857export function setupLogLevel() {
810 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');858 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
811859
812 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};860 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { };
813 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};861 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { };
814 globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => {};862 globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { };
815 globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => {};863 globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { };
816}864}
817865
818/**866/**
@@ -1005,3 +1053,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
1005 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);1053 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
1006 return null;1054 return null;
1007}1055}
1056
1057/**
1058 * Set the title of the terminal window
1059 * @param {string} title Desired title for the window
1060 */
1061export function setWindowTitle(title) {
1062 if (process.platform === 'win32') {
1063 process.title = title;
1064 }
1065 else {
1066 process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
1067 }
1068}