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 | ### Supported arguments | 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 | | `--autorunHostname` | The autorun hostname, probably best left on 'auto'. | string | | 328 | | `--listen` | SillyTavern will listen on all network interfaces | boolean | |
| 329 | | `--autorunPortOverride` | Overrides the port for autorun. | string | | 329 | | `--whitelist` | Enables whitelist mode | boolean | |
| 330 | | `--listen` | SillyTavern is listening on all network interfaces. If not provided falls back to yaml config 'listen'.| boolean | | 330 | | `--basicAuthMode` | Enables basic authentication | boolean | |
| 331 | | `--corsProxy` | Enables CORS proxy. If not provided falls back to yaml config 'enableCorsProxy'. | boolean | | 331 | | `--enableIPv4` | Enables IPv4 protocol | boolean | |
| 332 | | `--disableCsrf` | Disables CSRF protection | boolean | | 332 | | `--enableIPv6` | Enables IPv6 protocol | boolean | |
| 333 | | `--ssl` | Enables SSL | boolean | | 333 | | `--listenAddressIPv4` | Specific IPv4 address to listen to | string | |
| 334 | | `--certPath` | Path to your certificate file. | string | | 334 | | `--listenAddressIPv6` | Specific IPv6 address to listen to | string | |
| 335 | | `--keyPath` | Path to your private key file. | string | | 335 | | `--dnsPreferIPv6` | Prefers IPv6 for DNS | boolean | |
| 336 | | `--whitelist` | Enables whitelist mode | boolean | | 336 | | `--ssl` | Enables SSL | boolean | |
| 337 | | `--dataRoot` | Root directory for data storage | string | | 337 | | `--certPath` | Path to your certificate file | string | |
| 338 | | `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode. | boolean | | 338 | | `--keyPath` | Path to your private key file | string | |
| 339 | | `--basicAuthMode` | Enables basic authentication | boolean | | 339 | | `--autorun` | Automatically launch SillyTavern in the browser | boolean | |
| 340 | | `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean | | 340 | | `--autorunHostname` | Autorun hostname | string | |
| 341 | | `--requestProxyUrl` | Request proxy URL (HTTP or SOCKS protocols) | string | | 341 | | `--autorunPortOverride` | Overrides the port for autorun | string | |
| 342 | | `--requestProxyBypass` | Request proxy bypass list (space separated list of hosts) | array | | 342 | | `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode | boolean | |
| 343 | | `--corsProxy` | Enables CORS proxy | boolean | | ||
| 344 | | `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean | | ||
| 345 | | `--requestProxyUrl` | Request proxy URL (HTTP or SOCKS protocols) | string | | ||
| 346 | | `--requestProxyBypass` | Request proxy bypass list (space separated list of hosts) | array | | ||
| 347 | | `--disableCsrf` | Disables CSRF protection (NOT RECOMMENDED) | boolean | | ||
| 343 | 348 | ||
| 344 | ## Remote connections | 349 | ## Remote connections |
| 345 | 350 | ||
| @@ -351,10 +356,29 @@ You may also want to configure SillyTavern user profiles with (optional) passwor | |||
| 351 | 356 | ||
| 352 | ## Performance issues? | 357 | ## Performance issues? |
| 353 | 358 | ||
| 359 | ### General tips | ||
| 360 | |||
| 354 | 1. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category). | 361 | 1. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category). |
| 355 | 2. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended). | 362 | 2. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended). |
| 356 | 3. Make sure the browser is enabled to use GPU acceleration for rendering. | 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 | ## License and credits | 382 | ## License and credits |
| 359 | 383 | ||
| 360 | **This program is distributed in the hope that it will be useful, | 384 | **This program is distributed in the hope that it will be useful, |
| @@ -28,6 +28,11 @@ port: 8000 | |||
| 28 | # - Use -1 to use the server port. | 28 | # - Use -1 to use the server port. |
| 29 | # - Specify a port to override the default. | 29 | # - Specify a port to override the default. |
| 30 | autorunPortOverride: -1 | 30 | autorunPortOverride: -1 |
| 31 | # -- SSL options -- | ||
| 32 | ssl: | ||
| 33 | enabled: false | ||
| 34 | certPath: "./certs/cert.pem" | ||
| 35 | keyPath: "./certs/privkey.pem" | ||
| 31 | # -- SECURITY CONFIGURATION -- | 36 | # -- SECURITY CONFIGURATION -- |
| 32 | # Toggle whitelist mode | 37 | # Toggle whitelist mode |
| 33 | whitelistMode: true | 38 | whitelistMode: true |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { UserDirectoryList, User } from "./src/users"; | 1 | import { UserDirectoryList, User } from "./src/users"; |
| 2 | import { CommandLineArguments } from "./src/command-line"; | ||
| 2 | import { CsrfSyncedToken } from "csrf-sync"; | 3 | import { CsrfSyncedToken } from "csrf-sync"; |
| 3 | 4 | ||
| 4 | declare global { | 5 | declare global { |
| @@ -32,4 +33,9 @@ declare global { | |||
| 32 | * The root directory for user data. | 33 | * The root directory for user data. |
| 33 | */ | 34 | */ |
| 34 | var DATA_ROOT: string; | 35 | var DATA_ROOT: string; |
| 36 | |||
| 37 | /** | ||
| 38 | * Parsed command line arguments. | ||
| 39 | */ | ||
| 40 | var COMMAND_LINE_ARGS: CommandLineArguments; | ||
| 35 | } | 41 | } |
| @@ -1,10 +1,6 @@ | |||
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | 2 | ||
| 3 | // native node modules | 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 | import path from 'node:path'; | 4 | import path from 'node:path'; |
| 9 | import util from 'node:util'; | 5 | import util from 'node:util'; |
| 10 | import net from 'node:net'; | 6 | import net from 'node:net'; |
| @@ -12,12 +8,6 @@ import dns from 'node:dns'; | |||
| 12 | import process from 'node:process'; | 8 | import process from 'node:process'; |
| 13 | import { fileURLToPath } from 'node:url'; | 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 | import cors from 'cors'; | 11 | import cors from 'cors'; |
| 22 | import { csrfSync } from 'csrf-sync'; | 12 | import { csrfSync } from 'csrf-sync'; |
| 23 | import express from 'express'; | 13 | import express from 'express'; |
| @@ -27,23 +17,15 @@ import multer from 'multer'; | |||
| 27 | import responseTime from 'response-time'; | 17 | import responseTime from 'response-time'; |
| 28 | import helmet from 'helmet'; | 18 | import helmet from 'helmet'; |
| 29 | import bodyParser from 'body-parser'; | 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 | // local library imports | 22 | // local library imports |
| 23 | import { CommandLineParser } from './src/command-line.js'; | ||
| 41 | import { loadPlugins } from './src/plugin-loader.js'; | 24 | import { loadPlugins } from './src/plugin-loader.js'; |
| 42 | import { | 25 | import { |
| 43 | initUserStorage, | 26 | initUserStorage, |
| 44 | getCookieSecret, | 27 | getCookieSecret, |
| 45 | getCookieSessionName, | 28 | getCookieSessionName, |
| 46 | getAllEnabledUsers, | ||
| 47 | ensurePublicDirectoriesExist, | 29 | ensurePublicDirectoriesExist, |
| 48 | getUserDirectoriesList, | 30 | getUserDirectoriesList, |
| 49 | migrateSystemPrompts, | 31 | migrateSystemPrompts, |
| @@ -51,8 +33,10 @@ import { | |||
| 51 | requireLoginMiddleware, | 33 | requireLoginMiddleware, |
| 52 | setUserDataMiddleware, | 34 | setUserDataMiddleware, |
| 53 | shouldRedirectToLogin, | 35 | shouldRedirectToLogin, |
| 54 | tryAutoLogin, | 36 | cleanUploads, |
| 55 | router as userDataRouter, | 37 | getSessionCookieAge, |
| 38 | verifySecuritySettings, | ||
| 39 | loginPageMiddleware, | ||
| 56 | } from './src/users.js'; | 40 | } from './src/users.js'; |
| 57 | 41 | ||
| 58 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | 42 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| @@ -62,65 +46,35 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr | |||
| 62 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; | 46 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; |
| 63 | import initRequestProxy from './src/request-proxy.js'; | 47 | import initRequestProxy from './src/request-proxy.js'; |
| 64 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | 48 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; |
| 49 | import corsProxyMiddleware from './src/middleware/corsProxy.js'; | ||
| 65 | import { | 50 | import { |
| 66 | getVersion, | 51 | getVersion, |
| 67 | getConfigValue, | ||
| 68 | color, | 52 | color, |
| 69 | forwardFetchResponse, | ||
| 70 | removeColorFormatting, | 53 | removeColorFormatting, |
| 71 | getSeparator, | 54 | getSeparator, |
| 72 | stringToBool, | ||
| 73 | urlHostnameToIPv6, | ||
| 74 | canResolve, | ||
| 75 | safeReadFileSync, | 55 | safeReadFileSync, |
| 76 | setupLogLevel, | 56 | setupLogLevel, |
| 57 | setWindowTitle, | ||
| 77 | } from './src/util.js'; | 58 | } from './src/util.js'; |
| 78 | import { UPLOADS_DIRECTORY } from './src/constants.js'; | 59 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 79 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | 60 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| 80 | 61 | ||
| 81 | // Routers | 62 | // Routers |
| 82 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; | 63 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; |
| 83 | import { router as usersPrivateRouter } from './src/endpoints/users-private.js'; | 64 | import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js'; |
| 84 | import { router as usersAdminRouter } from './src/endpoints/users-admin.js'; | 65 | import { checkForNewContent } from './src/endpoints/content-manager.js'; |
| 85 | import { router as movingUIRouter } from './src/endpoints/moving-ui.js'; | 66 | import { init as settingsInit } from './src/endpoints/settings.js'; |
| 86 | import { router as imagesRouter } from './src/endpoints/images.js'; | 67 | import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-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 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. | 79 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. |
| 126 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 | 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 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | 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 | const DEFAULT_LISTEN = false; | 89 | globalThis.COMMAND_LINE_ARGS = cliArgs; |
| 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 | const app = express(); | 108 | const app = express(); |
| 256 | app.use(helmet({ | 109 | app.use(helmet({ |
| @@ -259,79 +112,6 @@ app.use(helmet({ | |||
| 259 | app.use(compression()); | 112 | app.use(compression()); |
| 260 | app.use(responseTime()); | 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 | // CORS Settings // | 115 | // CORS Settings // |
| 336 | const CORS = cors({ | 116 | const CORS = cors({ |
| 337 | origin: 'null', | 117 | origin: 'null', |
| @@ -340,59 +120,23 @@ const CORS = cors({ | |||
| 340 | 120 | ||
| 341 | app.use(CORS); | 121 | app.use(CORS); |
| 342 | 122 | ||
| 343 | if (listen && basicAuthMode) { | 123 | if (cliArgs.listen && cliArgs.basicAuthMode) { |
| 344 | app.use(basicAuthMiddleware); | 124 | app.use(basicAuthMiddleware); |
| 345 | } | 125 | } |
| 346 | 126 | ||
| 347 | if (enableWhitelist) { | 127 | if (cliArgs.whitelistMode) { |
| 348 | app.use(whitelistMiddleware()); | 128 | app.use(whitelistMiddleware()); |
| 349 | } | 129 | } |
| 350 | 130 | ||
| 351 | if (listen) { | 131 | if (cliArgs.listen) { |
| 352 | app.use(accessLoggerMiddleware()); | 132 | app.use(accessLoggerMiddleware()); |
| 353 | } | 133 | } |
| 354 | 134 | ||
| 355 | if (enableCorsProxy) { | 135 | if (cliArgs.enableCorsProxy) { |
| 356 | app.use(bodyParser.json({ | 136 | app.use(bodyParser.json({ |
| 357 | limit: '200mb', | 137 | limit: '200mb', |
| 358 | })); | 138 | })); |
| 359 | console.log('Enabling CORS proxy'); | 139 | app.use('/proxy/:url(*)', corsProxyMiddleware); |
| 360 | |||
| 361 | app.use('/proxy/:url(*)', async (req, res) => { | ||
| 362 | const url = req.params.url; // get the url from the request path | ||
| 363 | |||
| 364 | // Disallow circular requests | ||
| 365 | const serverUrl = req.protocol + '://' + req.get('host'); | ||
| 366 | if (url.startsWith(serverUrl)) { | ||
| 367 | return res.status(400).send('Circular requests are not allowed'); | ||
| 368 | } | ||
| 369 | |||
| 370 | try { | ||
| 371 | const headers = JSON.parse(JSON.stringify(req.headers)); | ||
| 372 | const headersToRemove = [ | ||
| 373 | 'x-csrf-token', 'host', 'referer', 'origin', 'cookie', | ||
| 374 | 'x-forwarded-for', 'x-forwarded-protocol', 'x-forwarded-proto', | ||
| 375 | 'x-forwarded-host', 'x-real-ip', 'sec-fetch-mode', | ||
| 376 | 'sec-fetch-site', 'sec-fetch-dest', | ||
| 377 | ]; | ||
| 378 | |||
| 379 | headersToRemove.forEach(header => delete headers[header]); | ||
| 380 | |||
| 381 | const bodyMethods = ['POST', 'PUT', 'PATCH']; | ||
| 382 | |||
| 383 | const response = await fetch(url, { | ||
| 384 | method: req.method, | ||
| 385 | headers: headers, | ||
| 386 | body: bodyMethods.includes(req.method) ? JSON.stringify(req.body) : undefined, | ||
| 387 | }); | ||
| 388 | |||
| 389 | // Copy over relevant response params to the proxy response | ||
| 390 | forwardFetchResponse(response, res); | ||
| 391 | |||
| 392 | } catch (error) { | ||
| 393 | res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error); | ||
| 394 | } | ||
| 395 | }); | ||
| 396 | } else { | 140 | } else { |
| 397 | app.use('/proxy/:url(*)', async (_, res) => { | 141 | app.use('/proxy/:url(*)', async (_, res) => { |
| 398 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; | 142 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; |
| @@ -401,74 +145,6 @@ if (enableCorsProxy) { | |||
| 401 | }); | 145 | }); |
| 402 | } | 146 | } |
| 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 | app.use(cookieSession({ | 148 | app.use(cookieSession({ |
| 473 | name: getCookieSessionName(), | 149 | name: getCookieSessionName(), |
| 474 | sameSite: 'strict', | 150 | sameSite: 'strict', |
| @@ -480,7 +156,7 @@ app.use(cookieSession({ | |||
| 480 | app.use(setUserDataMiddleware); | 156 | app.use(setUserDataMiddleware); |
| 481 | 157 | ||
| 482 | // CSRF Protection // | 158 | // CSRF Protection // |
| 483 | if (!disableCsrf) { | 159 | if (!cliArgs.disableCsrf) { |
| 484 | const csrfSyncProtection = csrfSync({ | 160 | const csrfSyncProtection = csrfSync({ |
| 485 | getTokenFromState: (req) => { | 161 | getTokenFromState: (req) => { |
| 486 | if (!req.session) { | 162 | if (!req.session) { |
| @@ -535,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => { | |||
| 535 | }); | 211 | }); |
| 536 | 212 | ||
| 537 | // Host login page | 213 | // Host login page |
| 538 | app.get('/login', async (request, response) => { | 214 | app.get('/login', loginPageMiddleware); |
| 539 | if (!enableAccounts) { | ||
| 540 | console.log('User accounts are disabled. Redirecting to index page.'); | ||
| 541 | return response.redirect('/'); | ||
| 542 | } | ||
| 543 | |||
| 544 | try { | ||
| 545 | const autoLogin = await tryAutoLogin(request, basicAuthMode); | ||
| 546 | |||
| 547 | if (autoLogin) { | ||
| 548 | return response.redirect('/'); | ||
| 549 | } | ||
| 550 | } catch (error) { | ||
| 551 | console.error('Error during auto-login:', error); | ||
| 552 | } | ||
| 553 | |||
| 554 | return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); | ||
| 555 | }); | ||
| 556 | 215 | ||
| 557 | // Host frontend assets | 216 | // Host frontend assets |
| 558 | const webpackMiddleware = getWebpackServeMiddleware(); | 217 | const webpackMiddleware = getWebpackServeMiddleware(); |
| @@ -573,185 +232,23 @@ app.get('/api/ping', (request, response) => { | |||
| 573 | }); | 232 | }); |
| 574 | 233 | ||
| 575 | // File uploads | 234 | // File uploads |
| 235 | const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); | ||
| 576 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); | 236 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); |
| 577 | app.use(multerMonkeyPatch); | 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 | app.get('/version', async function (_, response) { | 239 | app.get('/version', async function (_, response) { |
| 587 | const data = await getVersion(); | 240 | const data = await getVersion(); |
| 588 | response.send(data); | 241 | response.send(data); |
| 589 | }); | 242 | }); |
| 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 | * Tasks that need to be run before the server starts listening. | 248 | * Tasks that need to be run before the server starts listening. |
| 249 | * @returns {Promise<void>} | ||
| 753 | */ | 250 | */ |
| 754 | const preSetupTasks = async function () { | 251 | async function preSetupTasks() { |
| 755 | const version = await getVersion(); | 252 | const version = await getVersion(); |
| 756 | 253 | ||
| 757 | // Print formatted header | 254 | // Print formatted header |
| @@ -773,7 +270,8 @@ const preSetupTasks = async function () { | |||
| 773 | await settingsInit(); | 270 | await settingsInit(); |
| 774 | await statsInit(); | 271 | await statsInit(); |
| 775 | 272 | ||
| 776 | const cleanupPlugins = await initializePlugins(); | 273 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); |
| 274 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | ||
| 777 | const consoleTitle = process.title; | 275 | const consoleTitle = process.title; |
| 778 | 276 | ||
| 779 | let isExiting = false; | 277 | let isExiting = false; |
| @@ -797,70 +295,39 @@ const preSetupTasks = async function () { | |||
| 797 | }); | 295 | }); |
| 798 | 296 | ||
| 799 | // Add request proxy. | 297 | // Add request proxy. |
| 800 | initRequestProxy({ enabled: proxyEnabled, url: proxyUrl, bypass: proxyBypass }); | 298 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); |
| 801 | 299 | ||
| 802 | // Wait for frontend libs to compile | 300 | // Wait for frontend libs to compile |
| 803 | await webpackMiddleware.runWebpackCompiler(); | 301 | await webpackMiddleware.runWebpackCompiler(); |
| 804 | }; | ||
| 805 | |||
| 806 | /** | ||
| 807 | * Gets the hostname to use for autorun in the browser. | ||
| 808 | * @param {boolean} useIPv6 If use IPv6 | ||
| 809 | * @param {boolean} useIPv4 If use IPv4 | ||
| 810 | * @returns Promise<string> The hostname to use for autorun | ||
| 811 | */ | ||
| 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 | * Tasks that need to be run after the server starts listening. | 305 | * Tasks that need to be run after the server starts listening. |
| 834 | * @param {boolean} v6Failed If the server failed to start on 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 | const autorunUrl = new URL( | 310 | const autorunHostname = await cliArgs.getAutorunHostname(result); |
| 841 | (cliArguments.ssl ? 'https://' : 'http://') + | 311 | const autorunUrl = cliArgs.getAutorunUrl(autorunHostname); |
| 842 | (await getAutorunHostname(useIPv6, useIPv4)) + | ||
| 843 | (':') + | ||
| 844 | ((autorunPortOverride >= 0) ? autorunPortOverride : server_port), | ||
| 845 | ); | ||
| 846 | |||
| 847 | console.log('Launching...'); | ||
| 848 | 312 | ||
| 849 | if (autorun) open(autorunUrl.toString()); | 313 | if (cliArgs.autorun) { |
| 314 | console.log('Launching in a browser...'); | ||
| 315 | await open(autorunUrl.toString()); | ||
| 316 | } | ||
| 850 | 317 | ||
| 851 | setWindowTitle('SillyTavern WebServer'); | 318 | setWindowTitle('SillyTavern WebServer'); |
| 852 | 319 | ||
| 853 | let logListen = 'SillyTavern is listening on'; | 320 | let logListen = 'SillyTavern is listening on'; |
| 854 | 321 | ||
| 855 | if (useIPv6 && !v6Failed) { | 322 | if (result.useIPv6 && !result.v6Failed) { |
| 856 | logListen += color.green( | 323 | logListen += color.green( |
| 857 | ' IPv6: ' + tavernUrlV6.host, | 324 | ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, |
| 858 | ); | 325 | ); |
| 859 | } | 326 | } |
| 860 | 327 | ||
| 861 | if (useIPv4 && !v4Failed) { | 328 | if (result.useIPv4 && !result.v4Failed) { |
| 862 | logListen += color.green( | 329 | logListen += color.green( |
| 863 | ' IPv4: ' + tavernUrl.host, | 330 | ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, |
| 864 | ); | 331 | ); |
| 865 | } | 332 | } |
| 866 | 333 | ||
| @@ -868,7 +335,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | |||
| 868 | const plainGoToLog = removeColorFormatting(goToLog); | 335 | const plainGoToLog = removeColorFormatting(goToLog); |
| 869 | 336 | ||
| 870 | console.log(logListen); | 337 | console.log(logListen); |
| 871 | if (listen) { | 338 | if (cliArgs.listen) { |
| 872 | console.log(); | 339 | console.log(); |
| 873 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); | 340 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); |
| 874 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); | 341 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); |
| @@ -877,262 +344,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | |||
| 877 | console.log(goToLog); | 344 | console.log(goToLog); |
| 878 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | 345 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); |
| 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 | setupLogLevel(); | 347 | setupLogLevel(); |
| 898 | }; | ||
| 899 | |||
| 900 | /** | ||
| 901 | * Loads server plugins from a directory. | ||
| 902 | * @returns {Promise<Function>} Function to be run on server exit | ||
| 903 | */ | ||
| 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 | .then(verifySecuritySettings) | 365 | .then(verifySecuritySettings) |
| 1154 | .then(preSetupTasks) | 366 | .then(preSetupTasks) |
| 1155 | .then(apply404Middleware) | 367 | .then(apply404Middleware) |
| 1156 | .finally(startServer); | 368 | .then(() => new ServerStartup(app, cliArgs).start()) |
| 369 | .then(postSetupTasks); | ||
| @@ -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 | * be called before the server shuts down. | 38 | * be called before the server shuts down. |
| 39 | */ | 39 | */ |
| 40 | export async function loadPlugins(app, pluginsPath) { | 40 | export async function loadPlugins(app, pluginsPath) { |
| 41 | const exitHooks = []; | 41 | try { |
| 42 | const emptyFn = () => { }; | 42 | const exitHooks = []; |
| 43 | const emptyFn = () => { }; | ||
| 43 | 44 | ||
| 44 | // Server plugins are disabled. | 45 | // Server plugins are disabled. |
| 45 | if (!enableServerPlugins) { | 46 | if (!enableServerPlugins) { |
| 46 | return emptyFn; | 47 | return emptyFn; |
| 47 | } | 48 | } |
| 48 | 49 | ||
| 49 | // Plugins directory does not exist. | 50 | // Plugins directory does not exist. |
| 50 | if (!fs.existsSync(pluginsPath)) { | 51 | if (!fs.existsSync(pluginsPath)) { |
| 51 | return emptyFn; | 52 | return emptyFn; |
| 52 | } | 53 | } |
| 53 | 54 | ||
| 54 | const files = fs.readdirSync(pluginsPath); | 55 | const files = fs.readdirSync(pluginsPath); |
| 55 | 56 | ||
| 56 | // No plugins to load. | 57 | // No plugins to load. |
| 57 | if (files.length === 0) { | 58 | if (files.length === 0) { |
| 58 | return emptyFn; | 59 | return emptyFn; |
| 59 | } | 60 | } |
| 60 | 61 | ||
| 61 | await updatePlugins(pluginsPath); | 62 | await updatePlugins(pluginsPath); |
| 62 | 63 | ||
| 63 | for (const file of files) { | 64 | for (const file of files) { |
| 64 | const pluginFilePath = path.join(pluginsPath, file); | 65 | const pluginFilePath = path.join(pluginsPath, file); |
| 65 | 66 | ||
| 66 | if (fs.statSync(pluginFilePath).isDirectory()) { | 67 | if (fs.statSync(pluginFilePath).isDirectory()) { |
| 67 | await loadFromDirectory(app, pluginFilePath, exitHooks); | 68 | await loadFromDirectory(app, pluginFilePath, exitHooks); |
| 68 | continue; | 69 | continue; |
| 69 | } | 70 | } |
| 71 | |||
| 72 | // Not a JavaScript file. | ||
| 73 | if (!isCommonJS(file) && !isESModule(file)) { | ||
| 74 | continue; | ||
| 75 | } | ||
| 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 | async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { | 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 | import _ from 'lodash'; | 14 | import _ from 'lodash'; |
| 15 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | 15 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 16 | 16 | ||
| 17 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE } from './constants.js'; | 17 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; |
| 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; | 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; |
| 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; | 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; |
| 20 | import { getContentOfType } from './endpoints/content-manager.js'; | 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 | * Gets a list of all user directories. | 211 | * Gets a list of all user directories. |
| 125 | * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories | 212 | * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories |
| 126 | */ | 213 | */ |
| @@ -478,6 +565,25 @@ export function getCookieSessionName() { | |||
| 478 | return `session-${suffix}`; | 565 | return `session-${suffix}`; |
| 479 | } | 566 | } |
| 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 | * Hashes a password using scrypt with the provided salt. | 588 | * Hashes a password using scrypt with the provided salt. |
| 483 | * @param {string} password Password to hash | 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 | * Creates a route handler for serving files from a specific directory. | 912 | * Creates a route handler for serving files from a specific directory. |
| 782 | * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from | 913 | * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from |
| 783 | * @returns {import('express').RequestHandler} | 914 | * @returns {import('express').RequestHandler} |
| @@ -6,6 +6,7 @@ import { Readable } from 'node:stream'; | |||
| 6 | import { createRequire } from 'node:module'; | 6 | import { createRequire } from 'node:module'; |
| 7 | import { Buffer } from 'node:buffer'; | 7 | import { Buffer } from 'node:buffer'; |
| 8 | import { promises as dnsPromise } from 'node:dns'; | 8 | import { promises as dnsPromise } from 'node:dns'; |
| 9 | import os from 'node:os'; | ||
| 9 | 10 | ||
| 10 | import yaml from 'yaml'; | 11 | import yaml from 'yaml'; |
| 11 | import { sync as commandExistsSync } from 'command-exists'; | 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 | * Converts various JavaScript primitives to boolean values. | 822 | * Converts various JavaScript primitives to boolean values. |
| 775 | * Handles special case for "true"/"false" strings (case-insensitive) | 823 | * Handles special case for "true"/"false" strings (case-insensitive) |
| 776 | * | 824 | * |
| @@ -809,10 +857,10 @@ export function stringToBool(str) { | |||
| 809 | export function setupLogLevel() { | 857 | export function setupLogLevel() { |
| 810 | const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number'); | 858 | const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number'); |
| 811 | 859 | ||
| 812 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {}; | 860 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { }; |
| 813 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {}; | 861 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { }; |
| 814 | globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => {}; | 862 | globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { }; |
| 815 | globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => {}; | 863 | globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { }; |
| 816 | } | 864 | } |
| 817 | 865 | ||
| 818 | /** | 866 | /** |
| @@ -1005,3 +1053,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { | |||
| 1005 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); | 1053 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); |
| 1006 | return null; | 1054 | return null; |
| 1007 | } | 1055 | } |
| 1056 | |||
| 1057 | /** | ||
| 1058 | * Set the title of the terminal window | ||
| 1059 | * @param {string} title Desired title for the window | ||
| 1060 | */ | ||
| 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 | } | ||