// native node modules import path from 'node:path'; import util from 'node:util'; import net from 'node:net'; import dns from 'node:dns'; import process from 'node:process'; import cors from 'cors'; import { csrfSync } from 'csrf-sync'; import express from 'express'; import compression from 'compression'; import cookieSession from 'cookie-session'; import multer from 'multer'; import responseTime from 'response-time'; import helmet from 'helmet'; import bodyParser from 'body-parser'; // local library imports import './fetch-patch.js'; import { serverDirectory } from './server-directory.js'; console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 // Safe to remove once support for Node v20 is dropped. if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) { // @ts-ignore if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); } import { serverEvents, EVENT_NAMES } from './server-events.js'; import { loadPlugins } from './plugin-loader.js'; import { initUserStorage, getCookieSecret, getCookieSessionName, ensurePublicDirectoriesExist, getUserDirectoriesList, migrateSystemPrompts, migrateUserData, requireLoginMiddleware, setUserDataMiddleware, shouldRedirectToLogin, cleanUploads, getSessionCookieAge, verifySecuritySettings, loginPageMiddleware, } from './users.js'; import getWebpackServeMiddleware from './middleware/webpack-serve.js'; import basicAuthMiddleware from './middleware/basicAuth.js'; import getWhitelistMiddleware from './middleware/whitelist.js'; import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; import initRequestProxy from './request-proxy.js'; import getCacheBusterMiddleware from './middleware/cacheBuster.js'; import corsProxyMiddleware from './middleware/corsProxy.js'; import { getVersion, color, removeColorFormatting, getSeparator, safeReadFileSync, setupLogLevel, setWindowTitle, getConfigValue, } from './util.js'; import { UPLOADS_DIRECTORY } from './constants.js'; import { ensureThumbnailCache } from './endpoints/thumbnails.js'; // Routers import { router as usersPublicRouter } from './endpoints/users-public.js'; import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js'; import { checkForNewContent } from './endpoints/content-manager.js'; import { init as settingsInit } from './endpoints/settings.js'; import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js'; import { diskCache } from './endpoints/characters.js'; import { migrateFlatSecrets } from './endpoints/secrets.js'; // Unrestrict console logs display limit util.inspect.defaultOptions.maxArrayLength = null; util.inspect.defaultOptions.maxStringLength = null; util.inspect.defaultOptions.depth = 4; /** @type {import('./command-line.js').CommandLineArguments} */ const cliArgs = globalThis.COMMAND_LINE_ARGS; if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) { console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); process.exit(1); } try { if (cliArgs.dnsPreferIPv6) { dns.setDefaultResultOrder('ipv6first'); console.log('Preferring IPv6 for DNS resolution'); } else { dns.setDefaultResultOrder('ipv4first'); console.log('Preferring IPv4 for DNS resolution'); } } catch (error) { console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.'); } const app = express(); app.use(helmet({ contentSecurityPolicy: false, })); app.use(compression()); app.use(responseTime()); app.use(bodyParser.json({ limit: '200mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' })); // CORS Settings // const CORS = cors({ origin: 'null', methods: ['OPTIONS'], }); app.use(CORS); if (cliArgs.listen && cliArgs.basicAuthMode) { app.use(basicAuthMiddleware); } if (cliArgs.whitelistMode) { const whitelistMiddleware = await getWhitelistMiddleware(); app.use(whitelistMiddleware); } if (cliArgs.listen) { app.use(accessLoggerMiddleware()); } if (cliArgs.enableCorsProxy) { app.use('/proxy/:url(*)', corsProxyMiddleware); } else { app.use('/proxy/:url(*)', async (_, res) => { const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.'; console.log(message); res.status(404).send(message); }); } app.use(cookieSession({ name: getCookieSessionName(), sameSite: 'lax', httpOnly: true, maxAge: getSessionCookieAge(), secret: getCookieSecret(globalThis.DATA_ROOT), })); app.use(setUserDataMiddleware); // CSRF Protection // if (!cliArgs.disableCsrf) { const csrfSyncProtection = csrfSync({ getTokenFromState: (req) => { if (!req.session) { console.error('(CSRF error) getTokenFromState: Session object not initialized'); return; } return req.session.csrfToken; }, getTokenFromRequest: (req) => { return req.headers['x-csrf-token']?.toString(); }, storeTokenInState: (req, token) => { if (!req.session) { console.error('(CSRF error) storeTokenInState: Session object not initialized'); return; } req.session.csrfToken = token; }, size: 32, }); app.get('/csrf-token', (req, res) => { res.json({ 'token': csrfSyncProtection.generateToken(req), }); }); // Customize the error message csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); csrfSyncProtection.invalidCsrfTokenError.stack = undefined; app.use(csrfSyncProtection.csrfSynchronisedProtection); } else { console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); app.get('/csrf-token', (req, res) => { res.json({ 'token': 'disabled', }); }); } // Static files // Host index page app.get('/', getCacheBusterMiddleware(), (request, response) => { if (shouldRedirectToLogin(request)) { const query = request.url.split('?')[1]; const redirectUrl = query ? `/login?${query}` : '/login'; return response.redirect(redirectUrl); } return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') }); }); // Callback endpoint for OAuth PKCE flows (e.g. OpenRouter) app.get('/callback/:source?', (request, response) => { const source = request.params.source; const query = request.url.split('?')[1]; const searchParams = new URLSearchParams(); source && searchParams.set('source', source); query && searchParams.set('query', query); const path = `/?${searchParams.toString()}`; return response.redirect(307, path); }); // Host login page app.get('/login', loginPageMiddleware); // Host frontend assets const webpackMiddleware = getWebpackServeMiddleware(); app.use(webpackMiddleware); app.use(express.static(path.join(serverDirectory, 'public'), {})); // Public API app.use('/api/users', usersPublicRouter); // Everything below this line requires authentication app.use(requireLoginMiddleware); app.post('/api/ping', (request, response) => { if (request.query.extend && request.session) { request.session.touch = Date.now(); } response.sendStatus(204); }); // File uploads const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY); app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); app.use(multerMonkeyPatch); app.get('/version', async function (_, response) { const data = await getVersion(); response.send(data); }); redirectDeprecatedEndpoints(app); setupPrivateEndpoints(app); /** * Tasks that need to be run before the server starts listening. * @returns {Promise} */ async function preSetupTasks() { const version = await getVersion(); // Print formatted header console.log(); console.log(`SillyTavern ${version.pkgVersion}`); if (version.gitBranch && version.commitDate) { const date = new Date(version.commitDate); const localDate = date.toLocaleString('en-US', { timeZoneName: 'short' }); console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${localDate}`); if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) { console.log('INFO: Currently not on the latest commit.'); console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.'); } } console.log(); const directories = await getUserDirectoriesList(); await checkForNewContent(directories); await ensureThumbnailCache(directories); await diskCache.verify(directories); migrateFlatSecrets(directories); cleanUploads(); migrateAccessLog(); await settingsInit(); await statsInit(); const pluginsDirectory = path.join(serverDirectory, 'plugins'); const cleanupPlugins = await loadPlugins(app, pluginsDirectory); const consoleTitle = process.title; let isExiting = false; const exitProcess = async () => { if (isExiting) return; isExiting = true; await statsOnExit(); if (typeof cleanupPlugins === 'function') { await cleanupPlugins(); } diskCache.dispose(); setWindowTitle(consoleTitle); process.exit(); }; // Set up event listeners for a graceful shutdown process.on('SIGINT', exitProcess); process.on('SIGTERM', exitProcess); process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); exitProcess(); }); // Add request proxy. initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass }); // Wait for frontend libs to compile await webpackMiddleware.runWebpackCompiler(); } /** * Tasks that need to be run after the server starts listening. * @param {import('./server-startup.js').ServerStartupResult} result The result of the server startup * @returns {Promise} */ async function postSetupTasks(result) { const browserLaunchHostname = await cliArgs.getBrowserLaunchHostname(result); const browserLaunchUrl = cliArgs.getBrowserLaunchUrl(browserLaunchHostname); const browserLaunchApp = String(getConfigValue('browserLaunch.browser', 'default') ?? ''); if (cliArgs.browserLaunchEnabled) { try { // TODO: This should be converted to a regular import when support for Node 18 is dropped const openModule = await import('open'); const { default: open, apps } = openModule; function getBrowsers() { const isAndroid = process.platform === 'android'; if (isAndroid) { return {}; } return { 'firefox': apps.firefox, 'chrome': apps.chrome, 'edge': apps.edge, }; } const validBrowsers = getBrowsers(); const appName = validBrowsers[browserLaunchApp.trim().toLowerCase()]; const openOptions = appName ? { app: { name: appName } } : {}; console.log(`Launching in a browser: ${browserLaunchApp}...`); await open(browserLaunchUrl.toString(), openOptions); } catch (error) { console.error('Failed to launch the browser. Open the URL manually.', error); } } setWindowTitle('SillyTavern WebServer'); let logListen = 'SillyTavern is listening on'; if (result.useIPv6 && !result.v6Failed) { logListen += color.green( ' IPv6: ' + cliArgs.getIPv6ListenUrl().host, ); } if (result.useIPv4 && !result.v4Failed) { logListen += color.green( ' IPv4: ' + cliArgs.getIPv4ListenUrl().host, ); } const goToLog = `Go to: ${color.blue(browserLaunchUrl)} to open SillyTavern`; const plainGoToLog = removeColorFormatting(goToLog); console.log(logListen); if (cliArgs.listen) { console.log(); console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".'); console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath())); } console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); console.log(goToLog); console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); setupLogLevel(); serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: browserLaunchUrl }); } /** * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. */ function apply404Middleware() { const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? ''; app.use((req, res) => { res.status(404).send(notFoundWebpage); }); } // User storage module needs to be initialized before starting the server initUserStorage(globalThis.DATA_ROOT) .then(ensurePublicDirectoriesExist) .then(migrateUserData) .then(migrateSystemPrompts) .then(verifySecuritySettings) .then(preSetupTasks) .then(apply404Middleware) .then(() => new ServerStartup(app, cliArgs).start()) .then(postSetupTasks);