| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | | | |
| 3 | // native node modules | | |
| 4 | import path from 'node:path'; | | |
| 5 | import util from 'node:util'; | | |
| 6 | import net from 'node:net'; | | |
| 7 | import dns from 'node:dns'; | | |
| 8 | import process from 'node:process'; | | |
| 9 | | | |
| 10 | import cors from 'cors'; | | |
| 11 | import { csrfSync } from 'csrf-sync'; | | |
| 12 | import express from 'express'; | | |
| 13 | import compression from 'compression'; | | |
| 14 | import cookieSession from 'cookie-session'; | | |
| 15 | import multer from 'multer'; | | |
| 16 | import responseTime from 'response-time'; | | |
| 17 | import helmet from 'helmet'; | | |
| 18 | import bodyParser from 'body-parser'; | | |
| 19 | import open from 'open'; | | |
| 20 | | | |
| 21 | // local library imports | | |
| 22 | import './src/fetch-patch.js'; | | |
| 23 | import { CommandLineParser } from './src/command-line.js'; | 2 | import { CommandLineParser } from './src/command-line.js'; |
| 24 | import { serverDirectory } from './src/server-directory.js'; | 3 | import { serverDirectory } from './src/server-directory.js'; |
| 25 | | 4 | |
| 26 | console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); | | |
| 27 | | | |
| 28 | // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. | | |
| 29 | // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 | | |
| 30 | // Safe to remove once support for Node v20 is dropped. | | |
| 31 | if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) { | | |
| 32 | // @ts-ignore | | |
| 33 | if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); | | |
| 34 | } | | |
| 35 | | | |
| 36 | // config.yaml will be set when parsing command line arguments | 5 | // config.yaml will be set when parsing command line arguments |
| 37 | const cliArgs = new CommandLineParser().parse(process.argv); | 6 | const cliArgs = new CommandLineParser().parse(process.argv); |
| 38 | globalThis.DATA_ROOT = cliArgs.dataRoot; | 7 | globalThis.DATA_ROOT = cliArgs.dataRoot; |
| 39 | globalThis.COMMAND_LINE_ARGS = cliArgs; | 8 | globalThis.COMMAND_LINE_ARGS = cliArgs; |
| 40 | process.chdir(serverDirectory); | 9 | process.chdir(serverDirectory); |
| 41 | | 10 | |
| 42 | const { serverEvents, EVENT_NAMES } = await import('./src/server-events.js'); | | |
| 43 | const { loadPlugins } = await import('./src/plugin-loader.js'); | | |
| 44 | const { | | |
| 45 | initUserStorage, | | |
| 46 | getCookieSecret, | | |
| 47 | getCookieSessionName, | | |
| 48 | ensurePublicDirectoriesExist, | | |
| 49 | getUserDirectoriesList, | | |
| 50 | migrateSystemPrompts, | | |
| 51 | migrateUserData, | | |
| 52 | requireLoginMiddleware, | | |
| 53 | setUserDataMiddleware, | | |
| 54 | shouldRedirectToLogin, | | |
| 55 | cleanUploads, | | |
| 56 | getSessionCookieAge, | | |
| 57 | verifySecuritySettings, | | |
| 58 | loginPageMiddleware, | | |
| 59 | } = await import('./src/users.js'); | | |
| 60 | | | |
| 61 | const { default: getWebpackServeMiddleware } = await import('./src/middleware/webpack-serve.js'); | | |
| 62 | const { default: basicAuthMiddleware } = await import('./src/middleware/basicAuth.js'); | | |
| 63 | const { default: getWhitelistMiddleware } = await import('./src/middleware/whitelist.js'); | | |
| 64 | const { default: accessLoggerMiddleware, getAccessLogPath, migrateAccessLog } = await import('./src/middleware/accessLogWriter.js'); | | |
| 65 | const { default: multerMonkeyPatch } = await import('./src/middleware/multerMonkeyPatch.js'); | | |
| 66 | const { default: initRequestProxy } = await import('./src/request-proxy.js'); | | |
| 67 | const { default: getCacheBusterMiddleware } = await import('./src/middleware/cacheBuster.js'); | | |
| 68 | const { default: corsProxyMiddleware } = await import('./src/middleware/corsProxy.js'); | | |
| 69 | const { | | |
| 70 | getVersion, | | |
| 71 | color, | | |
| 72 | removeColorFormatting, | | |
| 73 | getSeparator, | | |
| 74 | safeReadFileSync, | | |
| 75 | setupLogLevel, | | |
| 76 | setWindowTitle, | | |
| 77 | } = await import('./src/util.js'); | | |
| 78 | const { UPLOADS_DIRECTORY } = await import('./src/constants.js'); | | |
| 79 | const { ensureThumbnailCache } = await import('./src/endpoints/thumbnails.js'); | | |
| 80 | | | |
| 81 | // Routers | | |
| 82 | const { router : usersPublicRouter } = await import('./src/endpoints/users-public.js'); | | |
| 83 | const { init : statsInit, onExit : statsOnExit } = await import('./src/endpoints/stats.js'); | | |
| 84 | const { checkForNewContent } = await import('./src/endpoints/content-manager.js'); | | |
| 85 | const { init : settingsInit } = await import('./src/endpoints/settings.js'); | | |
| 86 | const { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } = await import('./src/server-startup.js'); | | |
| 87 | const { diskCache } = await import('./src/endpoints/characters.js'); | | |
| 88 | | | |
| 89 | // Unrestrict console logs display limit | | |
| 90 | util.inspect.defaultOptions.maxArrayLength = null; | | |
| 91 | util.inspect.defaultOptions.maxStringLength = null; | | |
| 92 | util.inspect.defaultOptions.depth = 4; | | |
| 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 | try { | 11 | try { |
| 100 | if (cliArgs.dnsPreferIPv6) { | 12 | await import('./src/server-main.js'); |
| 101 | dns.setDefaultResultOrder('ipv6first'); | | |
| 102 | console.log('Preferring IPv6 for DNS resolution'); | | |
| 103 | } else { | | |
| 104 | dns.setDefaultResultOrder('ipv4first'); | | |
| 105 | console.log('Preferring IPv4 for DNS resolution'); | | |
| 106 | } | | |
| 107 | } catch (error) { | 13 | } catch (error) { |
| 108 | console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); | 14 | console.error('A critical error has occurred while starting the server:', error); |
| 109 | } | 15 | } |
| 110 | | | |
| 111 | const app = express(); | | |
| 112 | app.use(helmet({ | | |
| 113 | contentSecurityPolicy: false, | | |
| 114 | })); | | |
| 115 | app.use(compression()); | | |
| 116 | app.use(responseTime()); | | |
| 117 | | | |
| 118 | app.use(bodyParser.json({ limit: '200mb' })); | | |
| 119 | app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' })); | | |
| 120 | | | |
| 121 | // CORS Settings // | | |
| 122 | const CORS = cors({ | | |
| 123 | origin: 'null', | | |
| 124 | methods: ['OPTIONS'], | | |
| 125 | }); | | |
| 126 | | | |
| 127 | app.use(CORS); | | |
| 128 | | | |
| 129 | if (cliArgs.listen && cliArgs.basicAuthMode) { | | |
| 130 | app.use(basicAuthMiddleware); | | |
| 131 | } | | |
| 132 | | | |
| 133 | if (cliArgs.whitelistMode) { | | |
| 134 | const whitelistMiddleware = await getWhitelistMiddleware(); | | |
| 135 | app.use(whitelistMiddleware); | | |
| 136 | } | | |
| 137 | | | |
| 138 | if (cliArgs.listen) { | | |
| 139 | app.use(accessLoggerMiddleware()); | | |
| 140 | } | | |
| 141 | | | |
| 142 | if (cliArgs.enableCorsProxy) { | | |
| 143 | app.use('/proxy/:url(*)', corsProxyMiddleware); | | |
| 144 | } else { | | |
| 145 | app.use('/proxy/:url(*)', async (_, res) => { | | |
| 146 | const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; | | |
| 147 | console.log(message); | | |
| 148 | res.status(404).send(message); | | |
| 149 | }); | | |
| 150 | } | | |
| 151 | | | |
| 152 | app.use(cookieSession({ | | |
| 153 | name: getCookieSessionName(), | | |
| 154 | sameSite: 'lax', | | |
| 155 | httpOnly: true, | | |
| 156 | maxAge: getSessionCookieAge(), | | |
| 157 | secret: getCookieSecret(globalThis.DATA_ROOT), | | |
| 158 | })); | | |
| 159 | | | |
| 160 | app.use(setUserDataMiddleware); | | |
| 161 | | | |
| 162 | // CSRF Protection // | | |
| 163 | if (!cliArgs.disableCsrf) { | | |
| 164 | const csrfSyncProtection = csrfSync({ | | |
| 165 | getTokenFromState: (req) => { | | |
| 166 | if (!req.session) { | | |
| 167 | console.error('(CSRF error) getTokenFromState: Session object not initialized'); | | |
| 168 | return; | | |
| 169 | } | | |
| 170 | return req.session.csrfToken; | | |
| 171 | }, | | |
| 172 | getTokenFromRequest: (req) => { | | |
| 173 | return req.headers['x-csrf-token']?.toString(); | | |
| 174 | }, | | |
| 175 | storeTokenInState: (req, token) => { | | |
| 176 | if (!req.session) { | | |
| 177 | console.error('(CSRF error) storeTokenInState: Session object not initialized'); | | |
| 178 | return; | | |
| 179 | } | | |
| 180 | req.session.csrfToken = token; | | |
| 181 | }, | | |
| 182 | size: 32, | | |
| 183 | }); | | |
| 184 | | | |
| 185 | app.get('/csrf-token', (req, res) => { | | |
| 186 | res.json({ | | |
| 187 | 'token': csrfSyncProtection.generateToken(req), | | |
| 188 | }); | | |
| 189 | }); | | |
| 190 | | | |
| 191 | // Customize the error message | | |
| 192 | csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); | | |
| 193 | csrfSyncProtection.invalidCsrfTokenError.stack = undefined; | | |
| 194 | | | |
| 195 | app.use(csrfSyncProtection.csrfSynchronisedProtection); | | |
| 196 | } else { | | |
| 197 | console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); | | |
| 198 | app.get('/csrf-token', (req, res) => { | | |
| 199 | res.json({ | | |
| 200 | 'token': 'disabled', | | |
| 201 | }); | | |
| 202 | }); | | |
| 203 | } | | |
| 204 | | | |
| 205 | // Static files | | |
| 206 | // Host index page | | |
| 207 | app.get('/', getCacheBusterMiddleware(), (request, response) => { | | |
| 208 | if (shouldRedirectToLogin(request)) { | | |
| 209 | const query = request.url.split('?')[1]; | | |
| 210 | const redirectUrl = query ? `/login?${query}` : '/login'; | | |
| 211 | return response.redirect(redirectUrl); | | |
| 212 | } | | |
| 213 | | | |
| 214 | return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') }); | | |
| 215 | }); | | |
| 216 | | | |
| 217 | // Callback endpoint for OAuth PKCE flows (e.g. OpenRouter) | | |
| 218 | app.get('/callback/:source?', (request, response) => { | | |
| 219 | const source = request.params.source; | | |
| 220 | const query = request.url.split('?')[1]; | | |
| 221 | const searchParams = new URLSearchParams(); | | |
| 222 | source && searchParams.set('source', source); | | |
| 223 | query && searchParams.set('query', query); | | |
| 224 | const path = `/?${searchParams.toString()}`; | | |
| 225 | return response.redirect(307, path); | | |
| 226 | }); | | |
| 227 | | | |
| 228 | // Host login page | | |
| 229 | app.get('/login', loginPageMiddleware); | | |
| 230 | | | |
| 231 | // Host frontend assets | | |
| 232 | const webpackMiddleware = getWebpackServeMiddleware(); | | |
| 233 | app.use(webpackMiddleware); | | |
| 234 | app.use(express.static(path.join(serverDirectory, 'public'), {})); | | |
| 235 | | | |
| 236 | // Public API | | |
| 237 | app.use('/api/users', usersPublicRouter); | | |
| 238 | | | |
| 239 | // Everything below this line requires authentication | | |
| 240 | app.use(requireLoginMiddleware); | | |
| 241 | app.get('/api/ping', (request, response) => { | | |
| 242 | if (request.query.extend && request.session) { | | |
| 243 | request.session.touch = Date.now(); | | |
| 244 | } | | |
| 245 | | | |
| 246 | response.sendStatus(204); | | |
| 247 | }); | | |
| 248 | | | |
| 249 | // File uploads | | |
| 250 | const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); | | |
| 251 | app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); | | |
| 252 | app.use(multerMonkeyPatch); | | |
| 253 | | | |
| 254 | app.get('/version', async function (_, response) { | | |
| 255 | const data = await getVersion(); | | |
| 256 | response.send(data); | | |
| 257 | }); | | |
| 258 | | | |
| 259 | redirectDeprecatedEndpoints(app); | | |
| 260 | setupPrivateEndpoints(app); | | |
| 261 | | | |
| 262 | /** | | |
| 263 | * Tasks that need to be run before the server starts listening. | | |
| 264 | * @returns {Promise<void>} | | |
| 265 | */ | | |
| 266 | async function preSetupTasks() { | | |
| 267 | const version = await getVersion(); | | |
| 268 | | | |
| 269 | // Print formatted header | | |
| 270 | console.log(); | | |
| 271 | console.log(`SillyTavern ${version.pkgVersion}`); | | |
| 272 | if (version.gitBranch) { | | |
| 273 | console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`); | | |
| 274 | if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) { | | |
| 275 | console.log('INFO: Currently not on the latest commit.'); | | |
| 276 | console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.'); | | |
| 277 | } | | |
| 278 | } | | |
| 279 | console.log(); | | |
| 280 | | | |
| 281 | const directories = await getUserDirectoriesList(); | | |
| 282 | await checkForNewContent(directories); | | |
| 283 | await ensureThumbnailCache(directories); | | |
| 284 | await diskCache.verify(directories); | | |
| 285 | cleanUploads(); | | |
| 286 | migrateAccessLog(); | | |
| 287 | | | |
| 288 | await settingsInit(); | | |
| 289 | await statsInit(); | | |
| 290 | | | |
| 291 | const pluginsDirectory = path.join(serverDirectory, 'plugins'); | | |
| 292 | const cleanupPlugins = await loadPlugins(app, pluginsDirectory); | | |
| 293 | const consoleTitle = process.title; | | |
| 294 | | | |
| 295 | let isExiting = false; | | |
| 296 | const exitProcess = async () => { | | |
| 297 | if (isExiting) return; | | |
| 298 | isExiting = true; | | |
| 299 | await statsOnExit(); | | |
| 300 | if (typeof cleanupPlugins === 'function') { | | |
| 301 | await cleanupPlugins(); | | |
| 302 | } | | |
| 303 | diskCache.dispose(); | | |
| 304 | setWindowTitle(consoleTitle); | | |
| 305 | process.exit(); | | |
| 306 | }; | | |
| 307 | | | |
| 308 | // Set up event listeners for a graceful shutdown | | |
| 309 | process.on('SIGINT', exitProcess); | | |
| 310 | process.on('SIGTERM', exitProcess); | | |
| 311 | process.on('uncaughtException', (err) => { | | |
| 312 | console.error('Uncaught exception:', err); | | |
| 313 | exitProcess(); | | |
| 314 | }); | | |
| 315 | | | |
| 316 | // Add request proxy. | | |
| 317 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); | | |
| 318 | | | |
| 319 | // Wait for frontend libs to compile | | |
| 320 | await webpackMiddleware.runWebpackCompiler(); | | |
| 321 | } | | |
| 322 | | | |
| 323 | /** | | |
| 324 | * Tasks that need to be run after the server starts listening. | | |
| 325 | * @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup | | |
| 326 | * @returns {Promise<void>} | | |
| 327 | */ | | |
| 328 | async function postSetupTasks(result) { | | |
| 329 | const autorunHostname = await cliArgs.getAutorunHostname(result); | | |
| 330 | const autorunUrl = cliArgs.getAutorunUrl(autorunHostname); | | |
| 331 | | | |
| 332 | if (cliArgs.autorun) { | | |
| 333 | try { | | |
| 334 | console.log('Launching in a browser...'); | | |
| 335 | await open(autorunUrl.toString()); | | |
| 336 | } catch (error) { | | |
| 337 | console.error('Failed to launch the browser. Open the URL manually.'); | | |
| 338 | } | | |
| 339 | } | | |
| 340 | | | |
| 341 | setWindowTitle('SillyTavern WebServer'); | | |
| 342 | | | |
| 343 | let logListen = 'SillyTavern is listening on'; | | |
| 344 | | | |
| 345 | if (result.useIPv6 && !result.v6Failed) { | | |
| 346 | logListen += color.green( | | |
| 347 | ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, | | |
| 348 | ); | | |
| 349 | } | | |
| 350 | | | |
| 351 | if (result.useIPv4 && !result.v4Failed) { | | |
| 352 | logListen += color.green( | | |
| 353 | ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, | | |
| 354 | ); | | |
| 355 | } | | |
| 356 | | | |
| 357 | const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern'; | | |
| 358 | const plainGoToLog = removeColorFormatting(goToLog); | | |
| 359 | | | |
| 360 | console.log(logListen); | | |
| 361 | if (cliArgs.listen) { | | |
| 362 | console.log(); | | |
| 363 | console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); | | |
| 364 | console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); | | |
| 365 | } | | |
| 366 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | | |
| 367 | console.log(goToLog); | | |
| 368 | console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); | | |
| 369 | | | |
| 370 | setupLogLevel(); | | |
| 371 | serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl }); | | |
| 372 | } | | |
| 373 | | | |
| 374 | /** | | |
| 375 | * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. | | |
| 376 | */ | | |
| 377 | function apply404Middleware() { | | |
| 378 | const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? ''; | | |
| 379 | app.use((req, res) => { | | |
| 380 | res.status(404).send(notFoundWebpage); | | |
| 381 | }); | | |
| 382 | } | | |
| 383 | | | |
| 384 | // User storage module needs to be initialized before starting the server | | |
| 385 | initUserStorage(globalThis.DATA_ROOT) | | |
| 386 | .then(ensurePublicDirectoriesExist) | | |
| 387 | .then(migrateUserData) | | |
| 388 | .then(migrateSystemPrompts) | | |
| 389 | .then(verifySecuritySettings) | | |
| 390 | .then(preSetupTasks) | | |
| 391 | .then(apply404Middleware) | | |
| 392 | .then(() => new ServerStartup(app, cliArgs).start()) | | |
| 393 | .then(postSetupTasks); | | |