Merge pull request #3567 from SillyTavern/cli-args-refactor Refactor server startup
Signed| @@ -317,29 +317,34 @@ Start.bat --port 8000 --listen false | ||
| 317 | 317 | |
| 318 | 318 | ### Supported arguments |
| 319 | 319 | |
| 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 | 328 | | `--autorunHostnamelisten` | The autorunSillyTavern hostname,will probablylisten beston leftall onnetwork 'auto'. interfaces | string boolean | |
| 329 | 329 | | `--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 | | |
| 331 | 331 | | `--corsProxyenableIPv4` | Enables CORS proxy. If not provided falls back to yaml configIPv4 'enableCorsProxy'. protocol | boolean | |
| 332 | 332 | | `--disableCsrfenableIPv6` | DisablesEnables CSRFIPv6 protection protocol | boolean | |
| 333 | 333 | | `--ssllistenAddressIPv4` | EnablesSpecific SSL IPv4 address to listen to | boolean string | |
| 334 | 334 | | `--certPathlistenAddressIPv6` | PathSpecific toIPv6 youraddress certificateto file. listen to | string | |
| 335 | 335 | | `--keyPathdnsPreferIPv6` | Path to yourPrefers privateIPv6 keyfor file. DNS | string boolean | |
| 336 | 336 | | `--whitelistssl` | Enables whitelist mode SSL | boolean | |
| 337 | 337 | | `--dataRootcertPath` | RootPath directoryto foryour datacertificate storage file | string | |
| 338 | 338 | | `--avoidLocalhostkeyPath` | Avoids using 'localhost'Path forto autorunyour inprivate autokey mode. file | boolean string | |
| 339 | 339 | | `--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 | | |
| 341 | 341 | | `--requestProxyUrlautorunPortOverride` | Request proxy| URLOverrides (HTTPthe orport SOCKSfor protocols) autorun | string | |
| 342 | 342 | | `--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 | | |
| 343 | 348 | |
| 344 | 349 | ## Remote connections |
| 345 | 350 | |
| @@ -351,10 +356,29 @@ You may also want to configure SillyTavern user profiles with (optional) passwor | ||
| 351 | 356 | |
| 352 | 357 | ## Performance issues? |
| 353 | 358 | |
| 359 | +### General tips | |
| 360 | + | |
| 354 | 361 | 1. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category). |
| 355 | 362 | 2. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended). |
| 356 | 363 | 3. Make sure the browser is enabled to use GPU acceleration for rendering. |
| 357 | 364 | |
| 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 | + | |
| 358 | 382 | ## License and credits |
| 359 | 383 | |
| 360 | 384 | **This program is distributed in the hope that it will be useful, |
| @@ -28,6 +28,11 @@ port: 8000 | ||
| 28 | 28 | # - Use -1 to use the server port. |
| 29 | 29 | # - Specify a port to override the default. |
| 30 | 30 | autorunPortOverride: -1 |
| 31 | +# -- SSL options -- | |
| 32 | +ssl: | |
| 33 | + enabled: false | |
| 34 | + certPath: "./certs/cert.pem" | |
| 35 | + keyPath: "./certs/privkey.pem" | |
| 31 | 36 | # -- SECURITY CONFIGURATION -- |
| 32 | 37 | # Toggle whitelist mode |
| 33 | 38 | whitelistMode: true |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | +import { CommandLineArguments } from "./src/command-line"; | |
| 2 | 3 | import { CsrfSyncedToken } from "csrf-sync"; |
| 3 | 4 | |
| 4 | 5 | declare global { |
| @@ -32,4 +33,9 @@ declare global { | ||
| 32 | 33 | * The root directory for user data. |
| 33 | 34 | */ |
| 34 | 35 | var DATA_ROOT: string; |
| 36 | + | |
| 37 | + /** | |
| 38 | + * Parsed command line arguments. | |
| 39 | + */ | |
| 40 | + var COMMAND_LINE_ARGS: CommandLineArguments; | |
| 35 | 41 | } |
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | |
| 3 | 3 | // 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'; | |
| 8 | 4 | import path from 'node:path'; |
| 9 | 5 | import util from 'node:util'; |
| 10 | 6 | import net from 'node:net'; |
| @@ -12,12 +8,6 @@ import dns from 'node:dns'; | ||
| 12 | 8 | import process from 'node:process'; |
| 13 | 9 | import { fileURLToPath } from 'node:url'; |
| 14 | 10 | |
| 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 | |
| 21 | 11 | import cors from 'cors'; |
| 22 | 12 | import { csrfSync } from 'csrf-sync'; |
| 23 | 13 | import express from 'express'; |
| @@ -27,23 +17,15 @@ import multer from 'multer'; | ||
| 27 | 17 | import responseTime from 'response-time'; |
| 28 | 18 | import helmet from 'helmet'; |
| 29 | 19 | import 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; | |
| 39 | 21 | |
| 40 | 22 | // local library imports |
| 23 | +import { CommandLineParser } from './src/command-line.js'; | |
| 41 | 24 | import { loadPlugins } from './src/plugin-loader.js'; |
| 42 | 25 | import { |
| 43 | 26 | initUserStorage, |
| 44 | 27 | getCookieSecret, |
| 45 | 28 | getCookieSessionName, |
| 46 | - getAllEnabledUsers, | |
| 47 | 29 | ensurePublicDirectoriesExist, |
| 48 | 30 | getUserDirectoriesList, |
| 49 | 31 | migrateSystemPrompts, |
| @@ -51,8 +33,10 @@ import { | ||
| 51 | 33 | requireLoginMiddleware, |
| 52 | 34 | setUserDataMiddleware, |
| 53 | 35 | shouldRedirectToLogin, |
| 54 | 36 | tryAutoLogincleanUploads, |
| 55 | - router as userDataRouter, | |
| 37 | + getSessionCookieAge, | |
| 38 | + verifySecuritySettings, | |
| 39 | + loginPageMiddleware, | |
| 56 | 40 | } from './src/users.js'; |
| 57 | 41 | |
| 58 | 42 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| @@ -62,65 +46,35 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr | ||
| 62 | 46 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; |
| 63 | 47 | import initRequestProxy from './src/request-proxy.js'; |
| 64 | 48 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; |
| 49 | +import corsProxyMiddleware from './src/middleware/corsProxy.js'; | |
| 65 | 50 | import { |
| 66 | 51 | getVersion, |
| 67 | - getConfigValue, | |
| 68 | 52 | color, |
| 69 | - forwardFetchResponse, | |
| 70 | 53 | removeColorFormatting, |
| 71 | 54 | getSeparator, |
| 72 | - stringToBool, | |
| 73 | - urlHostnameToIPv6, | |
| 74 | - canResolve, | |
| 75 | 55 | safeReadFileSync, |
| 76 | 56 | setupLogLevel, |
| 57 | + setWindowTitle, | |
| 77 | 58 | } from './src/util.js'; |
| 78 | 59 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 79 | 60 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| 80 | 61 | |
| 81 | 62 | // Routers |
| 82 | 63 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; |
| 83 | 64 | import { routerinit as usersPrivateRouterstatsInit, onExit as statsOnExit } from './src/endpoints/users-privatestats.js'; |
| 84 | 65 | import { router as usersAdminRoutercheckForNewContent } from './src/endpoints/userscontent-adminmanager.js'; |
| 85 | 66 | import { routerinit as movingUIRoutersettingsInit } from './src/endpoints/moving-uisettings.js'; |
| 86 | 67 | import { 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'; | |
| 124 | 78 | |
| 125 | 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 | 80 | // 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 | 84 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); |
| 131 | 85 | } |
| 132 | 86 | |
| 133 | -const DEFAULT_PORT = 8000; | |
| 87 | +const cliArgs = new CommandLineParser().parse(process.argv); | |
| 134 | -const DEFAULT_AUTORUN = false; | |
| 88 | +globalThis.DATA_ROOT = cliArgs.dataRoot; | |
| 135 | 89 | const 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; | |
| 151 | 90 | |
| 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(); | |
| 249 | 95 | |
| 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 | +} | |
| 254 | 107 | |
| 255 | 108 | const app = express(); |
| 256 | 109 | app.use(helmet({ |
| @@ -259,79 +112,6 @@ app.use(helmet({ | ||
| 259 | 112 | app.use(compression()); |
| 260 | 113 | app.use(responseTime()); |
| 261 | 114 | |
| 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 | - | |
| 335 | 115 | // CORS Settings // |
| 336 | 116 | const CORS = cors({ |
| 337 | 117 | origin: 'null', |
| @@ -340,59 +120,23 @@ const CORS = cors({ | ||
| 340 | 120 | |
| 341 | 121 | app.use(CORS); |
| 342 | 122 | |
| 343 | 123 | if (cliArgs.listen && cliArgs.basicAuthMode) { |
| 344 | 124 | app.use(basicAuthMiddleware); |
| 345 | 125 | } |
| 346 | 126 | |
| 347 | 127 | if (enableWhitelistcliArgs.whitelistMode) { |
| 348 | 128 | app.use(whitelistMiddleware()); |
| 349 | 129 | } |
| 350 | 130 | |
| 351 | 131 | if (cliArgs.listen) { |
| 352 | 132 | app.use(accessLoggerMiddleware()); |
| 353 | 133 | } |
| 354 | 134 | |
| 355 | 135 | if (cliArgs.enableCorsProxy) { |
| 356 | 136 | app.use(bodyParser.json({ |
| 357 | 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 | 140 | } else { |
| 397 | 141 | app.use('/proxy/:url(*)', async (_, res) => { |
| 398 | 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 | } |
| 403 | 147 | |
| 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 | - | |
| 472 | 148 | app.use(cookieSession({ |
| 473 | 149 | name: getCookieSessionName(), |
| 474 | 150 | sameSite: 'strict', |
| @@ -480,7 +156,7 @@ app.use(cookieSession({ | ||
| 480 | 156 | app.use(setUserDataMiddleware); |
| 481 | 157 | |
| 482 | 158 | // CSRF Protection // |
| 483 | 159 | if (!cliArgs.disableCsrf) { |
| 484 | 160 | const csrfSyncProtection = csrfSync({ |
| 485 | 161 | getTokenFromState: (req) => { |
| 486 | 162 | if (!req.session) { |
| @@ -535,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => { | ||
| 535 | 211 | }); |
| 536 | 212 | |
| 537 | 213 | // Host login page |
| 538 | 214 | app.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 | -}); | |
| 556 | 215 | |
| 557 | 216 | // Host frontend assets |
| 558 | 217 | const webpackMiddleware = getWebpackServeMiddleware(); |
| @@ -573,185 +232,23 @@ app.get('/api/ping', (request, response) => { | ||
| 573 | 232 | }); |
| 574 | 233 | |
| 575 | 234 | // File uploads |
| 235 | +const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); | |
| 576 | 236 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); |
| 577 | 237 | app.use(multerMonkeyPatch); |
| 578 | 238 | |
| 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 | - | |
| 586 | 239 | app.get('/version', async function (_, response) { |
| 587 | 240 | const data = await getVersion(); |
| 588 | 241 | response.send(data); |
| 589 | 242 | }); |
| 590 | 243 | |
| 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 | -); | |
| 750 | 246 | |
| 751 | 247 | /** |
| 752 | 248 | * Tasks that need to be run before the server starts listening. |
| 249 | + * @returns {Promise<void>} | |
| 753 | 250 | */ |
| 754 | 251 | const preSetupTasks = async function preSetupTasks() { |
| 755 | 252 | const version = await getVersion(); |
| 756 | 253 | |
| 757 | 254 | // Print formatted header |
| @@ -773,7 +270,8 @@ const preSetupTasks = async function () { | ||
| 773 | 270 | await settingsInit(); |
| 774 | 271 | await statsInit(); |
| 775 | 272 | |
| 776 | 273 | const cleanupPluginspluginsDirectory = await initializePluginspath.join(serverDirectory, 'plugins'); |
| 274 | + const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | |
| 777 | 275 | const consoleTitle = process.title; |
| 778 | 276 | |
| 779 | 277 | let isExiting = false; |
| @@ -797,70 +295,39 @@ const preSetupTasks = async function () { | ||
| 797 | 295 | }); |
| 798 | 296 | |
| 799 | 297 | // Add request proxy. |
| 800 | 298 | initRequestProxy({ enabled: proxyEnabledcliArgs.requestProxyEnabled, url: proxyUrlcliArgs.requestProxyUrl, bypass: proxyBypasscliArgs.requestProxyBypass }); |
| 801 | 299 | |
| 802 | 300 | // Wait for frontend libs to compile |
| 803 | 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 | - */ | |
| 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; | |
| 830 | 302 | } |
| 831 | 303 | |
| 832 | 304 | /** |
| 833 | 305 | * 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 | |
| 838 | 308 | */ |
| 839 | -const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | |
| 309 | +async function postSetupTasks(result) { | |
| 840 | 310 | 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...'); | |
| 848 | 312 | |
| 849 | 313 | if (cliArgs.autorun) open(autorunUrl.toString());{ |
| 314 | + console.log('Launching in a browser...'); | |
| 315 | + await open(autorunUrl.toString()); | |
| 316 | + } | |
| 850 | 317 | |
| 851 | 318 | setWindowTitle('SillyTavern WebServer'); |
| 852 | 319 | |
| 853 | 320 | let logListen = 'SillyTavern is listening on'; |
| 854 | 321 | |
| 855 | 322 | if (result.useIPv6 && !result.v6Failed) { |
| 856 | 323 | logListen += color.green( |
| 857 | 324 | ' IPv6: ' + tavernUrlV6cliArgs.getIPv6ListenUrl().host, |
| 858 | 325 | ); |
| 859 | 326 | } |
| 860 | 327 | |
| 861 | 328 | if (result.useIPv4 && !result.v4Failed) { |
| 862 | 329 | logListen += color.green( |
| 863 | 330 | ' IPv4: ' + tavernUrlcliArgs.getIPv4ListenUrl().host, |
| 864 | 331 | ); |
| 865 | 332 | } |
| 866 | 333 | |
| @@ -868,7 +335,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | ||
| 868 | 335 | const plainGoToLog = removeColorFormatting(goToLog); |
| 869 | 336 | |
| 870 | 337 | console.log(logListen); |
| 871 | 338 | if (cliArgs.listen) { |
| 872 | 339 | console.log(); |
| 873 | 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 | 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 | 344 | console.log(goToLog); |
| 878 | 345 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); |
| 879 | 346 | |
| 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 | 347 | 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 | - } | |
| 1136 | 348 | } |
| 1137 | 349 | |
| 1138 | 350 | /** |
| @@ -1153,4 +365,5 @@ initUserStorage(globalThis.DATA_ROOT) | ||
| 1153 | 365 | .then(verifySecuritySettings) |
| 1154 | 366 | .then(preSetupTasks) |
| 1155 | 367 | .then(apply404Middleware) |
| 1156 | - .finally(startServer); | |
| 368 | + .then(() => new ServerStartup(app, cliArgs).start()) | |
| 369 | + .then(postSetupTasks); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs'; | ||
| 38 | 38 | * be called before the server shuts down. |
| 39 | 39 | */ |
| 40 | 40 | export async function loadPlugins(app, pluginsPath) { |
| 41 | - const exitHooks = []; | |
| 41 | + try { | |
| 42 | - const emptyFn = () => { }; | |
| 42 | + const exitHooks = []; | |
| 43 | + const emptyFn = () => { }; | |
| 43 | 44 | |
| 44 | 45 | // Server plugins are disabled. |
| 45 | 46 | if (!enableServerPlugins) { |
| 46 | 47 | return emptyFn; |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | 50 | // Plugins directory does not exist. |
| 50 | 51 | if (!fs.existsSync(pluginsPath)) { |
| 51 | 52 | return emptyFn; |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | 55 | const files = fs.readdirSync(pluginsPath); |
| 55 | 56 | |
| 56 | 57 | // No plugins to load. |
| 57 | 58 | if (files.length === 0) { |
| 58 | 59 | return emptyFn; |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | 62 | await updatePlugins(pluginsPath); |
| 62 | 63 | |
| 63 | 64 | for (const file of files) { |
| 64 | 65 | const pluginFilePath = path.join(pluginsPath, file); |
| 65 | 66 | |
| 66 | 67 | if (fs.statSync(pluginFilePath).isDirectory()) { |
| 67 | 68 | await loadFromDirectory(app, pluginFilePath, exitHooks); |
| 68 | 69 | continue; |
| 69 | 70 | } |
| 71 | + | |
| 72 | + // Not a JavaScript file. | |
| 73 | + if (!isCommonJS(file) && !isESModule(file)) { | |
| 74 | + continue; | |
| 75 | + } | |
| 70 | 76 | |
| 71 | - // Not a JavaScript file. | |
| 77 | + await loadFromFile(app, pluginFilePath, exitHooks); | |
| 72 | - if (!isCommonJS(file) && !isESModule(file)) { | |
| 73 | - continue; | |
| 74 | 78 | } |
| 75 | 79 | |
| 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 | + } | |
| 78 | 83 | |
| 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 | } |
| 86 | 91 | |
| 87 | 92 | async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { |
| @@ -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 | +} | |
| @@ -14,7 +14,7 @@ import archiver from 'archiver'; | ||
| 14 | 14 | import _ from 'lodash'; |
| 15 | 15 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 16 | 16 | |
| 17 | 17 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; |
| 18 | 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; |
| 19 | 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; |
| 20 | 20 | import { getContentOfType } from './endpoints/content-manager.js'; |
| @@ -121,6 +121,93 @@ export async function ensurePublicDirectoriesExist() { | ||
| 121 | 121 | } |
| 122 | 122 | |
| 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 | + */ | |
| 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 | +/** | |
| 124 | 211 | * Gets a list of all user directories. |
| 125 | 212 | * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories |
| 126 | 213 | */ |
| @@ -478,6 +565,25 @@ export function getCookieSessionName() { | ||
| 478 | 565 | return `session-${suffix}`; |
| 479 | 566 | } |
| 480 | 567 | |
| 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 | + | |
| 481 | 587 | /** |
| 482 | 588 | * Hashes a password using scrypt with the provided salt. |
| 483 | 589 | * @param {string} password Password to hash |
| @@ -778,6 +884,31 @@ export function requireLoginMiddleware(request, response, next) { | ||
| 778 | 884 | } |
| 779 | 885 | |
| 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 | + */ | |
| 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 | +/** | |
| 781 | 912 | * Creates a route handler for serving files from a specific directory. |
| 782 | 913 | * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from |
| 783 | 914 | * @returns {import('express').RequestHandler} |
| @@ -6,6 +6,7 @@ import { Readable } from 'node:stream'; | ||
| 6 | 6 | import { createRequire } from 'node:module'; |
| 7 | 7 | import { Buffer } from 'node:buffer'; |
| 8 | 8 | import { promises as dnsPromise } from 'node:dns'; |
| 9 | +import os from 'node:os'; | |
| 9 | 10 | |
| 10 | 11 | import yaml from 'yaml'; |
| 11 | 12 | import { sync as commandExistsSync } from 'command-exists'; |
| @@ -771,6 +772,53 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) { | ||
| 771 | 772 | } |
| 772 | 773 | |
| 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 | + */ | |
| 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 | +/** | |
| 774 | 822 | * Converts various JavaScript primitives to boolean values. |
| 775 | 823 | * Handles special case for "true"/"false" strings (case-insensitive) |
| 776 | 824 | * |
| @@ -809,10 +857,10 @@ export function stringToBool(str) { | ||
| 809 | 857 | export function setupLogLevel() { |
| 810 | 858 | const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number'); |
| 811 | 859 | |
| 812 | 860 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { }; |
| 813 | 861 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { }; |
| 814 | 862 | globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { }; |
| 815 | 863 | globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { }; |
| 816 | 864 | } |
| 817 | 865 | |
| 818 | 866 | /** |
| @@ -1005,3 +1053,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { | ||
| 1005 | 1053 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); |
| 1006 | 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 | + */ | |
| 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 | +} | |