Blame Raw
Cohee · 51ad27fb · · 489 lines (17.3 KB)
2 contributors
1// native node modules
2import fs from 'node:fs';
3import path from 'node:path';
4import util from 'node:util';
5import net from 'node:net';
6import dns from 'node:dns';
7import process from 'node:process';
8import http from 'node:http';
9import https from 'node:https';
10
11import cors from 'cors';
12import { csrfSync } from 'csrf-sync';
13import express from 'express';
14import compression from 'compression';
15import cookieSession from 'cookie-session';
16import multer from 'multer';
17import responseTime from 'response-time';
18import helmet from 'helmet';
19import bodyParser from 'body-parser';
20
21// local library imports
22import './fetch-patch.js';
23import { serverDirectory } from './server-directory.js';
24
25import { serverEvents, EVENT_NAMES } from './server-events.js';
26import { loadPlugins } from './plugin-loader.js';
27import {
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
45import getWebpackServeMiddleware from './middleware/webpack-serve.js';
46import basicAuthMiddleware from './middleware/basicAuth.js';
47import getWhitelistMiddleware from './middleware/whitelist.js';
48import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
49import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
50import initRequestProxy from './request-proxy.js';
51import initPrivateRequestFilter from './private-request-filter.js';
52import cacheBuster from './middleware/cacheBuster.js';
53import corsProxyMiddleware from './middleware/corsProxy.js';
54import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
55import userCssMiddleware from './middleware/userCss.js';
56import {
57 getVersion,
58 color,
59 removeColorFormatting,
60 getSeparator,
61 safeReadFileSync,
62 setupLogLevel,
63 setWindowTitle,
64 getConfigValue,
65} from './util.js';
66import { UPLOADS_DIRECTORY } from './constants.js';
67
68// Routers
69import { router as usersPublicRouter } from './endpoints/users-public.js';
70import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js';
71import { checkForNewContent } from './endpoints/content-manager.js';
72import { init as settingsInit } from './endpoints/settings.js';
73import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
74import { diskCache } from './endpoints/characters.js';
75import { migrateFlatSecrets } from './endpoints/secrets.js';
76import { 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.
81if (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
87util.inspect.defaultOptions.maxArrayLength = null;
88util.inspect.defaultOptions.maxStringLength = null;
89util.inspect.defaultOptions.depth = 4;
90
91/** @type {import('./command-line.js').CommandLineArguments} */
92const cliArgs = globalThis.COMMAND_LINE_ARGS;
93
94if (!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.
100http.globalAgent = new http.Agent({ keepAlive: cliArgs.enableKeepAlive });
101https.globalAgent = new https.Agent({ keepAlive: cliArgs.enableKeepAlive });
102
103const app = express();
104app.use(helmet({
105 contentSecurityPolicy: false,
106}));
107app.use(compression());
108app.use(responseTime());
109
110app.use(bodyParser.json({ limit: '500mb' }));
111app.use(bodyParser.urlencoded({ extended: true, limit: '500mb' }));
112
113// CORS Settings //
114const corsEnabled = getConfigValue('cors.enabled', true, 'boolean');
115if (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
141if (cliArgs.listen && cliArgs.basicAuthMode) {
142 app.use(basicAuthMiddleware);
143}
144
145if (cliArgs.whitelistMode) {
146 const whitelistMiddleware = await getWhitelistMiddleware();
147 app.use(whitelistMiddleware);
148}
149
150app.use(hostWhitelistMiddleware);
151
152if (cliArgs.listen) {
153 app.use(accessLoggerMiddleware());
154}
155
156app.use(cookieSession({
157 name: getCookieSessionName(),
158 sameSite: 'lax',
159 httpOnly: true,
160 maxAge: getSessionCookieAge(),
161 secret: getCookieSecret(globalThis.DATA_ROOT),
162}));
163
164app.use(setUserDataMiddleware);
165
166// CSRF Protection //
167if (!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
214app.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)
225app.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
236app.get('/login', loginPageMiddleware);
237
238// Host frontend assets
239const webpackMiddleware = getWebpackServeMiddleware();
240app.use(webpackMiddleware);
241app.use(userCssMiddleware);
242app.use(express.static(path.join(serverDirectory, 'public'), {}));
243
244// Public API
245app.use('/api/users', usersPublicRouter);
246
247// Everything below this line requires authentication
248app.use(requireLoginMiddleware);
249app.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
257if (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
268const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
269app.use(multer({ dest: uploadsPath, limits: { fieldSize: 500 * 1024 * 1024 } }).single('avatar'));
270app.use(multerMonkeyPatch);
271
272app.get('/version', async function (_, response) {
273 const data = await getVersion();
274 response.send(data);
275});
276
277redirectDeprecatedEndpoints(app);
278setupPrivateEndpoints(app);
279
280/**
281 * Tasks that need to be run before the server starts listening.
282 * @returns {Promise<void>}
283 */
284async 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 */
361async 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 */
454function 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 */
464function 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
479initUserStorage(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);