mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-03-13 18:40:11 +01:00
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// 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 { fileURLToPath } from 'node:url';
|
|
|
|
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';
|
|
import open from 'open';
|
|
|
|
// local library imports
|
|
import { CommandLineParser } from './src/command-line.js';
|
|
import { loadPlugins } from './src/plugin-loader.js';
|
|
import {
|
|
initUserStorage,
|
|
getCookieSecret,
|
|
getCookieSessionName,
|
|
ensurePublicDirectoriesExist,
|
|
getUserDirectoriesList,
|
|
migrateSystemPrompts,
|
|
migrateUserData,
|
|
requireLoginMiddleware,
|
|
setUserDataMiddleware,
|
|
shouldRedirectToLogin,
|
|
cleanUploads,
|
|
getSessionCookieAge,
|
|
verifySecuritySettings,
|
|
loginPageMiddleware,
|
|
} from './src/users.js';
|
|
|
|
import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
|
|
import basicAuthMiddleware from './src/middleware/basicAuth.js';
|
|
import whitelistMiddleware from './src/middleware/whitelist.js';
|
|
import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
|
|
import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
|
|
import initRequestProxy from './src/request-proxy.js';
|
|
import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
|
|
import corsProxyMiddleware from './src/middleware/corsProxy.js';
|
|
import {
|
|
getVersion,
|
|
color,
|
|
removeColorFormatting,
|
|
getSeparator,
|
|
safeReadFileSync,
|
|
setupLogLevel,
|
|
setWindowTitle,
|
|
} from './src/util.js';
|
|
import { UPLOADS_DIRECTORY } from './src/constants.js';
|
|
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
|
|
|
|
// Routers
|
|
import { router as usersPublicRouter } from './src/endpoints/users-public.js';
|
|
import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
|
|
import { checkForNewContent } from './src/endpoints/content-manager.js';
|
|
import { init as settingsInit } from './src/endpoints/settings.js';
|
|
import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
|
|
|
|
// Unrestrict console logs display limit
|
|
util.inspect.defaultOptions.maxArrayLength = null;
|
|
util.inspect.defaultOptions.maxStringLength = null;
|
|
util.inspect.defaultOptions.depth = 4;
|
|
|
|
// Set a working directory for the server
|
|
const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
|
|
process.chdir(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);
|
|
}
|
|
|
|
const cliArgs = new CommandLineParser().parse(process.argv);
|
|
globalThis.DATA_ROOT = cliArgs.dataRoot;
|
|
globalThis.COMMAND_LINE_ARGS = cliArgs;
|
|
|
|
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());
|
|
|
|
// CORS Settings //
|
|
const CORS = cors({
|
|
origin: 'null',
|
|
methods: ['OPTIONS'],
|
|
});
|
|
|
|
app.use(CORS);
|
|
|
|
if (cliArgs.listen && cliArgs.basicAuthMode) {
|
|
app.use(basicAuthMiddleware);
|
|
}
|
|
|
|
if (cliArgs.whitelistMode) {
|
|
app.use(whitelistMiddleware());
|
|
}
|
|
|
|
if (cliArgs.listen) {
|
|
app.use(accessLoggerMiddleware());
|
|
}
|
|
|
|
if (cliArgs.enableCorsProxy) {
|
|
app.use(bodyParser.json({
|
|
limit: '200mb',
|
|
}));
|
|
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: 'strict',
|
|
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(process.cwd(), 'public') });
|
|
});
|
|
|
|
// Host login page
|
|
app.get('/login', loginPageMiddleware);
|
|
|
|
// Host frontend assets
|
|
const webpackMiddleware = getWebpackServeMiddleware();
|
|
app.use(webpackMiddleware);
|
|
app.use(express.static(process.cwd() + '/public', {}));
|
|
|
|
// Public API
|
|
app.use('/api/users', usersPublicRouter);
|
|
|
|
// Everything below this line requires authentication
|
|
app.use(requireLoginMiddleware);
|
|
app.get('/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<void>}
|
|
*/
|
|
async function preSetupTasks() {
|
|
const version = await getVersion();
|
|
|
|
// Print formatted header
|
|
console.log();
|
|
console.log(`SillyTavern ${version.pkgVersion}`);
|
|
console.log(version.gitBranch ? `Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}` : '');
|
|
if (version.gitBranch && !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();
|
|
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();
|
|
}
|
|
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('./src/server-startup.js').ServerStartupResult} result The result of the server startup
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function postSetupTasks(result) {
|
|
const autorunHostname = await cliArgs.getAutorunHostname(result);
|
|
const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
|
|
|
|
if (cliArgs.autorun) {
|
|
console.log('Launching in a browser...');
|
|
await open(autorunUrl.toString());
|
|
}
|
|
|
|
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(autorunUrl) + ' 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();
|
|
}
|
|
|
|
/**
|
|
* 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('./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);
|