| 1 | // native node modules |
| 2 | import fs from 'node:fs'; |
| 3 | import path from 'node:path'; |
| 4 | import util from 'node:util'; |
| 5 | import net from 'node:net'; |
| 6 | import dns from 'node:dns'; |
| 7 | import process from 'node:process'; |
| 8 | import http from 'node:http'; |
| 9 | import https from 'node:https'; |
| 10 | |
| 11 | import cors from 'cors'; |
| 12 | import { csrfSync } from 'csrf-sync'; |
| 13 | import express from 'express'; |
| 14 | import compression from 'compression'; |
| 15 | import cookieSession from 'cookie-session'; |
| 16 | import multer from 'multer'; |
| 17 | import responseTime from 'response-time'; |
| 18 | import helmet from 'helmet'; |
| 19 | import bodyParser from 'body-parser'; |
| 20 | |
| 21 | // local library imports |
| 22 | import './fetch-patch.js'; |
| 23 | import { serverDirectory } from './server-directory.js'; |
| 24 | |
| 25 | import { serverEvents, EVENT_NAMES } from './server-events.js'; |
| 26 | import { loadPlugins } from './plugin-loader.js'; |
| 27 | import { |
| 28 | initUserStorage, |
| 29 | getCookieSecret, |
| 30 | getCookieSessionName, |
| 31 | ensurePublicDirectoriesExist, |
| 32 | getUserDirectoriesList, |
| 33 | migrateSystemPrompts, |
| 34 | migrateUserData, |
| 35 | requireLoginMiddleware, |
| 36 | setUserDataMiddleware, |
| 37 | shouldRedirectToLogin, |
| 38 | cleanUploads, |
| 39 | getSessionCookieAge, |
| 40 | verifySecuritySettings, |
| 41 | loginPageMiddleware, |
| 42 | migratePublicOverrides, |
| 43 | } from './users.js'; |
| 44 | |
| 45 | import getWebpackServeMiddleware from './middleware/webpack-serve.js'; |
| 46 | import basicAuthMiddleware from './middleware/basicAuth.js'; |
| 47 | import getWhitelistMiddleware from './middleware/whitelist.js'; |
| 48 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; |
| 49 | import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; |
| 50 | import initRequestProxy from './request-proxy.js'; |
| 51 | import initPrivateRequestFilter from './private-request-filter.js'; |
| 52 | import cacheBuster from './middleware/cacheBuster.js'; |
| 53 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 54 | import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; |
| 55 | import userCssMiddleware from './middleware/userCss.js'; |
| 56 | import { |
| 57 | getVersion, |
| 58 | color, |
| 59 | removeColorFormatting, |
| 60 | getSeparator, |
| 61 | safeReadFileSync, |
| 62 | setupLogLevel, |
| 63 | setWindowTitle, |
| 64 | getConfigValue, |
| 65 | } from './util.js'; |
| 66 | import { UPLOADS_DIRECTORY } from './constants.js'; |
| 67 | |
| 68 | // Routers |
| 69 | import { router as usersPublicRouter } from './endpoints/users-public.js'; |
| 70 | import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js'; |
| 71 | import { checkForNewContent } from './endpoints/content-manager.js'; |
| 72 | import { init as settingsInit } from './endpoints/settings.js'; |
| 73 | import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js'; |
| 74 | import { diskCache } from './endpoints/characters.js'; |
| 75 | import { migrateFlatSecrets } from './endpoints/secrets.js'; |
| 76 | import { migrateGroupChatsMetadataFormat } from './endpoints/groups.js'; |
| 77 | |
| 78 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. |
| 79 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 |
| 80 | // Safe to remove once support for Node v20 is dropped. |
| 81 | if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) { |
| 82 | // @ts-ignore |
| 83 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); |
| 84 | } |
| 85 | |
| 86 | // Unrestrict console logs display limit |
| 87 | util.inspect.defaultOptions.maxArrayLength = null; |
| 88 | util.inspect.defaultOptions.maxStringLength = null; |
| 89 | util.inspect.defaultOptions.depth = 4; |
| 90 | |
| 91 | /** @type {import('./command-line.js').CommandLineArguments} */ |
| 92 | const cliArgs = globalThis.COMMAND_LINE_ARGS; |
| 93 | |
| 94 | if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { |
| 95 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); |
| 96 | process.exit(1); |
| 97 | } |
| 98 | |
| 99 | // Set keep-alive preference for all HTTP/HTTPS requests. |
| 100 | http.globalAgent = new http.Agent({ keepAlive: cliArgs.enableKeepAlive }); |
| 101 | https.globalAgent = new https.Agent({ keepAlive: cliArgs.enableKeepAlive }); |
| 102 | |
| 103 | const app = express(); |
| 104 | app.use(helmet({ |
| 105 | contentSecurityPolicy: false, |
| 106 | })); |
| 107 | app.use(compression()); |
| 108 | app.use(responseTime()); |
| 109 | |
| 110 | app.use(bodyParser.json({ limit: '500mb' })); |
| 111 | app.use(bodyParser.urlencoded({ extended: true, limit: '500mb' })); |
| 112 | |
| 113 | // CORS Settings // |
| 114 | const corsEnabled = getConfigValue('cors.enabled', true, 'boolean'); |
| 115 | if (corsEnabled) { |
| 116 | const corsOrigin = getConfigValue('cors.origin', 'null'); |
| 117 | const corsMethods = getConfigValue('cors.methods', ['OPTIONS']); |
| 118 | const corsAllowedHeaders = getConfigValue('cors.allowedHeaders', []); |
| 119 | const corsExposedHeaders = getConfigValue('cors.exposedHeaders', []); |
| 120 | const corsCredentials = getConfigValue('cors.credentials', false, 'boolean'); |
| 121 | const corsMaxAge = getConfigValue('cors.maxAge', null, 'number'); |
| 122 | |
| 123 | /** @type {cors.CorsOptions} */ |
| 124 | const corsOptions = { |
| 125 | origin: corsOrigin, |
| 126 | methods: corsMethods, |
| 127 | credentials: corsCredentials, |
| 128 | }; |
| 129 | if (Array.isArray(corsAllowedHeaders) && corsAllowedHeaders.length > 0) { |
| 130 | corsOptions.allowedHeaders = corsAllowedHeaders; |
| 131 | } |
| 132 | if (Array.isArray(corsExposedHeaders) && corsExposedHeaders.length > 0) { |
| 133 | corsOptions.exposedHeaders = corsExposedHeaders; |
| 134 | } |
| 135 | if (corsMaxAge !== null && Number.isInteger(corsMaxAge)) { |
| 136 | corsOptions.maxAge = corsMaxAge; |
| 137 | } |
| 138 | app.use(cors(corsOptions)); |
| 139 | } |
| 140 | |
| 141 | if (cliArgs.listen && cliArgs.basicAuthMode) { |
| 142 | app.use(basicAuthMiddleware); |
| 143 | } |
| 144 | |
| 145 | if (cliArgs.whitelistMode) { |
| 146 | const whitelistMiddleware = await getWhitelistMiddleware(); |
| 147 | app.use(whitelistMiddleware); |
| 148 | } |
| 149 | |
| 150 | app.use(hostWhitelistMiddleware); |
| 151 | |
| 152 | if (cliArgs.listen) { |
| 153 | app.use(accessLoggerMiddleware()); |
| 154 | } |
| 155 | |
| 156 | app.use(cookieSession({ |
| 157 | name: getCookieSessionName(), |
| 158 | sameSite: 'lax', |
| 159 | httpOnly: true, |
| 160 | maxAge: getSessionCookieAge(), |
| 161 | secret: getCookieSecret(globalThis.DATA_ROOT), |
| 162 | })); |
| 163 | |
| 164 | app.use(setUserDataMiddleware); |
| 165 | |
| 166 | // CSRF Protection // |
| 167 | if (!cliArgs.disableCsrf) { |
| 168 | const csrfSyncProtection = csrfSync({ |
| 169 | getTokenFromState: (req) => { |
| 170 | if (!req.session) { |
| 171 | console.error('(CSRF error) getTokenFromState: Session object not initialized'); |
| 172 | return; |
| 173 | } |
| 174 | return req.session.csrfToken; |
| 175 | }, |
| 176 | getTokenFromRequest: (req) => { |
| 177 | return req.headers['x-csrf-token']?.toString(); |
| 178 | }, |
| 179 | storeTokenInState: (req, token) => { |
| 180 | if (!req.session) { |
| 181 | console.error('(CSRF error) storeTokenInState: Session object not initialized'); |
| 182 | return; |
| 183 | } |
| 184 | req.session.csrfToken = token; |
| 185 | }, |
| 186 | skipCsrfProtection: (req) => { |
| 187 | return cliArgs.enableCorsProxy ? /^\/proxy\//.test(req.path) : false; |
| 188 | }, |
| 189 | size: 32, |
| 190 | }); |
| 191 | |
| 192 | app.get('/csrf-token', (req, res) => { |
| 193 | res.json({ |
| 194 | 'token': csrfSyncProtection.generateToken(req), |
| 195 | }); |
| 196 | }); |
| 197 | |
| 198 | // Customize the error message |
| 199 | csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); |
| 200 | csrfSyncProtection.invalidCsrfTokenError.stack = undefined; |
| 201 | |
| 202 | app.use(csrfSyncProtection.csrfSynchronisedProtection); |
| 203 | } else { |
| 204 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); |
| 205 | app.get('/csrf-token', (req, res) => { |
| 206 | res.json({ |
| 207 | 'token': 'disabled', |
| 208 | }); |
| 209 | }); |
| 210 | } |
| 211 | |
| 212 | // Static files |
| 213 | // Host index page |
| 214 | app.get('/', cacheBuster.middleware, (request, response) => { |
| 215 | if (shouldRedirectToLogin(request)) { |
| 216 | const query = request.url.split('?')[1]; |
| 217 | const redirectUrl = query ? `/login?${query}` : '/login'; |
| 218 | return response.redirect(redirectUrl); |
| 219 | } |
| 220 | |
| 221 | return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') }); |
| 222 | }); |
| 223 | |
| 224 | // Callback endpoint for OAuth PKCE flows (e.g. OpenRouter) |
| 225 | app.get('/callback/:source?', (request, response) => { |
| 226 | const source = request.params.source; |
| 227 | const query = request.url.split('?')[1]; |
| 228 | const searchParams = new URLSearchParams(); |
| 229 | source && searchParams.set('source', source); |
| 230 | query && searchParams.set('query', query); |
| 231 | const path = `/?${searchParams.toString()}`; |
| 232 | return response.redirect(307, path); |
| 233 | }); |
| 234 | |
| 235 | // Host login page |
| 236 | app.get('/login', loginPageMiddleware); |
| 237 | |
| 238 | // Host frontend assets |
| 239 | const webpackMiddleware = getWebpackServeMiddleware(); |
| 240 | app.use(webpackMiddleware); |
| 241 | app.use(userCssMiddleware); |
| 242 | app.use(express.static(path.join(serverDirectory, 'public'), {})); |
| 243 | |
| 244 | // Public API |
| 245 | app.use('/api/users', usersPublicRouter); |
| 246 | |
| 247 | // Everything below this line requires authentication |
| 248 | app.use(requireLoginMiddleware); |
| 249 | app.post('/api/ping', (request, response) => { |
| 250 | if (request.query.extend && request.session) { |
| 251 | request.session.touch = Date.now(); |
| 252 | } |
| 253 | |
| 254 | response.sendStatus(204); |
| 255 | }); |
| 256 | |
| 257 | if (cliArgs.enableCorsProxy) { |
| 258 | app.use('/proxy/:url(*)', corsProxyMiddleware); |
| 259 | } else { |
| 260 | app.use('/proxy/:url(*)', async (_, res) => { |
| 261 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; |
| 262 | console.log(message); |
| 263 | res.status(404).send(message); |
| 264 | }); |
| 265 | } |
| 266 | |
| 267 | // File uploads |
| 268 | const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); |
| 269 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 500 * 1024 * 1024 } }).single('avatar')); |
| 270 | app.use(multerMonkeyPatch); |
| 271 | |
| 272 | app.get('/version', async function (_, response) { |
| 273 | const data = await getVersion(); |
| 274 | response.send(data); |
| 275 | }); |
| 276 | |
| 277 | redirectDeprecatedEndpoints(app); |
| 278 | setupPrivateEndpoints(app); |
| 279 | |
| 280 | /** |
| 281 | * Tasks that need to be run before the server starts listening. |
| 282 | * @returns {Promise<void>} |
| 283 | */ |
| 284 | async function preSetupTasks() { |
| 285 | const version = await getVersion(); |
| 286 | |
| 287 | // Print formatted header |
| 288 | console.log(); |
| 289 | console.log(`SillyTavern ${version.pkgVersion}`); |
| 290 | if (version.gitBranch && version.commitDate) { |
| 291 | const date = new Date(version.commitDate); |
| 292 | const localDate = date.toLocaleString('en-US', { timeZoneName: 'short' }); |
| 293 | console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${localDate}`); |
| 294 | if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) { |
| 295 | console.log('INFO: Currently not on the latest commit.'); |
| 296 | console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.'); |
| 297 | } |
| 298 | } |
| 299 | console.log(); |
| 300 | |
| 301 | const directories = await getUserDirectoriesList(); |
| 302 | await migrateGroupChatsMetadataFormat(directories); |
| 303 | await checkForNewContent(directories); |
| 304 | await diskCache.verify(directories); |
| 305 | migrateFlatSecrets(directories); |
| 306 | cleanUploads(); |
| 307 | migrateAccessLog(); |
| 308 | |
| 309 | await settingsInit(); |
| 310 | await statsInit(); |
| 311 | |
| 312 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); |
| 313 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); |
| 314 | const consoleTitle = process.title; |
| 315 | |
| 316 | let isExiting = false; |
| 317 | const exitProcess = async () => { |
| 318 | if (isExiting) return; |
| 319 | isExiting = true; |
| 320 | await statsOnExit(); |
| 321 | if (typeof cleanupPlugins === 'function') { |
| 322 | await cleanupPlugins(); |
| 323 | } |
| 324 | diskCache.dispose(); |
| 325 | setWindowTitle(consoleTitle); |
| 326 | process.exit(); |
| 327 | }; |
| 328 | |
| 329 | // Set up event listeners for a graceful shutdown |
| 330 | process.on('SIGINT', exitProcess); |
| 331 | process.on('SIGTERM', exitProcess); |
| 332 | process.on('uncaughtException', (err) => { |
| 333 | console.error('Uncaught exception:', err); |
| 334 | exitProcess(); |
| 335 | }); |
| 336 | |
| 337 | // Add private request filter. |
| 338 | const requestFilterOptions = { |
| 339 | listen: cliArgs.listen, |
| 340 | enabled: !!getConfigValue('privateAddressWhitelist.enabled', false, 'boolean'), |
| 341 | privateAddressWhitelist: getConfigValue('privateAddressWhitelist.allowedRanges', ['127.0.0.0/8', '::1/128']), |
| 342 | logBlocked: !!getConfigValue('privateAddressWhitelist.log.blockedRequests', true, 'boolean'), |
| 343 | logAllowed: !!getConfigValue('privateAddressWhitelist.log.allowedRequests', false, 'boolean'), |
| 344 | allowUnresolvedHosts: !!getConfigValue('privateAddressWhitelist.allowUnresolvedHosts', false, 'boolean'), |
| 345 | enableKeepAlive: cliArgs.enableKeepAlive, |
| 346 | }; |
| 347 | initPrivateRequestFilter(requestFilterOptions); |
| 348 | |
| 349 | // Add request proxy. |
| 350 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive, privateRequestFilterEnabled: requestFilterOptions.enabled }); |
| 351 | |
| 352 | // Wait for frontend libs to compile |
| 353 | await webpackMiddleware.runWebpackCompiler({ pruneCache: true }); |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Tasks that need to be run after the server starts listening. |
| 358 | * @param {import('./server-startup.js').ServerStartupResult} result The result of the server startup |
| 359 | * @returns {Promise<void>} |
| 360 | */ |
| 361 | async function postSetupTasks(result) { |
| 362 | const browserLaunchHostname = await cliArgs.getBrowserLaunchHostname(result); |
| 363 | const browserLaunchUrl = cliArgs.getBrowserLaunchUrl(browserLaunchHostname); |
| 364 | const browserLaunchApp = String(getConfigValue('browserLaunch.browser', 'default') ?? ''); |
| 365 | |
| 366 | if (cliArgs.browserLaunchEnabled) { |
| 367 | try { |
| 368 | // TODO: This should be converted to a regular import when support for Node 18 is dropped |
| 369 | const openModule = await import('open'); |
| 370 | const { default: open, apps } = openModule; |
| 371 | |
| 372 | function getBrowsers() { |
| 373 | const isAndroid = process.platform === 'android'; |
| 374 | if (isAndroid) { |
| 375 | return {}; |
| 376 | } |
| 377 | return { |
| 378 | 'firefox': apps.firefox, |
| 379 | 'chrome': apps.chrome, |
| 380 | 'edge': apps.edge, |
| 381 | 'brave': apps.brave, |
| 382 | }; |
| 383 | } |
| 384 | |
| 385 | const validBrowsers = getBrowsers(); |
| 386 | const appName = validBrowsers[browserLaunchApp.trim().toLowerCase()]; |
| 387 | const openOptions = appName ? { app: { name: appName } } : {}; |
| 388 | |
| 389 | console.log(`Launching in a browser: ${browserLaunchApp}...`); |
| 390 | await open(browserLaunchUrl.toString(), openOptions); |
| 391 | } catch (error) { |
| 392 | console.error('Failed to launch the browser. Open the URL manually.', error); |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | if (cliArgs.heartbeatInterval > 0) { |
| 397 | // Convert seconds to milliseconds for the timer |
| 398 | const intervalMs = cliArgs.heartbeatInterval * 1000; |
| 399 | const heartbeatPath = path.join(globalThis.DATA_ROOT, 'heartbeat.json'); |
| 400 | |
| 401 | console.log(`Heartbeat enabled. Updating ${color.green(heartbeatPath)} every ${cliArgs.heartbeatInterval} seconds`); |
| 402 | |
| 403 | const writeHeartbeat = () => { |
| 404 | try { |
| 405 | fs.writeFileSync(heartbeatPath, JSON.stringify({ timestamp: Date.now() })); |
| 406 | } catch (err) { |
| 407 | console.error(`Failed to write heartbeat file at ${color.green(heartbeatPath)}:`, err.message); |
| 408 | } |
| 409 | }; |
| 410 | |
| 411 | // Write immediately |
| 412 | writeHeartbeat(); |
| 413 | |
| 414 | // Loop using the converted milliseconds |
| 415 | setInterval(writeHeartbeat, intervalMs).unref(); |
| 416 | } |
| 417 | |
| 418 | setWindowTitle('SillyTavern WebServer'); |
| 419 | |
| 420 | let logListen = 'SillyTavern is listening on'; |
| 421 | |
| 422 | if (result.useIPv6 && !result.v6Failed) { |
| 423 | logListen += color.green( |
| 424 | ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, |
| 425 | ); |
| 426 | } |
| 427 | |
| 428 | if (result.useIPv4 && !result.v4Failed) { |
| 429 | logListen += color.green( |
| 430 | ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, |
| 431 | ); |
| 432 | } |
| 433 | |
| 434 | const goToLog = `Go to: ${color.blue(browserLaunchUrl)} to open SillyTavern`; |
| 435 | const plainGoToLog = removeColorFormatting(goToLog); |
| 436 | |
| 437 | console.log(logListen); |
| 438 | if (cliArgs.listen) { |
| 439 | console.log(); |
| 440 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); |
| 441 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); |
| 442 | } |
| 443 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); |
| 444 | console.log(goToLog); |
| 445 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); |
| 446 | |
| 447 | setupLogLevel(); |
| 448 | serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: browserLaunchUrl }); |
| 449 | } |
| 450 | |
| 451 | /** |
| 452 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. |
| 453 | */ |
| 454 | function apply404Middleware() { |
| 455 | const notFoundWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'url-not-found.html')) ?? ''; |
| 456 | app.use((req, res) => { |
| 457 | res.status(404).send(notFoundWebpage); |
| 458 | }); |
| 459 | } |
| 460 | |
| 461 | /** |
| 462 | * Sets the DNS resolution order based on the command line arguments. |
| 463 | */ |
| 464 | function setDnsResolutionOrder() { |
| 465 | try { |
| 466 | if (cliArgs.dnsPreferIPv6) { |
| 467 | dns.setDefaultResultOrder('ipv6first'); |
| 468 | console.log('Preferring IPv6 for DNS resolution'); |
| 469 | } else { |
| 470 | dns.setDefaultResultOrder('ipv4first'); |
| 471 | console.log('Preferring IPv4 for DNS resolution'); |
| 472 | } |
| 473 | } catch (error) { |
| 474 | console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | // User storage module needs to be initialized before starting the server |
| 479 | initUserStorage(globalThis.DATA_ROOT) |
| 480 | .then(setDnsResolutionOrder) |
| 481 | .then(ensurePublicDirectoriesExist) |
| 482 | .then(migrateUserData) |
| 483 | .then(migrateSystemPrompts) |
| 484 | .then(migratePublicOverrides) |
| 485 | .then(verifySecuritySettings) |
| 486 | .then(preSetupTasks) |
| 487 | .then(apply404Middleware) |
| 488 | .then(() => new ServerStartup(app, cliArgs).start()) |
| 489 | .then(postSetupTasks); |