diff --git a/index.d.ts b/index.d.ts index 35f34c22c..83eb8f9b1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,5 @@ import { UserDirectoryList, User } from "./src/users"; +import { CommandLineArguments } from "./src/command-line"; import { CsrfSyncedToken } from "csrf-sync"; declare global { @@ -32,4 +33,9 @@ declare global { * The root directory for user data. */ var DATA_ROOT: string; + + /** + * Parsed command line arguments. + */ + var COMMAND_LINE_ARGS: CommandLineArguments; } diff --git a/server.js b/server.js index 9cae3a57b..1f34b91a3 100644 --- a/server.js +++ b/server.js @@ -26,7 +26,6 @@ import { initUserStorage, getCookieSecret, getCookieSessionName, - getAllEnabledUsers, ensurePublicDirectoriesExist, getUserDirectoriesList, migrateSystemPrompts, @@ -34,9 +33,10 @@ import { requireLoginMiddleware, setUserDataMiddleware, shouldRedirectToLogin, - tryAutoLogin, cleanUploads, getSessionCookieAge, + verifySecuritySettings, + loginPageMiddleware, } from './src/users.js'; import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; @@ -49,7 +49,6 @@ import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; import corsProxyMiddleware from './src/middleware/corsProxy.js'; import { getVersion, - getConfigValue, color, removeColorFormatting, getSeparator, @@ -72,6 +71,11 @@ 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. @@ -80,19 +84,26 @@ if (process.versions && process.versions.node && process.versions.node.match(/20 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); } -const cliParser = new CommandLineParser(); -const cliArgs = cliParser.parse(process.argv); +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); } -// change all relative paths -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); +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({ @@ -101,19 +112,6 @@ app.use(helmet({ app.use(compression()); app.use(responseTime()); -/** @type {boolean} */ -const enableAccounts = getConfigValue('enableUserAccounts', false, 'boolean'); - -if (cliArgs.dnsPreferIPv6) { - // Set default DNS resolution order to IPv6 first - dns.setDefaultResultOrder('ipv6first'); - console.log('Preferring IPv6 for DNS resolution'); -} else { - // Set default DNS resolution order to IPv4 first - dns.setDefaultResultOrder('ipv4first'); - console.log('Preferring IPv4 for DNS resolution'); -} - // CORS Settings // const CORS = cors({ origin: 'null', @@ -213,24 +211,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => { }); // Host login page -app.get('/login', async (request, response) => { - if (!enableAccounts) { - console.log('User accounts are disabled. Redirecting to index page.'); - return response.redirect('/'); - } - - try { - const autoLogin = await tryAutoLogin(request, cliArgs.basicAuthMode); - - if (autoLogin) { - return response.redirect('/'); - } - } catch (error) { - console.error('Error during auto-login:', error); - } - - return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); -}); +app.get('/login', loginPageMiddleware); // Host frontend assets const webpackMiddleware = getWebpackServeMiddleware(); @@ -289,7 +270,8 @@ async function preSetupTasks() { await settingsInit(); await statsInit(); - const cleanupPlugins = await initializePlugins(); + const pluginsDirectory = path.join(serverDirectory, 'plugins'); + const cleanupPlugins = await loadPlugins(app, pluginsDirectory); const consoleTitle = process.title; let isExiting = false; @@ -365,80 +347,6 @@ async function postSetupTasks(result) { setupLogLevel(); } -/** - * Loads server plugins from a directory. - * @returns {Promise} Function to be run on server exit - */ -async function initializePlugins() { - try { - const pluginDirectory = path.join(serverDirectory, 'plugins'); - const cleanupPlugins = await loadPlugins(app, pluginDirectory); - return cleanupPlugins; - } catch { - console.log('Plugin loading failed.'); - return () => { }; - } -} - -/** - * Prints an error message and exits the process if necessary - * @param {string} message The error message to print - * @returns {void} - */ -function logSecurityAlert(message) { - if (cliArgs.basicAuthMode || cliArgs.whitelistMode) return; // safe! - console.error(color.red(message)); - if (getConfigValue('securityOverride', false, 'boolean')) { - console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); - return; - } - process.exit(1); -} - -async function verifySecuritySettings() { - // Skip all security checks as listen is set to false - if (!cliArgs.listen) { - return; - } - - if (!enableAccounts) { - logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.'); - } - - const users = await getAllEnabledUsers(); - const unprotectedUsers = users.filter(x => !x.password); - const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin); - - if (unprotectedUsers.length > 0) { - console.warn(color.blue('A friendly reminder that the following users are not password protected:')); - unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x)); - console.log(); - console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`); - console.log(); - - if (unprotectedAdminUsers.length > 0) { - logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); - } - } - - if (cliArgs.basicAuthMode) { - const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean'); - if (perUserBasicAuth && !enableAccounts) { - console.error(color.red( - 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', - )); - } else if (!perUserBasicAuth) { - const basicAuthUserName = getConfigValue('basicAuthUser.username', ''); - const basicAuthUserPassword = getConfigValue('basicAuthUser.password', ''); - if (!basicAuthUserName || !basicAuthUserPassword) { - console.warn(color.yellow( - 'Basic Authentication is enabled, but username or password is not set or empty!', - )); - } - } - } -} - /** * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. */ diff --git a/src/plugin-loader.js b/src/plugin-loader.js index 7d7053025..f752d5201 100644 --- a/src/plugin-loader.js +++ b/src/plugin-loader.js @@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs'; * be called before the server shuts down. */ export async function loadPlugins(app, pluginsPath) { - const exitHooks = []; - const emptyFn = () => { }; + try { + const exitHooks = []; + const emptyFn = () => { }; - // Server plugins are disabled. - if (!enableServerPlugins) { - return emptyFn; - } - - // Plugins directory does not exist. - if (!fs.existsSync(pluginsPath)) { - return emptyFn; - } - - const files = fs.readdirSync(pluginsPath); - - // No plugins to load. - if (files.length === 0) { - return emptyFn; - } - - await updatePlugins(pluginsPath); - - for (const file of files) { - const pluginFilePath = path.join(pluginsPath, file); - - if (fs.statSync(pluginFilePath).isDirectory()) { - await loadFromDirectory(app, pluginFilePath, exitHooks); - continue; + // Server plugins are disabled. + if (!enableServerPlugins) { + return emptyFn; } - // Not a JavaScript file. - if (!isCommonJS(file) && !isESModule(file)) { - continue; + // Plugins directory does not exist. + if (!fs.existsSync(pluginsPath)) { + return emptyFn; } - await loadFromFile(app, pluginFilePath, exitHooks); - } + const files = fs.readdirSync(pluginsPath); - if (loadedPlugins.size > 0) { - console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`); - } + // No plugins to load. + if (files.length === 0) { + return emptyFn; + } - // Call all plugin "exit" functions at once and wait for them to finish - return () => Promise.all(exitHooks.map(exitFn => exitFn())); + await updatePlugins(pluginsPath); + + for (const file of files) { + const pluginFilePath = path.join(pluginsPath, file); + + if (fs.statSync(pluginFilePath).isDirectory()) { + await loadFromDirectory(app, pluginFilePath, exitHooks); + continue; + } + + // Not a JavaScript file. + if (!isCommonJS(file) && !isESModule(file)) { + continue; + } + + await loadFromFile(app, pluginFilePath, exitHooks); + } + + if (loadedPlugins.size > 0) { + console.log(`${loadedPlugins.size} server plugin(s) are currently loaded. Make sure you know exactly what they do, and only install plugins from trusted sources!`); + } + + // Call all plugin "exit" functions at once and wait for them to finish + return () => Promise.all(exitHooks.map(exitFn => exitFn())); + } catch (error) { + console.error('Plugin loading failed.', error); + return () => { }; + } } async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) { diff --git a/src/users.js b/src/users.js index f6e383b38..da24df97c 100644 --- a/src/users.js +++ b/src/users.js @@ -120,6 +120,72 @@ export async function ensurePublicDirectoriesExist() { return directoriesList; } +/** + * Prints an error message and exits the process if necessary + * @param {string} message The error message to print + * @returns {void} + */ +function logSecurityAlert(message) { + const { basicAuthMode, whitelistMode } = globalThis.COMMAND_LINE_ARGS; + if (basicAuthMode || whitelistMode) return; // safe! + console.error(color.red(message)); + if (getConfigValue('securityOverride', false, 'boolean')) { + console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.')); + return; + } + process.exit(1); +} + +/** + * Verifies the security settings and prints warnings if necessary + * @returns {Promise} + */ +export async function verifySecuritySettings() { + const { listen, basicAuthMode } = globalThis.COMMAND_LINE_ARGS; + + // Skip all security checks as listen is set to false + if (!listen) { + return; + } + + if (!ENABLE_ACCOUNTS) { + logSecurityAlert('Your current SillyTavern configuration is insecure (listening to non-localhost). Enable whitelisting, basic authentication or user accounts.'); + } + + const users = await getAllEnabledUsers(); + const unprotectedUsers = users.filter(x => !x.password); + const unprotectedAdminUsers = unprotectedUsers.filter(x => x.admin); + + if (unprotectedUsers.length > 0) { + console.warn(color.blue('A friendly reminder that the following users are not password protected:')); + unprotectedUsers.map(x => `${color.yellow(x.handle)} ${color.red(x.admin ? '(admin)' : '')}`).forEach(x => console.warn(x)); + console.log(); + console.warn(`Consider setting a password in the admin panel or by using the ${color.blue('recover.js')} script.`); + console.log(); + + if (unprotectedAdminUsers.length > 0) { + logSecurityAlert('If you are not using basic authentication or whitelisting, you should set a password for all admin users.'); + } + } + + if (basicAuthMode) { + const perUserBasicAuth = getConfigValue('perUserBasicAuth', false, 'boolean'); + if (perUserBasicAuth && !ENABLE_ACCOUNTS) { + console.error(color.red( + 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', + )); + } else if (!perUserBasicAuth) { + const basicAuthUserName = getConfigValue('basicAuthUser.username', ''); + const basicAuthUserPassword = getConfigValue('basicAuthUser.password', ''); + if (!basicAuthUserName || !basicAuthUserPassword) { + console.warn(color.yellow( + 'Basic Authentication is enabled, but username or password is not set or empty!', + )); + } + } + } +} + export function cleanUploads() { try { const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY); @@ -817,6 +883,31 @@ export function requireLoginMiddleware(request, response, next) { return next(); } +/** + * Middleware to host the login page. + * @param {import('express').Request} request Request object + * @param {import('express').Response} response Response object + */ +export async function loginPageMiddleware(request, response) { + if (!ENABLE_ACCOUNTS) { + console.log('User accounts are disabled. Redirecting to index page.'); + return response.redirect('/'); + } + + try { + const { basicAuthMode } = globalThis.COMMAND_LINE_ARGS; + const autoLogin = await tryAutoLogin(request, basicAuthMode); + + if (autoLogin) { + return response.redirect('/'); + } + } catch (error) { + console.error('Error during auto-login:', error); + } + + return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') }); +} + /** * Creates a route handler for serving files from a specific directory. * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from