Refactor server startup
| @@ -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,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,17 +17,11 @@ 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'; | 21 | 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 | 22 | ||
| 40 | // local library imports | 23 | // local library imports |
| 24 | import { CommandLineParser } from './src/command-line.js'; | ||
| 41 | import { loadPlugins } from './src/plugin-loader.js'; | 25 | import { loadPlugins } from './src/plugin-loader.js'; |
| 42 | import { | 26 | import { |
| 43 | initUserStorage, | 27 | initUserStorage, |
| @@ -53,6 +37,8 @@ import { | |||
| 53 | shouldRedirectToLogin, | 37 | shouldRedirectToLogin, |
| 54 | tryAutoLogin, | 38 | tryAutoLogin, |
| 55 | router as userDataRouter, | 39 | router as userDataRouter, |
| 40 | cleanUploads, | ||
| 41 | getSessionCookieAge, | ||
| 56 | } from './src/users.js'; | 42 | } from './src/users.js'; |
| 57 | 43 | ||
| 58 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | 44 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| @@ -62,18 +48,16 @@ import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './sr | |||
| 62 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; | 48 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; |
| 63 | import initRequestProxy from './src/request-proxy.js'; | 49 | import initRequestProxy from './src/request-proxy.js'; |
| 64 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | 50 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; |
| 51 | import corsProxyMiddleware from './src/middleware/corsProxy.js'; | ||
| 65 | import { | 52 | import { |
| 66 | getVersion, | 53 | getVersion, |
| 67 | getConfigValue, | 54 | getConfigValue, |
| 68 | color, | 55 | color, |
| 69 | forwardFetchResponse, | ||
| 70 | removeColorFormatting, | 56 | removeColorFormatting, |
| 71 | getSeparator, | 57 | getSeparator, |
| 72 | stringToBool, | ||
| 73 | urlHostnameToIPv6, | ||
| 74 | canResolve, | ||
| 75 | safeReadFileSync, | 58 | safeReadFileSync, |
| 76 | setupLogLevel, | 59 | setupLogLevel, |
| 60 | setWindowTitle, | ||
| 77 | } from './src/util.js'; | 61 | } from './src/util.js'; |
| 78 | import { UPLOADS_DIRECTORY } from './src/constants.js'; | 62 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 79 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | 63 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| @@ -82,45 +66,15 @@ import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | |||
| 82 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; | 66 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; |
| 83 | import { router as usersPrivateRouter } from './src/endpoints/users-private.js'; | 67 | import { router as usersPrivateRouter } from './src/endpoints/users-private.js'; |
| 84 | import { router as usersAdminRouter } from './src/endpoints/users-admin.js'; | 68 | import { router as usersAdminRouter } from './src/endpoints/users-admin.js'; |
| 85 | import { router as movingUIRouter } from './src/endpoints/moving-ui.js'; | 69 | import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js'; |
| 86 | import { router as imagesRouter } from './src/endpoints/images.js'; | 70 | import { checkForNewContent } from './src/endpoints/content-manager.js'; |
| 87 | import { router as quickRepliesRouter } from './src/endpoints/quick-replies.js'; | 71 | import { init as settingsInit } from './src/endpoints/settings.js'; |
| 88 | import { router as avatarsRouter } from './src/endpoints/avatars.js'; | 72 | import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js'; |
| 89 | import { router as themesRouter } from './src/endpoints/themes.js'; | 73 | |
| 90 | import { router as openAiRouter } from './src/endpoints/openai.js'; | 74 | // Unrestrict console logs display limit |
| 91 | import { router as googleRouter } from './src/endpoints/google.js'; | 75 | util.inspect.defaultOptions.maxArrayLength = null; |
| 92 | import { router as anthropicRouter } from './src/endpoints/anthropic.js'; | 76 | util.inspect.defaultOptions.maxStringLength = null; |
| 93 | import { router as tokenizersRouter } from './src/endpoints/tokenizers.js'; | 77 | util.inspect.defaultOptions.depth = 4; |
| 94 | import { router as presetsRouter } from './src/endpoints/presets.js'; | ||
| 95 | import { router as secretsRouter } from './src/endpoints/secrets.js'; | ||
| 96 | import { router as thumbnailRouter } from './src/endpoints/thumbnails.js'; | ||
| 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,122 +84,14 @@ if (process.versions && process.versions.node && process.versions.node.match(/20 | |||
| 130 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | 84 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); |
| 131 | } | 85 | } |
| 132 | 86 | ||
| 133 | const DEFAULT_PORT = 8000; | 87 | const cliParser = new CommandLineParser(); |
| 134 | const DEFAULT_AUTORUN = false; | 88 | const cliArgs = cliParser.parse(process.argv); |
| 135 | const DEFAULT_LISTEN = false; | 89 | globalThis.DATA_ROOT = cliArgs.dataRoot; |
| 136 | const DEFAULT_LISTEN_ADDRESS_IPV6 = '[::]'; | 90 | |
| 137 | const DEFAULT_LISTEN_ADDRESS_IPV4 = '0.0.0.0'; | 91 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { |
| 138 | const DEFAULT_CORS_PROXY = false; | 92 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); |
| 139 | const DEFAULT_WHITELIST = true; | 93 | process.exit(1); |
| 140 | const DEFAULT_ACCOUNTS = false; | 94 | } |
| 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 | |||
| 152 | const DEFAULT_AUTORUN_HOSTNAME = 'auto'; | ||
| 153 | const DEFAULT_AUTORUN_PORT = -1; | ||
| 154 | |||
| 155 | const DEFAULT_PROXY_ENABLED = false; | ||
| 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 | // change all relative paths |
| 251 | const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); | 97 | const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); |
| @@ -259,53 +105,10 @@ app.use(helmet({ | |||
| 259 | app.use(compression()); | 105 | app.use(compression()); |
| 260 | app.use(responseTime()); | 106 | app.use(responseTime()); |
| 261 | 107 | ||
| 262 | |||
| 263 | /** @type {number} */ | ||
| 264 | const server_port = cliArguments.port ?? getConfigValue('port', DEFAULT_PORT, 'number'); | ||
| 265 | /** @type {boolean} */ | 108 | /** @type {boolean} */ |
| 266 | const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN, 'boolean')) && !cliArguments.ssl; | 109 | const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean'); |
| 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 | 110 | ||
| 287 | 111 | if (cliArgs.dnsPreferIPv6) { | |
| 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 | 112 | // Set default DNS resolution order to IPv6 first |
| 310 | dns.setDefaultResultOrder('ipv6first'); | 113 | dns.setDefaultResultOrder('ipv6first'); |
| 311 | console.log('Preferring IPv6 for DNS resolution'); | 114 | console.log('Preferring IPv6 for DNS resolution'); |
| @@ -315,23 +118,6 @@ if (dnsPreferIPv6) { | |||
| 315 | console.log('Preferring IPv4 for DNS resolution'); | 118 | console.log('Preferring IPv4 for DNS resolution'); |
| 316 | } | 119 | } |
| 317 | 120 | ||
| 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 // | 121 | // CORS Settings // |
| 336 | const CORS = cors({ | 122 | const CORS = cors({ |
| 337 | origin: 'null', | 123 | origin: 'null', |
| @@ -340,59 +126,23 @@ const CORS = cors({ | |||
| 340 | 126 | ||
| 341 | app.use(CORS); | 127 | app.use(CORS); |
| 342 | 128 | ||
| 343 | if (listen && basicAuthMode) { | 129 | if (cliArgs.listen && cliArgs.basicAuthMode) { |
| 344 | app.use(basicAuthMiddleware); | 130 | app.use(basicAuthMiddleware); |
| 345 | } | 131 | } |
| 346 | 132 | ||
| 347 | if (enableWhitelist) { | 133 | if (cliArgs.whitelistMode) { |
| 348 | app.use(whitelistMiddleware()); | 134 | app.use(whitelistMiddleware()); |
| 349 | } | 135 | } |
| 350 | 136 | ||
| 351 | if (listen) { | 137 | if (cliArgs.listen) { |
| 352 | app.use(accessLoggerMiddleware()); | 138 | app.use(accessLoggerMiddleware()); |
| 353 | } | 139 | } |
| 354 | 140 | ||
| 355 | if (enableCorsProxy) { | 141 | if (cliArgs.enableCorsProxy) { |
| 356 | app.use(bodyParser.json({ | 142 | app.use(bodyParser.json({ |
| 357 | limit: '200mb', | 143 | limit: '200mb', |
| 358 | })); | 144 | })); |
| 359 | console.log('Enabling CORS proxy'); | 145 | app.use('/proxy/:url(*)', corsProxyMiddleware); |
| 360 | |||
| 361 | app.use('/proxy/:url(*)', async (req, res) => { | ||
| 362 | const url = req.params.url; // get the url from the request path | ||
| 363 | |||
| 364 | // Disallow circular requests | ||
| 365 | const serverUrl = req.protocol + '://' + req.get('host'); | ||
| 366 | if (url.startsWith(serverUrl)) { | ||
| 367 | return res.status(400).send('Circular requests are not allowed'); | ||
| 368 | } | ||
| 369 | |||
| 370 | try { | ||
| 371 | const headers = JSON.parse(JSON.stringify(req.headers)); | ||
| 372 | const headersToRemove = [ | ||
| 373 | 'x-csrf-token', 'host', 'referer', 'origin', 'cookie', | ||
| 374 | 'x-forwarded-for', 'x-forwarded-protocol', 'x-forwarded-proto', | ||
| 375 | 'x-forwarded-host', 'x-real-ip', 'sec-fetch-mode', | ||
| 376 | 'sec-fetch-site', 'sec-fetch-dest', | ||
| 377 | ]; | ||
| 378 | |||
| 379 | headersToRemove.forEach(header => delete headers[header]); | ||
| 380 | |||
| 381 | const bodyMethods = ['POST', 'PUT', 'PATCH']; | ||
| 382 | |||
| 383 | const response = await fetch(url, { | ||
| 384 | method: req.method, | ||
| 385 | headers: headers, | ||
| 386 | body: bodyMethods.includes(req.method) ? JSON.stringify(req.body) : undefined, | ||
| 387 | }); | ||
| 388 | |||
| 389 | // Copy over relevant response params to the proxy response | ||
| 390 | forwardFetchResponse(response, res); | ||
| 391 | |||
| 392 | } catch (error) { | ||
| 393 | res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error); | ||
| 394 | } | ||
| 395 | }); | ||
| 396 | } else { | 146 | } else { |
| 397 | app.use('/proxy/:url(*)', async (_, res) => { | 147 | app.use('/proxy/:url(*)', async (_, res) => { |
| 398 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; | 148 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; |
| @@ -401,74 +151,6 @@ if (enableCorsProxy) { | |||
| 401 | }); | 151 | }); |
| 402 | } | 152 | } |
| 403 | 153 | ||
| 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({ | 154 | app.use(cookieSession({ |
| 473 | name: getCookieSessionName(), | 155 | name: getCookieSessionName(), |
| 474 | sameSite: 'strict', | 156 | sameSite: 'strict', |
| @@ -480,7 +162,7 @@ app.use(cookieSession({ | |||
| 480 | app.use(setUserDataMiddleware); | 162 | app.use(setUserDataMiddleware); |
| 481 | 163 | ||
| 482 | // CSRF Protection // | 164 | // CSRF Protection // |
| 483 | if (!disableCsrf) { | 165 | if (!cliArgs.disableCsrf) { |
| 484 | const csrfSyncProtection = csrfSync({ | 166 | const csrfSyncProtection = csrfSync({ |
| 485 | getTokenFromState: (req) => { | 167 | getTokenFromState: (req) => { |
| 486 | if (!req.session) { | 168 | if (!req.session) { |
| @@ -542,7 +224,7 @@ app.get('/login', async (request, response) => { | |||
| 542 | } | 224 | } |
| 543 | 225 | ||
| 544 | try { | 226 | try { |
| 545 | const autoLogin = await tryAutoLogin(request, basicAuthMode); | 227 | const autoLogin = await tryAutoLogin(request, cliArgs.basicAuthMode); |
| 546 | 228 | ||
| 547 | if (autoLogin) { | 229 | if (autoLogin) { |
| 548 | return response.redirect('/'); | 230 | return response.redirect('/'); |
| @@ -573,6 +255,7 @@ app.get('/api/ping', (request, response) => { | |||
| 573 | }); | 255 | }); |
| 574 | 256 | ||
| 575 | // File uploads | 257 | // File uploads |
| 258 | const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY); | ||
| 576 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); | 259 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); |
| 577 | app.use(multerMonkeyPatch); | 260 | app.use(multerMonkeyPatch); |
| 578 | 261 | ||
| @@ -588,170 +271,14 @@ app.get('/version', async function (_, response) { | |||
| 588 | response.send(data); | 271 | response.send(data); |
| 589 | }); | 272 | }); |
| 590 | 273 | ||
| 591 | function cleanUploads() { | 274 | redirectDeprecatedEndpoints(app); |
| 592 | try { | 275 | 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 | 276 | ||
| 751 | /** | 277 | /** |
| 752 | * Tasks that need to be run before the server starts listening. | 278 | * Tasks that need to be run before the server starts listening. |
| 279 | * @returns {Promise<void>} | ||
| 753 | */ | 280 | */ |
| 754 | const preSetupTasks = async function () { | 281 | async function preSetupTasks() { |
| 755 | const version = await getVersion(); | 282 | const version = await getVersion(); |
| 756 | 283 | ||
| 757 | // Print formatted header | 284 | // Print formatted header |
| @@ -797,56 +324,25 @@ const preSetupTasks = async function () { | |||
| 797 | }); | 324 | }); |
| 798 | 325 | ||
| 799 | // Add request proxy. | 326 | // Add request proxy. |
| 800 | initRequestProxy({ enabled: proxyEnabled, url: proxyUrl, bypass: proxyBypass }); | 327 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); |
| 801 | 328 | ||
| 802 | // Wait for frontend libs to compile | 329 | // Wait for frontend libs to compile |
| 803 | await webpackMiddleware.runWebpackCompiler(); | 330 | await webpackMiddleware.runWebpackCompiler(); |
| 804 | }; | ||
| 805 | |||
| 806 | /** | ||
| 807 | * Gets the hostname to use for autorun in the browser. | ||
| 808 | * @param {boolean} useIPv6 If use IPv6 | ||
| 809 | * @param {boolean} useIPv4 If use IPv4 | ||
| 810 | * @returns Promise<string> The hostname to use for autorun | ||
| 811 | */ | ||
| 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 | } | 331 | } |
| 831 | 332 | ||
| 832 | /** | 333 | /** |
| 833 | * Tasks that need to be run after the server starts listening. | 334 | * Tasks that need to be run after the server starts listening. |
| 834 | * @param {boolean} v6Failed If the server failed to start on IPv6 | 335 | * @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 | 336 | * @returns {Promise<void>} |
| 836 | * @param {boolean} useIPv6 If the server is using IPv6 | ||
| 837 | * @param {boolean} useIPv4 If the server is using IPv4 | ||
| 838 | */ | 337 | */ |
| 839 | const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | 338 | async function postSetupTasks({ v6Failed, v4Failed, useIPv6, useIPv4 }) { |
| 840 | const autorunUrl = new URL( | 339 | const autorunHostname = await cliArgs.getAutorunHostname(useIPv6, useIPv4); |
| 841 | (cliArguments.ssl ? 'https://' : 'http://') + | 340 | const autorunUrl = cliArgs.getAutorunUrl(autorunHostname); |
| 842 | (await getAutorunHostname(useIPv6, useIPv4)) + | ||
| 843 | (':') + | ||
| 844 | ((autorunPortOverride >= 0) ? autorunPortOverride : server_port), | ||
| 845 | ); | ||
| 846 | |||
| 847 | console.log('Launching...'); | 341 | console.log('Launching...'); |
| 848 | 342 | ||
| 849 | if (autorun) open(autorunUrl.toString()); | 343 | if (cliArgs.autorun) { |
| 344 | open(autorunUrl.toString()); | ||
| 345 | } | ||
| 850 | 346 | ||
| 851 | setWindowTitle('SillyTavern WebServer'); | 347 | setWindowTitle('SillyTavern WebServer'); |
| 852 | 348 | ||
| @@ -854,13 +350,13 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | |||
| 854 | 350 | ||
| 855 | if (useIPv6 && !v6Failed) { | 351 | if (useIPv6 && !v6Failed) { |
| 856 | logListen += color.green( | 352 | logListen += color.green( |
| 857 | ' IPv6: ' + tavernUrlV6.host, | 353 | ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, |
| 858 | ); | 354 | ); |
| 859 | } | 355 | } |
| 860 | 356 | ||
| 861 | if (useIPv4 && !v4Failed) { | 357 | if (useIPv4 && !v4Failed) { |
| 862 | logListen += color.green( | 358 | logListen += color.green( |
| 863 | ' IPv4: ' + tavernUrl.host, | 359 | ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, |
| 864 | ); | 360 | ); |
| 865 | } | 361 | } |
| 866 | 362 | ||
| @@ -868,7 +364,7 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | |||
| 868 | const plainGoToLog = removeColorFormatting(goToLog); | 364 | const plainGoToLog = removeColorFormatting(goToLog); |
| 869 | 365 | ||
| 870 | console.log(logListen); | 366 | console.log(logListen); |
| 871 | if (listen) { | 367 | if (cliArgs.listen) { |
| 872 | console.log(); | 368 | console.log(); |
| 873 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); | 369 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); |
| 874 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); | 370 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); |
| @@ -877,25 +373,8 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { | |||
| 877 | console.log(goToLog); | 373 | console.log(goToLog); |
| 878 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | 374 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); |
| 879 | 375 | ||
| 880 | |||
| 881 | if (basicAuthMode) { | ||
| 882 | if (perUserBasicAuth && !enableAccounts) { | ||
| 883 | console.error(color.red( | ||
| 884 | 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', | ||
| 885 | )); | ||
| 886 | } else if (!perUserBasicAuth) { | ||
| 887 | const basicAuthUserName = getConfigValue('basicAuthUser.username', ''); | ||
| 888 | const basicAuthUserPassword = getConfigValue('basicAuthUser.password', ''); | ||
| 889 | if (!basicAuthUserName || !basicAuthUserPassword) { | ||
| 890 | console.warn(color.yellow( | ||
| 891 | 'Basic Authentication is enabled, but username or password is not set or empty!', | ||
| 892 | )); | ||
| 893 | } | ||
| 894 | } | ||
| 895 | } | ||
| 896 | |||
| 897 | setupLogLevel(); | 376 | setupLogLevel(); |
| 898 | }; | 377 | } |
| 899 | 378 | ||
| 900 | /** | 379 | /** |
| 901 | * Loads server plugins from a directory. | 380 | * Loads server plugins from a directory. |
| @@ -913,25 +392,12 @@ async function initializePlugins() { | |||
| 913 | } | 392 | } |
| 914 | 393 | ||
| 915 | /** | 394 | /** |
| 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 | 395 | * Prints an error message and exits the process if necessary |
| 930 | * @param {string} message The error message to print | 396 | * @param {string} message The error message to print |
| 931 | * @returns {void} | 397 | * @returns {void} |
| 932 | */ | 398 | */ |
| 933 | function logSecurityAlert(message) { | 399 | function logSecurityAlert(message) { |
| 934 | if (basicAuthMode || enableWhitelist) return; // safe! | 400 | if (cliArgs.basicAuthMode || cliArgs.whitelistMode) return; // safe! |
| 935 | console.error(color.red(message)); | 401 | console.error(color.red(message)); |
| 936 | if (getConfigValue('securityOverride', false, 'boolean')) { | 402 | if (getConfigValue('securityOverride', false, 'boolean')) { |
| 937 | console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); | 403 | console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); |
| @@ -940,177 +406,9 @@ function logSecurityAlert(message) { | |||
| 940 | process.exit(1); | 406 | process.exit(1); |
| 941 | } | 407 | } |
| 942 | 408 | ||
| 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() { | 409 | async function verifySecuritySettings() { |
| 1112 | // Skip all security checks as listen is set to false | 410 | // Skip all security checks as listen is set to false |
| 1113 | if (!listen) { | 411 | if (!cliArgs.listen) { |
| 1114 | return; | 412 | return; |
| 1115 | } | 413 | } |
| 1116 | 414 | ||
| @@ -1133,6 +431,23 @@ async function verifySecuritySettings() { | |||
| 1133 | logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); | 431 | logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); |
| 1134 | } | 432 | } |
| 1135 | } | 433 | } |
| 434 | |||
| 435 | if (cliArgs.basicAuthMode) { | ||
| 436 | const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean'); | ||
| 437 | if (perUserBasicAuth && !enableAccounts) { | ||
| 438 | console.error(color.red( | ||
| 439 | 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', | ||
| 440 | )); | ||
| 441 | } else if (!perUserBasicAuth) { | ||
| 442 | const basicAuthUserName = getConfigValue('basicAuthUser.username', ''); | ||
| 443 | const basicAuthUserPassword = getConfigValue('basicAuthUser.password', ''); | ||
| 444 | if (!basicAuthUserName || !basicAuthUserPassword) { | ||
| 445 | console.warn(color.yellow( | ||
| 446 | 'Basic Authentication is enabled, but username or password is not set or empty!', | ||
| 447 | )); | ||
| 448 | } | ||
| 449 | } | ||
| 450 | } | ||
| 1136 | } | 451 | } |
| 1137 | 452 | ||
| 1138 | /** | 453 | /** |
| @@ -1153,4 +468,5 @@ initUserStorage(globalThis.DATA_ROOT) | |||
| 1153 | .then(verifySecuritySettings) | 468 | .then(verifySecuritySettings) |
| 1154 | .then(preSetupTasks) | 469 | .then(preSetupTasks) |
| 1155 | .then(apply404Middleware) | 470 | .then(apply404Middleware) |
| 1156 | .finally(startServer); | 471 | .then(new ServerStartup(app, cliArgs).start) |
| 472 | .then(postSetupTasks); | ||
| @@ -0,0 +1,265 @@ | |||
| 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 | ||
| 8 | * @property {string} dataRoot | ||
| 9 | * @property {number} port | ||
| 10 | * @property {boolean} listen | ||
| 11 | * @property {string} listenAddressIPv6 | ||
| 12 | * @property {string} listenAddressIPv4 | ||
| 13 | * @property {boolean|string} enableIPv4 | ||
| 14 | * @property {boolean|string} enableIPv6 | ||
| 15 | * @property {boolean} dnsPreferIPv6 | ||
| 16 | * @property {boolean} autorun | ||
| 17 | * @property {string} autorunHostname | ||
| 18 | * @property {number} autorunPortOverride | ||
| 19 | * @property {boolean} enableCorsProxy | ||
| 20 | * @property {boolean} disableCsrf | ||
| 21 | * @property {boolean} ssl | ||
| 22 | * @property {string} certPath | ||
| 23 | * @property {string} keyPath | ||
| 24 | * @property {boolean} whitelistMode | ||
| 25 | * @property {boolean} avoidLocalhost | ||
| 26 | * @property {boolean} basicAuthMode | ||
| 27 | * @property {boolean} requestProxyEnabled | ||
| 28 | * @property {string} requestProxyUrl | ||
| 29 | * @property {string[]} requestProxyBypass | ||
| 30 | * @property {function(): URL} getIPv4ListenUrl | ||
| 31 | * @property {function(): URL} getIPv6ListenUrl | ||
| 32 | * @property {function(boolean, boolean): Promise<string>} getAutorunHostname | ||
| 33 | * @property {function(string): URL} getAutorunUrl | ||
| 34 | */ | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Gets the hostname to use for autorun in the browser. | ||
| 38 | * @param {boolean} useIPv6 If use IPv6 | ||
| 39 | * @param {boolean} useIPv4 If use IPv4 | ||
| 40 | * @returns {Promise<string>} The hostname to use for autorun | ||
| 41 | */ | ||
| 42 | |||
| 43 | export class CommandLineParser { | ||
| 44 | constructor() { | ||
| 45 | /** @type {CommandLineArguments} */ | ||
| 46 | this.default = Object.freeze({ | ||
| 47 | dataRoot: './data', | ||
| 48 | port: 8000, | ||
| 49 | listen: false, | ||
| 50 | listenAddressIPv6: '[::]', | ||
| 51 | listenAddressIPv4: '0.0.0.0', | ||
| 52 | enableIPv4: true, | ||
| 53 | enableIPv6: false, | ||
| 54 | dnsPreferIPv6: false, | ||
| 55 | autorun: false, | ||
| 56 | autorunHostname: 'auto', | ||
| 57 | autorunPortOverride: -1, | ||
| 58 | enableCorsProxy: false, | ||
| 59 | disableCsrf: false, | ||
| 60 | ssl: false, | ||
| 61 | certPath: 'certs/cert.pem', | ||
| 62 | keyPath: 'certs/privkey.pem', | ||
| 63 | whitelistMode: true, | ||
| 64 | avoidLocalhost: false, | ||
| 65 | basicAuthMode: false, | ||
| 66 | requestProxyEnabled: false, | ||
| 67 | requestProxyUrl: '', | ||
| 68 | requestProxyBypass: [], | ||
| 69 | getIPv4ListenUrl: function () { | ||
| 70 | throw new Error('getIPv4ListenUrl is not implemented'); | ||
| 71 | }, | ||
| 72 | getIPv6ListenUrl: function () { | ||
| 73 | throw new Error('getIPv6ListenUrl is not implemented'); | ||
| 74 | }, | ||
| 75 | getAutorunHostname: async function () { | ||
| 76 | throw new Error('getAutorunHostname is not implemented'); | ||
| 77 | }, | ||
| 78 | getAutorunUrl: function () { | ||
| 79 | throw new Error('getAutorunUrl is not implemented'); | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | |||
| 83 | this.booleanAutoOptions = [true, false, 'auto']; | ||
| 84 | } | ||
| 85 | |||
| 86 | /** | ||
| 87 | * Parses command line arguments. | ||
| 88 | * @param {string[]} args Process startup arguments. | ||
| 89 | * @returns {CommandLineArguments} Parsed command line arguments. | ||
| 90 | */ | ||
| 91 | parse(args) { | ||
| 92 | const cliArguments = yargs(hideBin(args)) | ||
| 93 | .usage('Usage: <your-start-script> <command> [options]') | ||
| 94 | .option('enableIPv6', { | ||
| 95 | type: 'string', | ||
| 96 | default: null, | ||
| 97 | describe: 'Enables IPv6 protocol.', | ||
| 98 | }).option('enableIPv4', { | ||
| 99 | type: 'string', | ||
| 100 | default: null, | ||
| 101 | describe: 'Enables IPv4 protocol.', | ||
| 102 | }).option('port', { | ||
| 103 | type: 'number', | ||
| 104 | default: null, | ||
| 105 | describe: 'Sets the port under which SillyTavern will run.\nIf not provided falls back to yaml config \'port\'.', | ||
| 106 | }).option('dnsPreferIPv6', { | ||
| 107 | type: 'boolean', | ||
| 108 | default: null, | ||
| 109 | describe: 'Prefers IPv6 for DNS\nyou should probably have the enabled if you\'re on an IPv6 only network\nIf not provided falls back to yaml config \'dnsPreferIPv6\'.', | ||
| 110 | }).option('autorun', { | ||
| 111 | type: 'boolean', | ||
| 112 | default: null, | ||
| 113 | describe: 'Automatically launch SillyTavern in the browser.\nAutorun is automatically disabled if --ssl is set to true.\nIf not provided falls back to yaml config \'autorun\'.', | ||
| 114 | }).option('autorunHostname', { | ||
| 115 | type: 'string', | ||
| 116 | default: null, | ||
| 117 | describe: 'Sets the autorun hostname, probably best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'', | ||
| 118 | }).option('autorunPortOverride', { | ||
| 119 | type: 'string', | ||
| 120 | default: null, | ||
| 121 | describe: 'Overrides the port for autorun with open your browser with this port and ignore what port the server is running on. -1 is use server port', | ||
| 122 | }).option('listen', { | ||
| 123 | type: 'boolean', | ||
| 124 | default: null, | ||
| 125 | describe: 'SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If false, will limit it only to internal localhost (127.0.0.1).\nIf not provided falls back to yaml config \'listen\'.', | ||
| 126 | }).option('listenAddressIPv6', { | ||
| 127 | type: 'string', | ||
| 128 | default: null, | ||
| 129 | describe: 'Set SillyTavern to listen to a specific IPv6 address. If not set, it will fallback to listen to all.', | ||
| 130 | }).option('listenAddressIPv4', { | ||
| 131 | type: 'string', | ||
| 132 | default: null, | ||
| 133 | describe: 'Set SillyTavern to listen to a specific IPv4 address. If not set, it will fallback to listen to all.', | ||
| 134 | }).option('corsProxy', { | ||
| 135 | type: 'boolean', | ||
| 136 | default: null, | ||
| 137 | describe: 'Enables CORS proxy\nIf not provided falls back to yaml config \'enableCorsProxy\'', | ||
| 138 | }).option('disableCsrf', { | ||
| 139 | type: 'boolean', | ||
| 140 | default: null, | ||
| 141 | describe: 'Disables CSRF protection', | ||
| 142 | }).option('ssl', { | ||
| 143 | type: 'boolean', | ||
| 144 | default: false, | ||
| 145 | describe: 'Enables SSL', | ||
| 146 | }).option('certPath', { | ||
| 147 | type: 'string', | ||
| 148 | default: 'certs/cert.pem', | ||
| 149 | describe: 'Path to your certificate file.', | ||
| 150 | }).option('keyPath', { | ||
| 151 | type: 'string', | ||
| 152 | default: 'certs/privkey.pem', | ||
| 153 | describe: 'Path to your private key file.', | ||
| 154 | }).option('whitelist', { | ||
| 155 | type: 'boolean', | ||
| 156 | default: null, | ||
| 157 | describe: 'Enables whitelist mode', | ||
| 158 | }).option('dataRoot', { | ||
| 159 | type: 'string', | ||
| 160 | default: null, | ||
| 161 | describe: 'Root directory for data storage', | ||
| 162 | }).option('avoidLocalhost', { | ||
| 163 | type: 'boolean', | ||
| 164 | default: null, | ||
| 165 | describe: 'Avoids using \'localhost\' for autorun in auto mode.\nuse if you don\'t have \'localhost\' in your hosts file', | ||
| 166 | }).option('basicAuthMode', { | ||
| 167 | type: 'boolean', | ||
| 168 | default: null, | ||
| 169 | describe: 'Enables basic authentication', | ||
| 170 | }).option('requestProxyEnabled', { | ||
| 171 | type: 'boolean', | ||
| 172 | default: null, | ||
| 173 | describe: 'Enables a use of proxy for outgoing requests', | ||
| 174 | }).option('requestProxyUrl', { | ||
| 175 | type: 'string', | ||
| 176 | default: null, | ||
| 177 | describe: 'Request proxy URL (HTTP or SOCKS protocols)', | ||
| 178 | }).option('requestProxyBypass', { | ||
| 179 | type: 'array', | ||
| 180 | describe: 'Request proxy bypass list (space separated list of hosts)', | ||
| 181 | }).parseSync(); | ||
| 182 | |||
| 183 | /** @type {CommandLineArguments} */ | ||
| 184 | const result = { | ||
| 185 | dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot), | ||
| 186 | port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'), | ||
| 187 | listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'), | ||
| 188 | listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', this.default.listenAddressIPv6), | ||
| 189 | listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', this.default.listenAddressIPv4), | ||
| 190 | enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', this.default.enableIPv4)) ?? this.default.enableIPv4, | ||
| 191 | enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', this.default.enableIPv6)) ?? this.default.enableIPv6, | ||
| 192 | dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', this.default.dnsPreferIPv6, 'boolean'), | ||
| 193 | autorun: cliArguments.autorun ?? getConfigValue('autorun', this.default.autorun, 'boolean'), | ||
| 194 | autorunHostname: cliArguments.autorunHostname ?? getConfigValue('autorunHostname', this.default.autorunHostname), | ||
| 195 | autorunPortOverride: cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', this.default.autorunPortOverride, 'number'), | ||
| 196 | enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', this.default.enableCorsProxy, 'boolean'), | ||
| 197 | disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', this.default.disableCsrf, 'boolean'), | ||
| 198 | ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', this.default.ssl, 'boolean'), | ||
| 199 | certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', this.default.certPath), | ||
| 200 | keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', this.default.keyPath), | ||
| 201 | whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', this.default.whitelistMode, 'boolean'), | ||
| 202 | avoidLocalhost: cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', this.default.avoidLocalhost, 'boolean'), | ||
| 203 | basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', this.default.basicAuthMode, 'boolean'), | ||
| 204 | requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', this.default.requestProxyEnabled, 'boolean'), | ||
| 205 | requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', this.default.requestProxyUrl), | ||
| 206 | requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', this.default.requestProxyBypass), | ||
| 207 | getIPv4ListenUrl: function () { | ||
| 208 | const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4); | ||
| 209 | return new URL( | ||
| 210 | (this.ssl ? 'https://' : 'http://') + | ||
| 211 | (this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') + | ||
| 212 | (':' + this.port), | ||
| 213 | ); | ||
| 214 | }, | ||
| 215 | getIPv6ListenUrl: function () { | ||
| 216 | const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6); | ||
| 217 | return new URL( | ||
| 218 | (this.ssl ? 'https://' : 'http://') + | ||
| 219 | (this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') + | ||
| 220 | (':' + this.port), | ||
| 221 | ); | ||
| 222 | }, | ||
| 223 | getAutorunHostname: async function (useIPv6, useIPv4) { | ||
| 224 | if (this.autorunHostname === 'auto') { | ||
| 225 | let localhostResolve = await canResolve('localhost', useIPv6, useIPv4); | ||
| 226 | |||
| 227 | if (useIPv6 && useIPv4) { | ||
| 228 | return (this.avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost'; | ||
| 229 | } | ||
| 230 | |||
| 231 | if (useIPv6) { | ||
| 232 | return '[::1]'; | ||
| 233 | } | ||
| 234 | |||
| 235 | if (useIPv4) { | ||
| 236 | return '127.0.0.1'; | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | return this.autorunHostname; | ||
| 241 | }, | ||
| 242 | getAutorunUrl: function (hostname) { | ||
| 243 | const autorunPort = (this.autorunPortOverride >= 0) ? this.autorunPortOverride : this.port; | ||
| 244 | return new URL( | ||
| 245 | (this.ssl ? 'https://' : 'http://') + | ||
| 246 | (hostname) + | ||
| 247 | (':') + | ||
| 248 | (autorunPort), | ||
| 249 | ); | ||
| 250 | }, | ||
| 251 | }; | ||
| 252 | |||
| 253 | if (!this.booleanAutoOptions.includes(result.enableIPv6)) { | ||
| 254 | console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv6); | ||
| 255 | result.enableIPv6 = this.default.enableIPv6; | ||
| 256 | } | ||
| 257 | |||
| 258 | if (!this.booleanAutoOptions.includes(result.enableIPv4)) { | ||
| 259 | console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv4); | ||
| 260 | result.enableIPv4 = this.default.enableIPv4; | ||
| 261 | } | ||
| 262 | |||
| 263 | return result; | ||
| 264 | } | ||
| 265 | } | ||
| @@ -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 | } | ||
| @@ -0,0 +1,359 @@ | |||
| 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 movingUIRouter } from './endpoints/moving-ui.js'; | ||
| 8 | import { router as imagesRouter } from './endpoints/images.js'; | ||
| 9 | import { router as quickRepliesRouter } from './endpoints/quick-replies.js'; | ||
| 10 | import { router as avatarsRouter } from './endpoints/avatars.js'; | ||
| 11 | import { router as themesRouter } from './endpoints/themes.js'; | ||
| 12 | import { router as openAiRouter } from './endpoints/openai.js'; | ||
| 13 | import { router as googleRouter } from './endpoints/google.js'; | ||
| 14 | import { router as anthropicRouter } from './endpoints/anthropic.js'; | ||
| 15 | import { router as tokenizersRouter } from './endpoints/tokenizers.js'; | ||
| 16 | import { router as presetsRouter } from './endpoints/presets.js'; | ||
| 17 | import { router as secretsRouter } from './endpoints/secrets.js'; | ||
| 18 | import { router as thumbnailRouter } from './endpoints/thumbnails.js'; | ||
| 19 | import { router as novelAiRouter } from './endpoints/novelai.js'; | ||
| 20 | import { router as extensionsRouter } from './endpoints/extensions.js'; | ||
| 21 | import { router as assetsRouter } from './endpoints/assets.js'; | ||
| 22 | import { router as filesRouter } from './endpoints/files.js'; | ||
| 23 | import { router as charactersRouter } from './endpoints/characters.js'; | ||
| 24 | import { router as chatsRouter } from './endpoints/chats.js'; | ||
| 25 | import { router as groupsRouter } from './endpoints/groups.js'; | ||
| 26 | import { router as worldInfoRouter } from './endpoints/worldinfo.js'; | ||
| 27 | import { router as statsRouter } from './endpoints/stats.js'; | ||
| 28 | import { router as contentManagerRouter } from './endpoints/content-manager.js'; | ||
| 29 | import { router as settingsRouter } from './endpoints/settings.js'; | ||
| 30 | import { router as backgroundsRouter } from './endpoints/backgrounds.js'; | ||
| 31 | import { router as spritesRouter } from './endpoints/sprites.js'; | ||
| 32 | import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js'; | ||
| 33 | import { router as hordeRouter } from './endpoints/horde.js'; | ||
| 34 | import { router as vectorsRouter } from './endpoints/vectors.js'; | ||
| 35 | import { router as translateRouter } from './endpoints/translate.js'; | ||
| 36 | import { router as classifyRouter } from './endpoints/classify.js'; | ||
| 37 | import { router as captionRouter } from './endpoints/caption.js'; | ||
| 38 | import { router as searchRouter } from './endpoints/search.js'; | ||
| 39 | import { router as openRouterRouter } from './endpoints/openrouter.js'; | ||
| 40 | import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js'; | ||
| 41 | import { router as koboldRouter } from './endpoints/backends/kobold.js'; | ||
| 42 | import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js'; | ||
| 43 | import { router as scaleAltRouter } from './endpoints/backends/scale-alt.js'; | ||
| 44 | import { router as speechRouter } from './endpoints/speech.js'; | ||
| 45 | import { router as azureRouter } from './endpoints/azure.js'; | ||
| 46 | |||
| 47 | /** | ||
| 48 | * @typedef {object} ServerStartupResult | ||
| 49 | * @property {boolean} v6Failed If the server failed to start on IPv6 | ||
| 50 | * @property {boolean} v4Failed If the server failed to start on IPv4 | ||
| 51 | * @property {boolean} useIPv6 If use IPv6 | ||
| 52 | * @property {boolean} useIPv4 If use IPv4 | ||
| 53 | */ | ||
| 54 | |||
| 55 | /** | ||
| 56 | * Redirect deprecated API endpoints to their replacements. | ||
| 57 | * @param {import('express').Express} app The Express app to use | ||
| 58 | */ | ||
| 59 | export function redirectDeprecatedEndpoints(app) { | ||
| 60 | /** | ||
| 61 | * Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow | ||
| 62 | * redirects, this is transparent to client-side code. | ||
| 63 | * @param {string} src The URL to redirect from. | ||
| 64 | * @param {string} destination The URL to redirect to. | ||
| 65 | */ | ||
| 66 | function redirect(src, destination) { | ||
| 67 | app.use(src, (req, res) => { | ||
| 68 | console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`); | ||
| 69 | // HTTP 301 causes the request to become a GET. 308 preserves the request method. | ||
| 70 | res.redirect(308, destination); | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | |||
| 74 | redirect('/createcharacter', '/api/characters/create'); | ||
| 75 | redirect('/renamecharacter', '/api/characters/rename'); | ||
| 76 | redirect('/editcharacter', '/api/characters/edit'); | ||
| 77 | redirect('/editcharacterattribute', '/api/characters/edit-attribute'); | ||
| 78 | redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes'); | ||
| 79 | redirect('/deletecharacter', '/api/characters/delete'); | ||
| 80 | redirect('/getcharacters', '/api/characters/all'); | ||
| 81 | redirect('/getonecharacter', '/api/characters/get'); | ||
| 82 | redirect('/getallchatsofcharacter', '/api/characters/chats'); | ||
| 83 | redirect('/importcharacter', '/api/characters/import'); | ||
| 84 | redirect('/dupecharacter', '/api/characters/duplicate'); | ||
| 85 | redirect('/exportcharacter', '/api/characters/export'); | ||
| 86 | redirect('/savechat', '/api/chats/save'); | ||
| 87 | redirect('/getchat', '/api/chats/get'); | ||
| 88 | redirect('/renamechat', '/api/chats/rename'); | ||
| 89 | redirect('/delchat', '/api/chats/delete'); | ||
| 90 | redirect('/exportchat', '/api/chats/export'); | ||
| 91 | redirect('/importgroupchat', '/api/chats/group/import'); | ||
| 92 | redirect('/importchat', '/api/chats/import'); | ||
| 93 | redirect('/getgroupchat', '/api/chats/group/get'); | ||
| 94 | redirect('/deletegroupchat', '/api/chats/group/delete'); | ||
| 95 | redirect('/savegroupchat', '/api/chats/group/save'); | ||
| 96 | redirect('/getgroups', '/api/groups/all'); | ||
| 97 | redirect('/creategroup', '/api/groups/create'); | ||
| 98 | redirect('/editgroup', '/api/groups/edit'); | ||
| 99 | redirect('/deletegroup', '/api/groups/delete'); | ||
| 100 | redirect('/getworldinfo', '/api/worldinfo/get'); | ||
| 101 | redirect('/deleteworldinfo', '/api/worldinfo/delete'); | ||
| 102 | redirect('/importworldinfo', '/api/worldinfo/import'); | ||
| 103 | redirect('/editworldinfo', '/api/worldinfo/edit'); | ||
| 104 | redirect('/getstats', '/api/stats/get'); | ||
| 105 | redirect('/recreatestats', '/api/stats/recreate'); | ||
| 106 | redirect('/updatestats', '/api/stats/update'); | ||
| 107 | redirect('/getbackgrounds', '/api/backgrounds/all'); | ||
| 108 | redirect('/delbackground', '/api/backgrounds/delete'); | ||
| 109 | redirect('/renamebackground', '/api/backgrounds/rename'); | ||
| 110 | redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one | ||
| 111 | redirect('/savetheme', '/api/themes/save'); | ||
| 112 | redirect('/getuseravatars', '/api/avatars/get'); | ||
| 113 | redirect('/deleteuseravatar', '/api/avatars/delete'); | ||
| 114 | redirect('/uploaduseravatar', '/api/avatars/upload'); | ||
| 115 | redirect('/deletequickreply', '/api/quick-replies/delete'); | ||
| 116 | redirect('/savequickreply', '/api/quick-replies/save'); | ||
| 117 | redirect('/uploadimage', '/api/images/upload'); | ||
| 118 | redirect('/listimgfiles/:folder', '/api/images/list/:folder'); | ||
| 119 | redirect('/api/content/import', '/api/content/importURL'); | ||
| 120 | redirect('/savemovingui', '/api/moving-ui/save'); | ||
| 121 | redirect('/api/serpapi/search', '/api/search/serpapi'); | ||
| 122 | redirect('/api/serpapi/visit', '/api/search/visit'); | ||
| 123 | redirect('/api/serpapi/transcript', '/api/search/transcript'); | ||
| 124 | } | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Setup the routers for the endpoints. | ||
| 128 | * @param {import('express').Express} app The Express app to use | ||
| 129 | */ | ||
| 130 | export function setupPrivateEndpoints(app) { | ||
| 131 | app.use('/api/moving-ui', movingUIRouter); | ||
| 132 | app.use('/api/images', imagesRouter); | ||
| 133 | app.use('/api/quick-replies', quickRepliesRouter); | ||
| 134 | app.use('/api/avatars', avatarsRouter); | ||
| 135 | app.use('/api/themes', themesRouter); | ||
| 136 | app.use('/api/openai', openAiRouter); | ||
| 137 | app.use('/api/google', googleRouter); | ||
| 138 | app.use('/api/anthropic', anthropicRouter); | ||
| 139 | app.use('/api/tokenizers', tokenizersRouter); | ||
| 140 | app.use('/api/presets', presetsRouter); | ||
| 141 | app.use('/api/secrets', secretsRouter); | ||
| 142 | app.use('/thumbnail', thumbnailRouter); | ||
| 143 | app.use('/api/novelai', novelAiRouter); | ||
| 144 | app.use('/api/extensions', extensionsRouter); | ||
| 145 | app.use('/api/assets', assetsRouter); | ||
| 146 | app.use('/api/files', filesRouter); | ||
| 147 | app.use('/api/characters', charactersRouter); | ||
| 148 | app.use('/api/chats', chatsRouter); | ||
| 149 | app.use('/api/groups', groupsRouter); | ||
| 150 | app.use('/api/worldinfo', worldInfoRouter); | ||
| 151 | app.use('/api/stats', statsRouter); | ||
| 152 | app.use('/api/backgrounds', backgroundsRouter); | ||
| 153 | app.use('/api/sprites', spritesRouter); | ||
| 154 | app.use('/api/content', contentManagerRouter); | ||
| 155 | app.use('/api/settings', settingsRouter); | ||
| 156 | app.use('/api/sd', stableDiffusionRouter); | ||
| 157 | app.use('/api/horde', hordeRouter); | ||
| 158 | app.use('/api/vector', vectorsRouter); | ||
| 159 | app.use('/api/translate', translateRouter); | ||
| 160 | app.use('/api/extra/classify', classifyRouter); | ||
| 161 | app.use('/api/extra/caption', captionRouter); | ||
| 162 | app.use('/api/search', searchRouter); | ||
| 163 | app.use('/api/backends/text-completions', textCompletionsRouter); | ||
| 164 | app.use('/api/openrouter', openRouterRouter); | ||
| 165 | app.use('/api/backends/kobold', koboldRouter); | ||
| 166 | app.use('/api/backends/chat-completions', chatCompletionsRouter); | ||
| 167 | app.use('/api/backends/scale-alt', scaleAltRouter); | ||
| 168 | app.use('/api/speech', speechRouter); | ||
| 169 | app.use('/api/azure', azureRouter); | ||
| 170 | } | ||
| 171 | |||
| 172 | /** | ||
| 173 | * Utilities for starting the express server. | ||
| 174 | */ | ||
| 175 | export class ServerStartup { | ||
| 176 | /** | ||
| 177 | * Creates a new ServerStartup instance. | ||
| 178 | * @param {import('express').Express} app The Express app to use | ||
| 179 | * @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments | ||
| 180 | */ | ||
| 181 | constructor(app, cliArgs) { | ||
| 182 | this.app = app; | ||
| 183 | this.cliArgs = cliArgs; | ||
| 184 | } | ||
| 185 | |||
| 186 | /** | ||
| 187 | * Creates an HTTPS server. | ||
| 188 | * @param {URL} url The URL to listen on | ||
| 189 | * @param {number} ipVersion the ip version to use | ||
| 190 | * @returns {Promise<void>} A promise that resolves when the server is listening | ||
| 191 | * @throws {Error} If the server fails to start | ||
| 192 | */ | ||
| 193 | #createHttpsServer(url, ipVersion) { | ||
| 194 | return new Promise((resolve, reject) => { | ||
| 195 | const sslOptions = { | ||
| 196 | cert: fs.readFileSync(this.cliArgs.certPath), | ||
| 197 | key: fs.readFileSync(this.cliArgs.keyPath), | ||
| 198 | }; | ||
| 199 | const server = https.createServer(sslOptions, this.app); | ||
| 200 | server.on('error', reject); | ||
| 201 | server.on('listening', resolve); | ||
| 202 | |||
| 203 | let host = url.hostname; | ||
| 204 | if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); | ||
| 205 | server.listen({ | ||
| 206 | host: host, | ||
| 207 | port: Number(url.port || 443), | ||
| 208 | // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used | ||
| 209 | ipv6Only: true, | ||
| 210 | }); | ||
| 211 | }); | ||
| 212 | } | ||
| 213 | |||
| 214 | /** | ||
| 215 | * Creates an HTTP server. | ||
| 216 | * @param {URL} url The URL to listen on | ||
| 217 | * @param {number} ipVersion the ip version to use | ||
| 218 | * @returns {Promise<void>} A promise that resolves when the server is listening | ||
| 219 | * @throws {Error} If the server fails to start | ||
| 220 | */ | ||
| 221 | #createHttpServer(url, ipVersion) { | ||
| 222 | return new Promise((resolve, reject) => { | ||
| 223 | const server = http.createServer(this.app); | ||
| 224 | server.on('error', reject); | ||
| 225 | server.on('listening', resolve); | ||
| 226 | |||
| 227 | let host = url.hostname; | ||
| 228 | if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); | ||
| 229 | server.listen({ | ||
| 230 | host: host, | ||
| 231 | port: Number(url.port || 80), | ||
| 232 | // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used | ||
| 233 | ipv6Only: true, | ||
| 234 | }); | ||
| 235 | }); | ||
| 236 | } | ||
| 237 | |||
| 238 | /** | ||
| 239 | * Starts the server using http or https depending on config | ||
| 240 | * @param {boolean} useIPv6 If use IPv6 | ||
| 241 | * @param {boolean} useIPv4 If use IPv4 | ||
| 242 | * @returns {Promise<[boolean, boolean]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively | ||
| 243 | */ | ||
| 244 | async #startHTTPorHTTPS(useIPv6, useIPv4) { | ||
| 245 | let v6Failed = false; | ||
| 246 | let v4Failed = false; | ||
| 247 | |||
| 248 | const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this); | ||
| 249 | |||
| 250 | if (useIPv6) { | ||
| 251 | try { | ||
| 252 | await createFunc(this.cliArgs.getIPv6ListenUrl(), 6); | ||
| 253 | } catch (error) { | ||
| 254 | console.error('non-fatal error: failed to start server on IPv6'); | ||
| 255 | console.error(error); | ||
| 256 | |||
| 257 | v6Failed = true; | ||
| 258 | } | ||
| 259 | } | ||
| 260 | |||
| 261 | if (useIPv4) { | ||
| 262 | try { | ||
| 263 | await createFunc(this.cliArgs.getIPv4ListenUrl(), 4); | ||
| 264 | } catch (error) { | ||
| 265 | console.error('non-fatal error: failed to start server on IPv4'); | ||
| 266 | console.error(error); | ||
| 267 | |||
| 268 | v4Failed = true; | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | return [v6Failed, v4Failed]; | ||
| 273 | } | ||
| 274 | |||
| 275 | /** | ||
| 276 | * Handles the case where the server failed to start on one or both protocols. | ||
| 277 | * @param {boolean} v6Failed If the server failed to start on IPv6 | ||
| 278 | * @param {boolean} v4Failed If the server failed to start on IPv4 | ||
| 279 | * @param {boolean} useIPv6 If use IPv6 | ||
| 280 | * @param {boolean} useIPv4 If use IPv4 | ||
| 281 | * @returns {void} | ||
| 282 | */ | ||
| 283 | #handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) { | ||
| 284 | if (v6Failed && !useIPv4) { | ||
| 285 | console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled')); | ||
| 286 | process.exit(1); | ||
| 287 | } | ||
| 288 | |||
| 289 | if (v4Failed && !useIPv6) { | ||
| 290 | console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled')); | ||
| 291 | process.exit(1); | ||
| 292 | } | ||
| 293 | |||
| 294 | if (v6Failed && v4Failed) { | ||
| 295 | console.error(color.red('fatal error: Failed to start server on both IPv6 and IPv4')); | ||
| 296 | process.exit(1); | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | /** | ||
| 301 | * Performs the server startup. | ||
| 302 | * @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup | ||
| 303 | */ | ||
| 304 | async start() { | ||
| 305 | let useIPv6 = (this.cliArgs.enableIPv6 === true); | ||
| 306 | let useIPv4 = (this.cliArgs.enableIPv4 === true); | ||
| 307 | |||
| 308 | let hasIPv6 = false, | ||
| 309 | hasIPv4 = false, | ||
| 310 | hasIPv6Local = false, | ||
| 311 | hasIPv4Local = false, | ||
| 312 | hasIPv6Any = false, | ||
| 313 | hasIPv4Any = false; | ||
| 314 | |||
| 315 | if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') { | ||
| 316 | [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP(); | ||
| 317 | |||
| 318 | hasIPv6 = this.cliArgs.listen ? hasIPv6Any : hasIPv6Local; | ||
| 319 | if (this.cliArgs.enableIPv6 === 'auto') { | ||
| 320 | useIPv6 = hasIPv6; | ||
| 321 | } | ||
| 322 | if (hasIPv6) { | ||
| 323 | if (useIPv6) { | ||
| 324 | console.log(color.green('IPv6 support detected')); | ||
| 325 | } else { | ||
| 326 | console.log('IPv6 support detected (but disabled)'); | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | hasIPv4 = this.cliArgs.listen ? hasIPv4Any : hasIPv4Local; | ||
| 331 | if (this.cliArgs.enableIPv4 === 'auto') { | ||
| 332 | useIPv4 = hasIPv4; | ||
| 333 | } | ||
| 334 | if (hasIPv4) { | ||
| 335 | if (useIPv4) { | ||
| 336 | console.log(color.green('IPv4 support detected')); | ||
| 337 | } else { | ||
| 338 | console.log('IPv4 support detected (but disabled)'); | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') { | ||
| 343 | if (!hasIPv6 && !hasIPv4) { | ||
| 344 | console.error('Both IPv6 and IPv4 are not detected'); | ||
| 345 | process.exit(1); | ||
| 346 | } | ||
| 347 | } | ||
| 348 | } | ||
| 349 | |||
| 350 | if (!useIPv6 && !useIPv4) { | ||
| 351 | console.error('Both IPv6 and IPv4 are disabled'); | ||
| 352 | process.exit(1); | ||
| 353 | } | ||
| 354 | |||
| 355 | const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4); | ||
| 356 | this.#handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4); | ||
| 357 | return { v6Failed, v4Failed, useIPv6, useIPv4 }; | ||
| 358 | } | ||
| 359 | } | ||
| @@ -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'; |
| @@ -120,6 +120,27 @@ export async function ensurePublicDirectoriesExist() { | |||
| 120 | return directoriesList; | 120 | return directoriesList; |
| 121 | } | 121 | } |
| 122 | 122 | ||
| 123 | export function cleanUploads() { | ||
| 124 | try { | ||
| 125 | const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY); | ||
| 126 | if (fs.existsSync(uploadsPath)) { | ||
| 127 | const uploads = fs.readdirSync(uploadsPath); | ||
| 128 | |||
| 129 | if (!uploads.length) { | ||
| 130 | return; | ||
| 131 | } | ||
| 132 | |||
| 133 | console.debug(`Cleaning uploads folder (${uploads.length} files)`); | ||
| 134 | uploads.forEach(file => { | ||
| 135 | const pathToFile = path.join(uploadsPath, file); | ||
| 136 | fs.unlinkSync(pathToFile); | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | } catch (err) { | ||
| 140 | console.error(err); | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 123 | /** | 144 | /** |
| 124 | * Gets a list of all user directories. | 145 | * Gets a list of all user directories. |
| 125 | * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories | 146 | * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories |
| @@ -478,6 +499,25 @@ export function getCookieSessionName() { | |||
| 478 | return `session-${suffix}`; | 499 | return `session-${suffix}`; |
| 479 | } | 500 | } |
| 480 | 501 | ||
| 502 | export function getSessionCookieAge() { | ||
| 503 | // Defaults to "no expiration" if not set | ||
| 504 | const configValue = getConfigValue('sessionTimeout', -1, 'number'); | ||
| 505 | |||
| 506 | // Convert to milliseconds | ||
| 507 | if (configValue > 0) { | ||
| 508 | return configValue * 1000; | ||
| 509 | } | ||
| 510 | |||
| 511 | // "No expiration" is just 400 days as per RFC 6265 | ||
| 512 | if (configValue < 0) { | ||
| 513 | return 400 * 24 * 60 * 60 * 1000; | ||
| 514 | } | ||
| 515 | |||
| 516 | // 0 means session cookie is deleted when the browser session ends | ||
| 517 | // (depends on the implementation of the browser) | ||
| 518 | return undefined; | ||
| 519 | } | ||
| 520 | |||
| 481 | /** | 521 | /** |
| 482 | * Hashes a password using scrypt with the provided salt. | 522 | * Hashes a password using scrypt with the provided salt. |
| 483 | * @param {string} password Password to hash | 523 | * @param {string} password Password to hash |
| @@ -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 '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,56 @@ 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 | * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing: | ||
| 778 | * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`). | ||
| 779 | * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`). | ||
| 780 | * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`). | ||
| 781 | * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`). | ||
| 782 | */ | ||
| 783 | export async function getHasIP() { | ||
| 784 | let hasIPv6 = false; | ||
| 785 | let hasIPv6Local = false; | ||
| 786 | |||
| 787 | let hasIPv4 = false; | ||
| 788 | let hasIPv4Local = false; | ||
| 789 | |||
| 790 | const interfaces = os.networkInterfaces(); | ||
| 791 | |||
| 792 | for (const iface of Object.values(interfaces)) { | ||
| 793 | if (iface === undefined) { | ||
| 794 | continue; | ||
| 795 | } | ||
| 796 | |||
| 797 | for (const info of iface) { | ||
| 798 | if (info.family === 'IPv6') { | ||
| 799 | hasIPv6 = true; | ||
| 800 | if (info.address === '::1') { | ||
| 801 | hasIPv6Local = true; | ||
| 802 | } | ||
| 803 | } | ||
| 804 | |||
| 805 | if (info.family === 'IPv4') { | ||
| 806 | hasIPv4 = true; | ||
| 807 | if (info.address === '127.0.0.1') { | ||
| 808 | hasIPv4Local = true; | ||
| 809 | } | ||
| 810 | } | ||
| 811 | if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break; | ||
| 812 | } | ||
| 813 | if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break; | ||
| 814 | } | ||
| 815 | return [ | ||
| 816 | hasIPv6, | ||
| 817 | hasIPv4, | ||
| 818 | hasIPv6Local, | ||
| 819 | hasIPv4Local, | ||
| 820 | ]; | ||
| 821 | } | ||
| 822 | |||
| 823 | |||
| 824 | /** | ||
| 774 | * Converts various JavaScript primitives to boolean values. | 825 | * Converts various JavaScript primitives to boolean values. |
| 775 | * Handles special case for "true"/"false" strings (case-insensitive) | 826 | * Handles special case for "true"/"false" strings (case-insensitive) |
| 776 | * | 827 | * |
| @@ -1005,3 +1056,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { | |||
| 1005 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); | 1056 | if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); |
| 1006 | return null; | 1057 | return null; |
| 1007 | } | 1058 | } |
| 1059 | |||
| 1060 | /** | ||
| 1061 | * Set the title of the terminal window | ||
| 1062 | * @param {string} title Desired title for the window | ||
| 1063 | */ | ||
| 1064 | export function setWindowTitle(title) { | ||
| 1065 | if (process.platform === 'win32') { | ||
| 1066 | process.title = title; | ||
| 1067 | } | ||
| 1068 | else { | ||
| 1069 | process.stdout.write(`\x1b]2;${title}\x1b\x5c`); | ||
| 1070 | } | ||
| 1071 | } | ||