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