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
318318### 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 |
328328| `--autorunHostnamelisten` | The autorunSillyTavern hostname,will probablylisten beston leftall onnetwork 'auto'. interfaces | string boolean |
329329| `--autorunPortOverridewhitelist` | Overrides the portEnables forwhitelist autorun. mode | string 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 |
331331| `--corsProxyenableIPv4` | Enables CORS proxy. If not provided falls back to yaml configIPv4 'enableCorsProxy'. protocol | boolean |
332332| `--disableCsrfenableIPv6` | DisablesEnables CSRFIPv6 protection protocol | boolean |
333333| `--ssllistenAddressIPv4` | EnablesSpecific SSL IPv4 address to listen to | boolean string |
334334| `--certPathlistenAddressIPv6` | PathSpecific toIPv6 youraddress certificateto file. listen to | string |
335335| `--keyPathdnsPreferIPv6` | Path to yourPrefers privateIPv6 keyfor file. DNS | string boolean |
336336| `--whitelistssl` | Enables whitelist mode SSL | boolean |
337337| `--dataRootcertPath` | RootPath directoryto foryour datacertificate storage file | string |
338338| `--avoidLocalhostkeyPath` | Avoids using 'localhost'Path forto autorunyour inprivate autokey mode. file | boolean string |
339339| `--basicAuthModeautorun` | EnablesAutomatically basiclaunch authentication SillyTavern in the browser | boolean |
340-| `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean |
340+| `--autorunHostname` | Autorun hostname | string |
341341| `--requestProxyUrlautorunPortOverride` | Request proxy| URLOverrides (HTTPthe orport SOCKSfor protocols) autorun | string |
342342| `--requestProxyBypassavoidLocalhost` | Request proxyAvoids bypassusing list'localhost' (spacefor separatedautorun listin ofauto hosts) mode | array 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
344349## Remote connections
345350
@@ -351,10 +356,29 @@ You may also want to configure SillyTavern user profiles with (optional) passwor
351356
352357## Performance issues?
353358
359+### General tips
360+
3543611. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category).
3553622. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended).
3563633. Make sure the browser is enabled to use GPU acceleration for rendering.
357364
365+### Input lag
366+
367+Performance 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+
374+If you experience performance issues and cannot identify the cause, or suspect an issue with SillyTavern itself, please:
375+
376+1. [Record a performance profile](https://developer.chrome.com/docs/devtools/performance/reference)
377+2. Export the profile as a JSON file
378+3. Submit it to the development team for analysis
379+
380+We recommend first testing with all browser extensions and third-party SillyTavern extensions disabled to isolate the source of the performance degradation.
381+
358382## License and credits
359383
360384**This program is distributed in the hope that it will be useful,
default/config.yaml+5 -0
@@ -28,6 +28,11 @@ port: 8000
2828# - Use -1 to use the server port.
2929# - Specify a port to override the default.
3030autorunPortOverride: -1
31+# -- SSL options --
32+ssl:
33+ enabled: false
34+ certPath: "./certs/cert.pem"
35+ keyPath: "./certs/privkey.pem"
3136# -- SECURITY CONFIGURATION --
3237# Toggle whitelist mode
3338whitelistMode: true
index.d.ts+6 -0
@@ -1,4 +1,5 @@
11import { UserDirectoryList, User } from "./src/users";
2+import { CommandLineArguments } from "./src/command-line";
23import { CsrfSyncedToken } from "csrf-sync";
34
45declare global {
@@ -32,4 +33,9 @@ declare global {
3233 * The root directory for user data.
3334 */
3435 var DATA_ROOT: string;
36+
37+ /**
38+ * Parsed command line arguments.
39+ */
40+ var COMMAND_LINE_ARGS: CommandLineArguments;
3541}
server.js+71 -858
@@ -1,10 +1,6 @@
11#!/usr/bin/env node
22
33// native node modules
4-import fs from 'node:fs';
5-import http from 'node:http';
6-import https from 'node:https';
7-import os from 'os';
84import path from 'node:path';
95import util from 'node:util';
106import net from 'node:net';
@@ -12,12 +8,6 @@ import dns from 'node:dns';
128import process from 'node:process';
139import { fileURLToPath } from 'node:url';
1410
15-// cli/fs related library imports
16-import open from 'open';
17-import yargs from 'yargs/yargs';
18-import { hideBin } from 'yargs/helpers';
19-
20-// express/server related library imports
2111import cors from 'cors';
2212import { csrfSync } from 'csrf-sync';
2313import express from 'express';
@@ -27,23 +17,15 @@ import multer from 'multer';
2717import responseTime from 'response-time';
2818import helmet from 'helmet';
2919import bodyParser from 'body-parser';
30-
20+import open from 'open';
31-// net related library imports
32-import fetch from 'node-fetch';
33-import ipRegex from 'ip-regex';
34-
35-// Unrestrict console logs display limit
36-util.inspect.defaultOptions.maxArrayLength = null;
37-util.inspect.defaultOptions.maxStringLength = null;
38-util.inspect.defaultOptions.depth = 4;
3921
4022// local library imports
23+import { CommandLineParser } from './src/command-line.js';
4124import { loadPlugins } from './src/plugin-loader.js';
4225import {
4326 initUserStorage,
4427 getCookieSecret,
4528 getCookieSessionName,
46- getAllEnabledUsers,
4729 ensurePublicDirectoriesExist,
4830 getUserDirectoriesList,
4931 migrateSystemPrompts,
@@ -51,8 +33,10 @@ import {
5133 requireLoginMiddleware,
5234 setUserDataMiddleware,
5335 shouldRedirectToLogin,
5436 tryAutoLogincleanUploads,
55- router as userDataRouter,
37+ getSessionCookieAge,
38+ verifySecuritySettings,
39+ loginPageMiddleware,
5640} from './src/users.js';
5741
5842import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
@@ -62,65 +46,35 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr
6246import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
6347import initRequestProxy from './src/request-proxy.js';
6448import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
49+import corsProxyMiddleware from './src/middleware/corsProxy.js';
6550import {
6651 getVersion,
67- getConfigValue,
6852 color,
69- forwardFetchResponse,
7053 removeColorFormatting,
7154 getSeparator,
72- stringToBool,
73- urlHostnameToIPv6,
74- canResolve,
7555 safeReadFileSync,
7656 setupLogLevel,
57+ setWindowTitle,
7758} from './src/util.js';
7859import { UPLOADS_DIRECTORY } from './src/constants.js';
7960import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
8061
8162// Routers
8263import { router as usersPublicRouter } from './src/endpoints/users-public.js';
8364import { routerinit as usersPrivateRouterstatsInit, onExit as statsOnExit } from './src/endpoints/users-privatestats.js';
8465import { router as usersAdminRoutercheckForNewContent } from './src/endpoints/userscontent-adminmanager.js';
8566import { routerinit as movingUIRoutersettingsInit } from './src/endpoints/moving-uisettings.js';
8667import { routerredirectDeprecatedEndpoints, asServerStartup, imagesRoutersetupPrivateEndpoints } from './src/endpoints/imagesserver-startup.js';
87-import { router as quickRepliesRouter } from './src/endpoints/quick-replies.js';
68+
88-import { router as avatarsRouter } from './src/endpoints/avatars.js';
69+// Unrestrict console logs display limit
89-import { router as themesRouter } from './src/endpoints/themes.js';
70+util.inspect.defaultOptions.maxArrayLength = null;
90-import { router as openAiRouter } from './src/endpoints/openai.js';
71+util.inspect.defaultOptions.maxStringLength = null;
91-import { router as googleRouter } from './src/endpoints/google.js';
72+util.inspect.defaultOptions.depth = 4;
92-import { router as anthropicRouter } from './src/endpoints/anthropic.js';
73+
93-import { router as tokenizersRouter } from './src/endpoints/tokenizers.js';
74+// Set a working directory for the server
94-import { router as presetsRouter } from './src/endpoints/presets.js';
75+const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
95-import { router as secretsRouter } from './src/endpoints/secrets.js';
76+console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
96-import { router as thumbnailRouter } from './src/endpoints/thumbnails.js';
77+process.chdir(serverDirectory);
97-import { router as novelAiRouter } from './src/endpoints/novelai.js';
98-import { router as extensionsRouter } from './src/endpoints/extensions.js';
99-import { router as assetsRouter } from './src/endpoints/assets.js';
100-import { router as filesRouter } from './src/endpoints/files.js';
101-import { router as charactersRouter } from './src/endpoints/characters.js';
102-import { router as chatsRouter } from './src/endpoints/chats.js';
103-import { router as groupsRouter } from './src/endpoints/groups.js';
104-import { router as worldInfoRouter } from './src/endpoints/worldinfo.js';
105-import { router as statsRouter, init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
106-import { router as backgroundsRouter } from './src/endpoints/backgrounds.js';
107-import { router as spritesRouter } from './src/endpoints/sprites.js';
108-import { router as contentManagerRouter, checkForNewContent } from './src/endpoints/content-manager.js';
109-import { router as settingsRouter, init as settingsInit } from './src/endpoints/settings.js';
110-import { router as stableDiffusionRouter } from './src/endpoints/stable-diffusion.js';
111-import { router as hordeRouter } from './src/endpoints/horde.js';
112-import { router as vectorsRouter } from './src/endpoints/vectors.js';
113-import { router as translateRouter } from './src/endpoints/translate.js';
114-import { router as classifyRouter } from './src/endpoints/classify.js';
115-import { router as captionRouter } from './src/endpoints/caption.js';
116-import { router as searchRouter } from './src/endpoints/search.js';
117-import { router as openRouterRouter } from './src/endpoints/openrouter.js';
118-import { router as chatCompletionsRouter } from './src/endpoints/backends/chat-completions.js';
119-import { router as koboldRouter } from './src/endpoints/backends/kobold.js';
120-import { router as textCompletionsRouter } from './src/endpoints/backends/text-completions.js';
121-import { router as scaleAltRouter } from './src/endpoints/backends/scale-alt.js';
122-import { router as speechRouter } from './src/endpoints/speech.js';
123-import { router as azureRouter } from './src/endpoints/azure.js';
12478
12579// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
12680// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -130,127 +84,26 @@ if (process.versions && process.versions.node && process.versions.node.match(/20
13084 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
13185}
13286
133-const DEFAULT_PORT = 8000;
87+const cliArgs = new CommandLineParser().parse(process.argv);
134-const DEFAULT_AUTORUN = false;
88+globalThis.DATA_ROOT = cliArgs.dataRoot;
13589const DEFAULT_LISTENglobalThis.COMMAND_LINE_ARGS = falsecliArgs;
136-const DEFAULT_LISTEN_ADDRESS_IPV6 = '[::]';
137-const DEFAULT_LISTEN_ADDRESS_IPV4 = '0.0.0.0';
138-const DEFAULT_CORS_PROXY = false;
139-const DEFAULT_WHITELIST = true;
140-const DEFAULT_ACCOUNTS = false;
141-const DEFAULT_CSRF_DISABLED = false;
142-const DEFAULT_BASIC_AUTH = false;
143-const DEFAULT_PER_USER_BASIC_AUTH = false;
144-
145-const DEFAULT_ENABLE_IPV6 = false;
146-const DEFAULT_ENABLE_IPV4 = true;
147-
148-const DEFAULT_PREFER_IPV6 = false;
149-
150-const DEFAULT_AVOID_LOCALHOST = false;
15190
152-const DEFAULT_AUTORUN_HOSTNAME = 'auto';
91+if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
153-const DEFAULT_AUTORUN_PORT = -1;
92+ console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
154-
93+ process.exit(1);
155-const DEFAULT_PROXY_ENABLED = false;
94+}
156-const DEFAULT_PROXY_URL = '';
157-const DEFAULT_PROXY_BYPASS = [];
158-
159-const cliArguments = yargs(hideBin(process.argv))
160- .usage('Usage: <your-start-script> <command> [options]')
161- .option('enableIPv6', {
162- type: 'string',
163- default: null,
164- describe: `Enables IPv6.\n[config default: ${DEFAULT_ENABLE_IPV6}]`,
165- }).option('enableIPv4', {
166- type: 'string',
167- default: null,
168- describe: `Enables IPv4.\n[config default: ${DEFAULT_ENABLE_IPV4}]`,
169- }).option('port', {
170- type: 'number',
171- default: null,
172- describe: `Sets the port under which SillyTavern will run.\nIf not provided falls back to yaml config 'port'.\n[config default: ${DEFAULT_PORT}]`,
173- }).option('dnsPreferIPv6', {
174- type: 'boolean',
175- default: null,
176- describe: `Prefers IPv6 for dns\nyou should probably have the enabled if you're on an IPv6 only network\nIf not provided falls back to yaml config 'preferIPv6'.\n[config default: ${DEFAULT_PREFER_IPV6}]`,
177- }).option('autorun', {
178- type: 'boolean',
179- default: null,
180- describe: `Automatically launch SillyTavern in the browser.\nAutorun is automatically disabled if --ssl is set to true.\nIf not provided falls back to yaml config 'autorun'.\n[config default: ${DEFAULT_AUTORUN}]`,
181- }).option('autorunHostname', {
182- type: 'string',
183- default: null,
184- describe: 'the autorun hostname, probably best left on \'auto\'.\nuse values like \'localhost\', \'st.example.com\'',
185- }).option('autorunPortOverride', {
186- type: 'string',
187- default: null,
188- describe: 'Overrides the port for autorun with open your browser with this port and ignore what port the server is running on. -1 is use server port',
189- }).option('listen', {
190- type: 'boolean',
191- default: null,
192- describe: `SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If false, will limit it only to internal localhost (127.0.0.1).\nIf not provided falls back to yaml config 'listen'.\n[config default: ${DEFAULT_LISTEN}]`,
193- }).option('listenAddressIPv6', {
194- type: 'string',
195- default: null,
196- describe: 'Set SillyTavern to listen to a specific IPv6 address. If not set, it will fallback to listen to all.\n[config default: [::] ]',
197- }).option('listenAddressIPv4', {
198- type: 'string',
199- default: null,
200- describe: 'Set SillyTavern to listen to a specific IPv4 address. If not set, it will fallback to listen to all.\n[config default: 0.0.0.0 ]',
201- }).option('corsProxy', {
202- type: 'boolean',
203- default: null,
204- describe: `Enables CORS proxy\nIf not provided falls back to yaml config 'enableCorsProxy'.\n[config default: ${DEFAULT_CORS_PROXY}]`,
205- }).option('disableCsrf', {
206- type: 'boolean',
207- default: null,
208- describe: 'Disables CSRF protection',
209- }).option('ssl', {
210- type: 'boolean',
211- default: false,
212- describe: 'Enables SSL',
213- }).option('certPath', {
214- type: 'string',
215- default: 'certs/cert.pem',
216- describe: 'Path to your certificate file.',
217- }).option('keyPath', {
218- type: 'string',
219- default: 'certs/privkey.pem',
220- describe: 'Path to your private key file.',
221- }).option('whitelist', {
222- type: 'boolean',
223- default: null,
224- describe: 'Enables whitelist mode',
225- }).option('dataRoot', {
226- type: 'string',
227- default: null,
228- describe: 'Root directory for data storage',
229- }).option('avoidLocalhost', {
230- type: 'boolean',
231- default: null,
232- describe: 'Avoids using \'localhost\' for autorun in auto mode.\nuse if you don\'t have \'localhost\' in your hosts file',
233- }).option('basicAuthMode', {
234- type: 'boolean',
235- default: null,
236- describe: 'Enables basic authentication',
237- }).option('requestProxyEnabled', {
238- type: 'boolean',
239- default: null,
240- describe: 'Enables a use of proxy for outgoing requests',
241- }).option('requestProxyUrl', {
242- type: 'string',
243- default: null,
244- describe: 'Request proxy URL (HTTP or SOCKS protocols)',
245- }).option('requestProxyBypass', {
246- type: 'array',
247- describe: 'Request proxy bypass list (space separated list of hosts)',
248- }).parseSync();
24995
250-// change all relative paths
96+try {
251-const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
97+ if (cliArgs.dnsPreferIPv6) {
252-console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
98+ dns.setDefaultResultOrder('ipv6first');
253-process.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
255108const app = express();
256109app.use(helmet({
@@ -259,79 +112,6 @@ app.use(helmet({
259112app.use(compression());
260113app.use(responseTime());
261114
262-
263-/** @type {number} */
264-const server_port = cliArguments.port ?? getConfigValue('port', DEFAULT_PORT, 'number');
265-/** @type {boolean} */
266-const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN, 'boolean')) && !cliArguments.ssl;
267-/** @type {boolean} */
268-const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN, 'boolean');
269-/** @type {string} */
270-const listenAddressIPv6 = cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', DEFAULT_LISTEN_ADDRESS_IPV6);
271-/** @type {string} */
272-const listenAddressIPv4 = cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', DEFAULT_LISTEN_ADDRESS_IPV4);
273-/** @type {boolean} */
274-const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY, 'boolean');
275-const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST, 'boolean');
276-/** @type {string} */
277-globalThis.DATA_ROOT = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
278-/** @type {boolean} */
279-const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED, 'boolean');
280-const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH, 'boolean');
281-const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH, 'boolean');
282-/** @type {boolean} */
283-const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS, 'boolean');
284-
285-const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
286-
287-
288-/** @type {boolean | string} */
289-let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6)) ?? DEFAULT_ENABLE_IPV6;
290-/** @type {boolean | string} */
291-let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4)) ?? DEFAULT_ENABLE_IPV4;
292-
293-/** @type {string} */
294-const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);
295-/** @type {number} */
296-const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT, 'number');
297-
298-/** @type {boolean} */
299-const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6, 'boolean');
300-
301-/** @type {boolean} */
302-const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST, 'boolean');
303-
304-const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED, 'boolean');
305-const proxyUrl = cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', DEFAULT_PROXY_URL);
306-const proxyBypass = cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', DEFAULT_PROXY_BYPASS);
307-
308-if (dnsPreferIPv6) {
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-
319-const ipOptions = [true, 'auto', false];
320-
321-if (!ipOptions.includes(enableIPv6)) {
322- console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV6);
323- enableIPv6 = DEFAULT_ENABLE_IPV6;
324-}
325-if (!ipOptions.includes(enableIPv4)) {
326- console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV4);
327- enableIPv4 = DEFAULT_ENABLE_IPV4;
328-}
329-
330-if (enableIPv6 === false && enableIPv4 === false) {
331- console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
332- process.exit(1);
333-}
334-
335115// CORS Settings //
336116const CORS = cors({
337117 origin: 'null',
@@ -340,59 +120,23 @@ const CORS = cors({
340120
341121app.use(CORS);
342122
343123if (cliArgs.listen && cliArgs.basicAuthMode) {
344124 app.use(basicAuthMiddleware);
345125}
346126
347127if (enableWhitelistcliArgs.whitelistMode) {
348128 app.use(whitelistMiddleware());
349129}
350130
351131if (cliArgs.listen) {
352132 app.use(accessLoggerMiddleware());
353133}
354134
355135if (cliArgs.enableCorsProxy) {
356136 app.use(bodyParser.json({
357137 limit: '200mb',
358138 }));
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- });
396140} else {
397141 app.use('/proxy/:url(*)', async (_, res) => {
398142 const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
@@ -401,74 +145,6 @@ if (enableCorsProxy) {
401145 });
402146}
403147
404-function getSessionCookieAge() {
405- // Defaults to "no expiration" if not set
406- const configValue = getConfigValue('sessionTimeout', -1, 'number');
407-
408- // Convert to milliseconds
409- if (configValue > 0) {
410- return configValue * 1000;
411- }
412-
413- // "No expiration" is just 400 days as per RFC 6265
414- if (configValue < 0) {
415- return 400 * 24 * 60 * 60 * 1000;
416- }
417-
418- // 0 means session cookie is deleted when the browser session ends
419- // (depends on the implementation of the browser)
420- return undefined;
421-}
422-
423-/**
424- * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
425- *
426- * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing:
427- * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`).
428- * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`).
429- * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`).
430- * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`).
431- */
432-async function getHasIP() {
433- let hasIPv6 = false;
434- let hasIPv6Local = false;
435-
436- let hasIPv4 = false;
437- let hasIPv4Local = false;
438-
439- const interfaces = os.networkInterfaces();
440-
441- for (const iface of Object.values(interfaces)) {
442- if (iface === undefined) {
443- continue;
444- }
445-
446- for (const info of iface) {
447- if (info.family === 'IPv6') {
448- hasIPv6 = true;
449- if (info.address === '::1') {
450- hasIPv6Local = true;
451- }
452- }
453-
454- if (info.family === 'IPv4') {
455- hasIPv4 = true;
456- if (info.address === '127.0.0.1') {
457- hasIPv4Local = true;
458- }
459- }
460- if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
461- }
462- if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
463- }
464- return [
465- hasIPv6,
466- hasIPv4,
467- hasIPv6Local,
468- hasIPv4Local,
469- ];
470-}
471-
472148app.use(cookieSession({
473149 name: getCookieSessionName(),
474150 sameSite: 'strict',
@@ -480,7 +156,7 @@ app.use(cookieSession({
480156app.use(setUserDataMiddleware);
481157
482158// CSRF Protection //
483159if (!cliArgs.disableCsrf) {
484160 const csrfSyncProtection = csrfSync({
485161 getTokenFromState: (req) => {
486162 if (!req.session) {
@@ -535,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
535211});
536212
537213// Host login page
538214app.get('/login', async (request, responseloginPageMiddleware) => {;
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
557216// Host frontend assets
558217const webpackMiddleware = getWebpackServeMiddleware();
@@ -573,185 +232,23 @@ app.get('/api/ping', (request, response) => {
573232});
574233
575234// File uploads
235+const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
576236app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
577237app.use(multerMonkeyPatch);
578238
579-// User data mount
580-app.use('/', userDataRouter);
581-// Private endpoints
582-app.use('/api/users', usersPrivateRouter);
583-// Admin endpoints
584-app.use('/api/users', usersAdminRouter);
585-
586239app.get('/version', async function (_, response) {
587240 const data = await getVersion();
588241 response.send(data);
589242});
590243
591-function cleanUploads() {
244+redirectDeprecatedEndpoints(app);
592- try {
245+setupPrivateEndpoints(app);
593- if (fs.existsSync(uploadsPath)) {
594- const uploads = fs.readdirSync(uploadsPath);
595-
596- if (!uploads.length) {
597- return;
598- }
599-
600- console.debug(`Cleaning uploads folder (${uploads.length} files)`);
601- uploads.forEach(file => {
602- const pathToFile = path.join(uploadsPath, file);
603- fs.unlinkSync(pathToFile);
604- });
605- }
606- } catch (err) {
607- console.error(err);
608- }
609-}
610-
611-/**
612- * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
613- * redirects, this is transparent to client-side code.
614- * @param {string} src The URL to redirect from.
615- * @param {string} destination The URL to redirect to.
616- */
617-function redirect(src, destination) {
618- app.use(src, (req, res) => {
619- console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
620- // HTTP 301 causes the request to become a GET. 308 preserves the request method.
621- res.redirect(308, destination);
622- });
623-}
624-
625-// Redirect deprecated character API endpoints
626-redirect('/createcharacter', '/api/characters/create');
627-redirect('/renamecharacter', '/api/characters/rename');
628-redirect('/editcharacter', '/api/characters/edit');
629-redirect('/editcharacterattribute', '/api/characters/edit-attribute');
630-redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
631-redirect('/deletecharacter', '/api/characters/delete');
632-redirect('/getcharacters', '/api/characters/all');
633-redirect('/getonecharacter', '/api/characters/get');
634-redirect('/getallchatsofcharacter', '/api/characters/chats');
635-redirect('/importcharacter', '/api/characters/import');
636-redirect('/dupecharacter', '/api/characters/duplicate');
637-redirect('/exportcharacter', '/api/characters/export');
638-
639-// Redirect deprecated chat API endpoints
640-redirect('/savechat', '/api/chats/save');
641-redirect('/getchat', '/api/chats/get');
642-redirect('/renamechat', '/api/chats/rename');
643-redirect('/delchat', '/api/chats/delete');
644-redirect('/exportchat', '/api/chats/export');
645-redirect('/importgroupchat', '/api/chats/group/import');
646-redirect('/importchat', '/api/chats/import');
647-redirect('/getgroupchat', '/api/chats/group/get');
648-redirect('/deletegroupchat', '/api/chats/group/delete');
649-redirect('/savegroupchat', '/api/chats/group/save');
650-
651-// Redirect deprecated group API endpoints
652-redirect('/getgroups', '/api/groups/all');
653-redirect('/creategroup', '/api/groups/create');
654-redirect('/editgroup', '/api/groups/edit');
655-redirect('/deletegroup', '/api/groups/delete');
656-
657-// Redirect deprecated worldinfo API endpoints
658-redirect('/getworldinfo', '/api/worldinfo/get');
659-redirect('/deleteworldinfo', '/api/worldinfo/delete');
660-redirect('/importworldinfo', '/api/worldinfo/import');
661-redirect('/editworldinfo', '/api/worldinfo/edit');
662-
663-// Redirect deprecated stats API endpoints
664-redirect('/getstats', '/api/stats/get');
665-redirect('/recreatestats', '/api/stats/recreate');
666-redirect('/updatestats', '/api/stats/update');
667-
668-// Redirect deprecated backgrounds API endpoints
669-redirect('/getbackgrounds', '/api/backgrounds/all');
670-redirect('/delbackground', '/api/backgrounds/delete');
671-redirect('/renamebackground', '/api/backgrounds/rename');
672-redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
673-
674-// Redirect deprecated theme API endpoints
675-redirect('/savetheme', '/api/themes/save');
676-
677-// Redirect deprecated avatar API endpoints
678-redirect('/getuseravatars', '/api/avatars/get');
679-redirect('/deleteuseravatar', '/api/avatars/delete');
680-redirect('/uploaduseravatar', '/api/avatars/upload');
681-
682-// Redirect deprecated quick reply endpoints
683-redirect('/deletequickreply', '/api/quick-replies/delete');
684-redirect('/savequickreply', '/api/quick-replies/save');
685-
686-// Redirect deprecated image endpoints
687-redirect('/uploadimage', '/api/images/upload');
688-redirect('/listimgfiles/:folder', '/api/images/list/:folder');
689-redirect('/api/content/import', '/api/content/importURL');
690-
691-// Redirect deprecated moving UI endpoints
692-redirect('/savemovingui', '/api/moving-ui/save');
693-
694-// Redirect Serp endpoints
695-redirect('/api/serpapi/search', '/api/search/serpapi');
696-redirect('/api/serpapi/visit', '/api/search/visit');
697-redirect('/api/serpapi/transcript', '/api/search/transcript');
698-
699-app.use('/api/moving-ui', movingUIRouter);
700-app.use('/api/images', imagesRouter);
701-app.use('/api/quick-replies', quickRepliesRouter);
702-app.use('/api/avatars', avatarsRouter);
703-app.use('/api/themes', themesRouter);
704-app.use('/api/openai', openAiRouter);
705-app.use('/api/google', googleRouter);
706-app.use('/api/anthropic', anthropicRouter);
707-app.use('/api/tokenizers', tokenizersRouter);
708-app.use('/api/presets', presetsRouter);
709-app.use('/api/secrets', secretsRouter);
710-app.use('/thumbnail', thumbnailRouter);
711-app.use('/api/novelai', novelAiRouter);
712-app.use('/api/extensions', extensionsRouter);
713-app.use('/api/assets', assetsRouter);
714-app.use('/api/files', filesRouter);
715-app.use('/api/characters', charactersRouter);
716-app.use('/api/chats', chatsRouter);
717-app.use('/api/groups', groupsRouter);
718-app.use('/api/worldinfo', worldInfoRouter);
719-app.use('/api/stats', statsRouter);
720-app.use('/api/backgrounds', backgroundsRouter);
721-app.use('/api/sprites', spritesRouter);
722-app.use('/api/content', contentManagerRouter);
723-app.use('/api/settings', settingsRouter);
724-app.use('/api/sd', stableDiffusionRouter);
725-app.use('/api/horde', hordeRouter);
726-app.use('/api/vector', vectorsRouter);
727-app.use('/api/translate', translateRouter);
728-app.use('/api/extra/classify', classifyRouter);
729-app.use('/api/extra/caption', captionRouter);
730-app.use('/api/search', searchRouter);
731-app.use('/api/backends/text-completions', textCompletionsRouter);
732-app.use('/api/openrouter', openRouterRouter);
733-app.use('/api/backends/kobold', koboldRouter);
734-app.use('/api/backends/chat-completions', chatCompletionsRouter);
735-app.use('/api/backends/scale-alt', scaleAltRouter);
736-app.use('/api/speech', speechRouter);
737-app.use('/api/azure', azureRouter);
738-
739-const tavernUrlV6 = new URL(
740- (cliArguments.ssl ? 'https://' : 'http://') +
741- (listen ? (ipRegex.v6({ exact: true }).test(listenAddressIPv6) ? listenAddressIPv6 : '[::]') : '[::1]') +
742- (':' + server_port),
743-);
744-
745-const tavernUrl = new URL(
746- (cliArguments.ssl ? 'https://' : 'http://') +
747- (listen ? (ipRegex.v4({ exact: true }).test(listenAddressIPv4) ? listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
748- (':' + server_port),
749-);
750246
751247/**
752248 * Tasks that need to be run before the server starts listening.
249+ * @returns {Promise<void>}
753250 */
754251const preSetupTasks = async function preSetupTasks() {
755252 const version = await getVersion();
756253
757254 // Print formatted header
@@ -773,7 +270,8 @@ const preSetupTasks = async function () {
773270 await settingsInit();
774271 await statsInit();
775272
776273 const cleanupPluginspluginsDirectory = await initializePluginspath.join(serverDirectory, 'plugins');
274+ const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
777275 const consoleTitle = process.title;
778276
779277 let isExiting = false;
@@ -797,70 +295,39 @@ const preSetupTasks = async function () {
797295 });
798296
799297 // Add request proxy.
800298 initRequestProxy({ enabled: proxyEnabledcliArgs.requestProxyEnabled, url: proxyUrlcliArgs.requestProxyUrl, bypass: proxyBypasscliArgs.requestProxyBypass });
801299
802300 // Wait for frontend libs to compile
803301 await webpackMiddleware.runWebpackCompiler();
804-};
805-
806-/**
807- * Gets the hostname to use for autorun in the browser.
808- * @param {boolean} useIPv6 If use IPv6
809- * @param {boolean} useIPv4 If use IPv4
810- * @returns Promise<string> The hostname to use for autorun
811- */
812-async function getAutorunHostname(useIPv6, useIPv4) {
813- if (autorunHostname === 'auto') {
814- let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
815-
816- if (useIPv6 && useIPv4) {
817- return (avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
818- }
819-
820- if (useIPv6) {
821- return '[::1]';
822- }
823-
824- if (useIPv4) {
825- return '127.0.0.1';
826- }
827- }
828-
829- return autorunHostname;
830302}
831303
832304/**
833305 * Tasks that need to be run after the server starts listening.
834- * @param {boolean} v6Failed If the server failed to start on IPv6
306+ * @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup
835- * @param {boolean} v4Failed If the server failed to start on IPv4
307+ * @returns {Promise<void>}
836- * @param {boolean} useIPv6 If the server is using IPv6
837- * @param {boolean} useIPv4 If the server is using IPv4
838308 */
839-const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
309+async function postSetupTasks(result) {
840310 const autorunUrlautorunHostname = newawait URLcliArgs.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
849313 if (cliArgs.autorun) open(autorunUrl.toString());{
314+ console.log('Launching in a browser...');
315+ await open(autorunUrl.toString());
316+ }
850317
851318 setWindowTitle('SillyTavern WebServer');
852319
853320 let logListen = 'SillyTavern is listening on';
854321
855322 if (result.useIPv6 && !result.v6Failed) {
856323 logListen += color.green(
857324 ' IPv6: ' + tavernUrlV6cliArgs.getIPv6ListenUrl().host,
858325 );
859326 }
860327
861328 if (result.useIPv4 && !result.v4Failed) {
862329 logListen += color.green(
863330 ' IPv4: ' + tavernUrlcliArgs.getIPv4ListenUrl().host,
864331 );
865332 }
866333
@@ -868,7 +335,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
868335 const plainGoToLog = removeColorFormatting(goToLog);
869336
870337 console.log(logListen);
871338 if (cliArgs.listen) {
872339 console.log();
873340 console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
874341 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) {
877344 console.log(goToLog);
878345 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-
897347 setupLogLevel();
898-};
899-
900-/**
901- * Loads server plugins from a directory.
902- * @returns {Promise<Function>} Function to be run on server exit
903- */
904-async 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- */
919-function setWindowTitle(title) {
920- if (process.platform === 'win32') {
921- process.title = title;
922- }
923- else {
924- process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
925- }
926-}
927-
928-/**
929- * Prints an error message and exits the process if necessary
930- * @param {string} message The error message to print
931- * @returns {void}
932- */
933-function 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- */
950-function handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) {
951- if (v6Failed && !useIPv4) {
952- console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled'));
953- process.exit(1);
954- }
955-
956- if (v4Failed && !useIPv6) {
957- console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled'));
958- process.exit(1);
959- }
960-
961- if (v6Failed && v4Failed) {
962- console.error(color.red('fatal error: Failed to start server on both IPv6 and IPv4'));
963- process.exit(1);
964- }
965-}
966-
967-/**
968- * Creates an HTTPS server.
969- * @param {URL} url The URL to listen on
970- * @param {number} ipVersion the ip version to use
971- * @returns {Promise<void>} A promise that resolves when the server is listening
972- * @throws {Error} If the server fails to start
973- */
974-function createHttpsServer(url, ipVersion) {
975- return new Promise((resolve, reject) => {
976- const server = https.createServer(
977- {
978- cert: fs.readFileSync(cliArguments.certPath),
979- key: fs.readFileSync(cliArguments.keyPath),
980- }, app);
981- server.on('error', reject);
982- server.on('listening', resolve);
983-
984- let host = url.hostname;
985- if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
986- server.listen({
987- host: host,
988- port: Number(url.port || 443),
989- // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
990- ipv6Only: true,
991- });
992- });
993-}
994-
995-/**
996- * Creates an HTTP server.
997- * @param {URL} url The URL to listen on
998- * @param {number} ipVersion the ip version to use
999- * @returns {Promise<void>} A promise that resolves when the server is listening
1000- * @throws {Error} If the server fails to start
1001- */
1002-function createHttpServer(url, ipVersion) {
1003- return new Promise((resolve, reject) => {
1004- const server = http.createServer(app);
1005- server.on('error', reject);
1006- server.on('listening', resolve);
1007-
1008- let host = url.hostname;
1009- if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
1010- server.listen({
1011- host: host,
1012- port: Number(url.port || 80),
1013- // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
1014- ipv6Only: true,
1015- });
1016- });
1017-}
1018-
1019-/**
1020- * Starts the server using http or https depending on config
1021- * @param {boolean} useIPv6 If use IPv6
1022- * @param {boolean} useIPv4 If use IPv4
1023- */
1024-async function startHTTPorHTTPS(useIPv6, useIPv4) {
1025- let v6Failed = false;
1026- let v4Failed = false;
1027-
1028- const createFunc = cliArguments.ssl ? createHttpsServer : createHttpServer;
1029-
1030- if (useIPv6) {
1031- try {
1032- await createFunc(tavernUrlV6, 6);
1033- } catch (error) {
1034- console.error('non-fatal error: failed to start server on IPv6');
1035- console.error(error);
1036-
1037- v6Failed = true;
1038- }
1039- }
1040-
1041- if (useIPv4) {
1042- try {
1043- await createFunc(tavernUrl, 4);
1044- } catch (error) {
1045- console.error('non-fatal error: failed to start server on IPv4');
1046- console.error(error);
1047-
1048- v4Failed = true;
1049- }
1050- }
1051-
1052- return [v6Failed, v4Failed];
1053-}
1054-
1055-async function startServer() {
1056- let useIPv6 = (enableIPv6 === true);
1057- let useIPv4 = (enableIPv4 === true);
1058-
1059- let hasIPv6 = false,
1060- hasIPv4 = false,
1061- hasIPv6Local = false,
1062- hasIPv4Local = false,
1063- hasIPv6Any = false,
1064- hasIPv4Any = false;
1065-
1066- if (enableIPv6 === 'auto' || enableIPv4 === 'auto') {
1067- [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP();
1068-
1069- hasIPv6 = listen ? hasIPv6Any : hasIPv6Local;
1070- if (enableIPv6 === 'auto') {
1071- useIPv6 = hasIPv6;
1072- }
1073- if (hasIPv6) {
1074- if (useIPv6) {
1075- console.log(color.green('IPv6 support detected'));
1076- } else {
1077- console.log('IPv6 support detected (but disabled)');
1078- }
1079- }
1080-
1081- hasIPv4 = listen ? hasIPv4Any : hasIPv4Local;
1082- if (enableIPv4 === 'auto') {
1083- useIPv4 = hasIPv4;
1084- }
1085- if (hasIPv4) {
1086- if (useIPv4) {
1087- console.log(color.green('IPv4 support detected'));
1088- } else {
1089- console.log('IPv4 support detected (but disabled)');
1090- }
1091- }
1092-
1093- if (enableIPv6 === 'auto' && enableIPv4 === 'auto') {
1094- if (!hasIPv6 && !hasIPv4) {
1095- console.error('Both IPv6 and IPv4 are not detected');
1096- process.exit(1);
1097- }
1098- }
1099- }
1100-
1101- if (!useIPv6 && !useIPv4) {
1102- console.error('Both IPv6 and IPv4 are disabled,\nP.S. you should never see this error, at least at one point it was checked for before this, with the rest of the config options');
1103- process.exit(1);
1104- }
1105-
1106- const [v6Failed, v4Failed] = await startHTTPorHTTPS(useIPv6, useIPv4);
1107- handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4);
1108- postSetupTasks(v6Failed, v4Failed, useIPv6, useIPv4);
1109-}
1110-
1111-async 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- }
1136348}
1137349
1138350/**
@@ -1153,4 +365,5 @@ initUserStorage(globalThis.DATA_ROOT)
1153365 .then(verifySecuritySettings)
1154366 .then(preSetupTasks)
1155367 .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 @@
1+import yargs from 'yargs/yargs';
2+import { hideBin } from 'yargs/helpers';
3+import ipRegex from 'ip-regex';
4+import { canResolve, color, getConfigValue, stringToBool } from './util.js';
5+
6+/**
7+ * @typedef {object} CommandLineArguments 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+ */
39+export 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 @@
1+import fetch from 'node-fetch';
2+import { forwardFetchResponse } from '../util.js';
3+
4+/**
5+ * Middleware to proxy requests to a different domain
6+ * @param {import('express').Request} req Express request object
7+ * @param {import('express').Response} res Express response object
8+ */
9+export default async function corsProxyMiddleware(req, res) {
10+ const url = req.params.url; // get the url from the request path
11+
12+ // Disallow circular requests
13+ const serverUrl = req.protocol + '://' + req.get('host');
14+ if (url.startsWith(serverUrl)) {
15+ return res.status(400).send('Circular requests are not allowed');
16+ }
17+
18+ try {
19+ const headers = JSON.parse(JSON.stringify(req.headers));
20+ const headersToRemove = [
21+ 'x-csrf-token', 'host', 'referer', 'origin', 'cookie',
22+ 'x-forwarded-for', 'x-forwarded-protocol', 'x-forwarded-proto',
23+ 'x-forwarded-host', 'x-real-ip', 'sec-fetch-mode',
24+ 'sec-fetch-site', 'sec-fetch-dest',
25+ ];
26+
27+ headersToRemove.forEach(header => delete headers[header]);
28+
29+ const bodyMethods = ['POST', 'PUT', 'PATCH'];
30+
31+ const response = await fetch(url, {
32+ method: req.method,
33+ headers: headers,
34+ body: bodyMethods.includes(req.method) ? JSON.stringify(req.body) : undefined,
35+ });
36+
37+ // Copy over relevant response params to the proxy response
38+ forwardFetchResponse(response, res);
39+ } catch (error) {
40+ res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error);
41+ }
42+}
src/plugin-loader.js+37 -32
@@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs';
3838 * be called before the server shuts down.
3939 */
4040export async function loadPlugins(app, pluginsPath) {
41- const exitHooks = [];
41+ try {
42- const emptyFn = () => { };
42+ const exitHooks = [];
43+ const emptyFn = () => { };
4344
4445 // Server plugins are disabled.
4546 if (!enableServerPlugins) {
4647 return emptyFn;
4748 }
4849
4950 // Plugins directory does not exist.
5051 if (!fs.existsSync(pluginsPath)) {
5152 return emptyFn;
5253 }
5354
5455 const files = fs.readdirSync(pluginsPath);
5556
5657 // No plugins to load.
5758 if (files.length === 0) {
5859 return emptyFn;
5960 }
6061
6162 await updatePlugins(pluginsPath);
6263
6364 for (const file of files) {
6465 const pluginFilePath = path.join(pluginsPath, file);
6566
6667 if (fs.statSync(pluginFilePath).isDirectory()) {
6768 await loadFromDirectory(app, pluginFilePath, exitHooks);
6869 continue;
6970 }
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;
7478 }
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 () => { };
8189 }
82-
83- // Call all plugin "exit" functions at once and wait for them to finish
84- return () => Promise.all(exitHooks.map(exitFn => exitFn()));
8590}
8691
8792async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {
src/server-startup.js+386 -0
@@ -0,0 +1,386 @@
1+import https from 'node:https';
2+import http from 'node:http';
3+import fs from 'node:fs';
4+import { color, urlHostnameToIPv6, getHasIP } from './util.js';
5+
6+// Express routers
7+import { router as userDataRouter } from './users.js';
8+import { router as usersPrivateRouter } from './endpoints/users-private.js';
9+import { router as usersAdminRouter } from './endpoints/users-admin.js';
10+import { router as movingUIRouter } from './endpoints/moving-ui.js';
11+import { router as imagesRouter } from './endpoints/images.js';
12+import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
13+import { router as avatarsRouter } from './endpoints/avatars.js';
14+import { router as themesRouter } from './endpoints/themes.js';
15+import { router as openAiRouter } from './endpoints/openai.js';
16+import { router as googleRouter } from './endpoints/google.js';
17+import { router as anthropicRouter } from './endpoints/anthropic.js';
18+import { router as tokenizersRouter } from './endpoints/tokenizers.js';
19+import { router as presetsRouter } from './endpoints/presets.js';
20+import { router as secretsRouter } from './endpoints/secrets.js';
21+import { router as thumbnailRouter } from './endpoints/thumbnails.js';
22+import { router as novelAiRouter } from './endpoints/novelai.js';
23+import { router as extensionsRouter } from './endpoints/extensions.js';
24+import { router as assetsRouter } from './endpoints/assets.js';
25+import { router as filesRouter } from './endpoints/files.js';
26+import { router as charactersRouter } from './endpoints/characters.js';
27+import { router as chatsRouter } from './endpoints/chats.js';
28+import { router as groupsRouter } from './endpoints/groups.js';
29+import { router as worldInfoRouter } from './endpoints/worldinfo.js';
30+import { router as statsRouter } from './endpoints/stats.js';
31+import { router as contentManagerRouter } from './endpoints/content-manager.js';
32+import { router as settingsRouter } from './endpoints/settings.js';
33+import { router as backgroundsRouter } from './endpoints/backgrounds.js';
34+import { router as spritesRouter } from './endpoints/sprites.js';
35+import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
36+import { router as hordeRouter } from './endpoints/horde.js';
37+import { router as vectorsRouter } from './endpoints/vectors.js';
38+import { router as translateRouter } from './endpoints/translate.js';
39+import { router as classifyRouter } from './endpoints/classify.js';
40+import { router as captionRouter } from './endpoints/caption.js';
41+import { router as searchRouter } from './endpoints/search.js';
42+import { router as openRouterRouter } from './endpoints/openrouter.js';
43+import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
44+import { router as koboldRouter } from './endpoints/backends/kobold.js';
45+import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
46+import { router as scaleAltRouter } from './endpoints/backends/scale-alt.js';
47+import { router as speechRouter } from './endpoints/speech.js';
48+import { 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+ */
62+export 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+ */
133+export 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+ */
181+export 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';
1414import _ from 'lodash';
1515import { sync as writeFileAtomicSync } from 'write-file-atomic';
1616
1717import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
1818import { getConfigValue, color, delay, generateTimestamp } from './util.js';
1919import { readSecret, writeSecret } from './endpoints/secrets.js';
2020import { getContentOfType } from './endpoints/content-manager.js';
@@ -121,6 +121,93 @@ export async function ensurePublicDirectoriesExist() {
121121}
122122
123123/**
124+ * Prints an error message and exits the process if necessary
125+ * @param {string} message The error message to print
126+ * @returns {void}
127+ */
128+function 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+ */
143+export 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+
189+export 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+/**
124211 * Gets a list of all user directories.
125212 * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
126213 */
@@ -478,6 +565,25 @@ export function getCookieSessionName() {
478565 return `session-${suffix}`;
479566}
480567
568+export 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+
481587/**
482588 * Hashes a password using scrypt with the provided salt.
483589 * @param {string} password Password to hash
@@ -778,6 +884,31 @@ export function requireLoginMiddleware(request, response, next) {
778884}
779885
780886/**
887+ * Middleware to host the login page.
888+ * @param {import('express').Request} request Request object
889+ * @param {import('express').Response} response Response object
890+ */
891+export 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+/**
781912 * Creates a route handler for serving files from a specific directory.
782913 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
783914 * @returns {import('express').RequestHandler}
src/util.js+65 -4
@@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
66import { createRequire } from 'node:module';
77import { Buffer } from 'node:buffer';
88import { promises as dnsPromise } from 'node:dns';
9+import os from 'node:os';
910
1011import yaml from 'yaml';
1112import { sync as commandExistsSync } from 'command-exists';
@@ -771,6 +772,53 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
771772}
772773
773774/**
775+ * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
776+ *
777+ * @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+ */
784+export 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+/**
774822 * Converts various JavaScript primitives to boolean values.
775823 * Handles special case for "true"/"false" strings (case-insensitive)
776824 *
@@ -809,10 +857,10 @@ export function stringToBool(str) {
809857export function setupLogLevel() {
810858 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
811859
812860 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { };
813861 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { };
814862 globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { };
815863 globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { };
816864}
817865
818866/**
@@ -1005,3 +1053,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
10051053 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
10061054 return null;
10071055}
1056+
1057+/**
1058+ * Set the title of the terminal window
1059+ * @param {string} title Desired title for the window
1060+ */
1061+export 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+}