Merge pull request #3567 from SillyTavern/cli-args-refactor

Refactor server startup
This commit is contained in:
Cohee 2025-02-27 22:17:08 +02:00 committed by GitHub
commit f43b42544b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1058 additions and 923 deletions

70
.github/readme.md vendored
View File

@ -317,29 +317,34 @@ Start.bat --port 8000 --listen false
### Supported arguments ### Supported arguments
| Option | Description | Type | > \[!TIP]
|-------------------------|------------------------------------------------------------------------------------------------------|----------| > None of the arguments are required. If you don't provide them, SillyTavern will use the settings in `config.yaml`.
| `--version` | Show version number | boolean |
| `--enableIPv6` | Enables IPv6. | boolean | | Option | Description | Type |
| `--enableIPv4` | Enables IPv4. | boolean | |-------------------------|----------------------------------------------------------------------|----------|
| `--port` | Sets the port under which SillyTavern will run. If not provided falls back to yaml config 'port'. | number | | `--version` | Show version number | boolean |
| `--dnsPreferIPv6` | Prefers IPv6 for dns. If not provided falls back to yaml config 'preferIPv6'. | boolean | | `--dataRoot` | Root directory for data storage | string |
| `--autorun` | Automatically launch SillyTavern in the browser. If not provided falls back to yaml config 'autorun'.| boolean | | `--port` | Sets the port under which SillyTavern will run | number |
| `--autorunHostname` | The autorun hostname, probably best left on 'auto'. | string | | `--listen` | SillyTavern will listen on all network interfaces | boolean |
| `--autorunPortOverride` | Overrides the port for autorun. | string | | `--whitelist` | Enables whitelist mode | boolean |
| `--listen` | SillyTavern is listening on all network interfaces. If not provided falls back to yaml config 'listen'.| boolean | | `--basicAuthMode` | Enables basic authentication | boolean |
| `--corsProxy` | Enables CORS proxy. If not provided falls back to yaml config 'enableCorsProxy'. | boolean | | `--enableIPv4` | Enables IPv4 protocol | boolean |
| `--disableCsrf` | Disables CSRF protection | boolean | | `--enableIPv6` | Enables IPv6 protocol | boolean |
| `--ssl` | Enables SSL | boolean | | `--listenAddressIPv4` | Specific IPv4 address to listen to | string |
| `--certPath` | Path to your certificate file. | string | | `--listenAddressIPv6` | Specific IPv6 address to listen to | string |
| `--keyPath` | Path to your private key file. | string | | `--dnsPreferIPv6` | Prefers IPv6 for DNS | boolean |
| `--whitelist` | Enables whitelist mode | boolean | | `--ssl` | Enables SSL | boolean |
| `--dataRoot` | Root directory for data storage | string | | `--certPath` | Path to your certificate file | string |
| `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode. | boolean | | `--keyPath` | Path to your private key file | string |
| `--basicAuthMode` | Enables basic authentication | boolean | | `--autorun` | Automatically launch SillyTavern in the browser | boolean |
| `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean | | `--autorunHostname` | Autorun hostname | string |
| `--requestProxyUrl` | Request proxy URL (HTTP or SOCKS protocols) | string | | `--autorunPortOverride` | Overrides the port for autorun | string |
| `--requestProxyBypass` | Request proxy bypass list (space separated list of hosts) | array | | `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode | boolean |
| `--corsProxy` | Enables CORS proxy | boolean |
| `--requestProxyEnabled` | Enables a use of proxy for outgoing requests | boolean |
| `--requestProxyUrl` | Request proxy URL (HTTP or SOCKS protocols) | string |
| `--requestProxyBypass` | Request proxy bypass list (space separated list of hosts) | array |
| `--disableCsrf` | Disables CSRF protection (NOT RECOMMENDED) | boolean |
## Remote connections ## Remote connections
@ -351,10 +356,29 @@ You may also want to configure SillyTavern user profiles with (optional) passwor
## Performance issues? ## Performance issues?
### General tips
1. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category). 1. Disable the Blur Effect and enable Reduced Motion on the User Settings panel (UI Theme toggles category).
2. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended). 2. If using response streaming, set the streaming FPS to a lower value (10-15 FPS is recommended).
3. Make sure the browser is enabled to use GPU acceleration for rendering. 3. Make sure the browser is enabled to use GPU acceleration for rendering.
### Input lag
Performance degradation, particularly input lag, is most commonly attributed to browser extensions. Known problematic extensions include:
* iCloud Password Manager
* DeepL Translation
* AI-based grammar correction tools
* Various ad-blocking extensions
If you experience performance issues and cannot identify the cause, or suspect an issue with SillyTavern itself, please:
1. [Record a performance profile](https://developer.chrome.com/docs/devtools/performance/reference)
2. Export the profile as a JSON file
3. Submit it to the development team for analysis
We recommend first testing with all browser extensions and third-party SillyTavern extensions disabled to isolate the source of the performance degradation.
## License and credits ## License and credits
**This program is distributed in the hope that it will be useful, **This program is distributed in the hope that it will be useful,

View File

@ -28,6 +28,11 @@ port: 8000
# - Use -1 to use the server port. # - Use -1 to use the server port.
# - Specify a port to override the default. # - Specify a port to override the default.
autorunPortOverride: -1 autorunPortOverride: -1
# -- SSL options --
ssl:
enabled: false
certPath: "./certs/cert.pem"
keyPath: "./certs/privkey.pem"
# -- SECURITY CONFIGURATION -- # -- SECURITY CONFIGURATION --
# Toggle whitelist mode # Toggle whitelist mode
whitelistMode: true whitelistMode: true

6
index.d.ts vendored
View File

@ -1,4 +1,5 @@
import { UserDirectoryList, User } from "./src/users"; import { UserDirectoryList, User } from "./src/users";
import { CommandLineArguments } from "./src/command-line";
import { CsrfSyncedToken } from "csrf-sync"; import { CsrfSyncedToken } from "csrf-sync";
declare global { declare global {
@ -32,4 +33,9 @@ declare global {
* The root directory for user data. * The root directory for user data.
*/ */
var DATA_ROOT: string; var DATA_ROOT: string;
/**
* Parsed command line arguments.
*/
var COMMAND_LINE_ARGS: CommandLineArguments;
} }

929
server.js

File diff suppressed because it is too large Load Diff

262
src/command-line.js Normal file
View File

@ -0,0 +1,262 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import ipRegex from 'ip-regex';
import { canResolve, color, getConfigValue, stringToBool } from './util.js';
/**
* @typedef {object} CommandLineArguments Parsed command line arguments
* @property {string} dataRoot Data root directory
* @property {number} port Port number
* @property {boolean} listen If SillyTavern is listening on all network interfaces
* @property {string} listenAddressIPv6 IPv6 address to listen to
* @property {string} listenAddressIPv4 IPv4 address to listen to
* @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
* @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
* @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
* @property {boolean} autorun If automatically launch SillyTavern in the browser
* @property {string} autorunHostname Autorun hostname
* @property {number} autorunPortOverride Autorun port override (-1 is use server port)
* @property {boolean} enableCorsProxy If enable CORS proxy
* @property {boolean} disableCsrf If disable CSRF protection
* @property {boolean} ssl If enable SSL
* @property {string} certPath Path to certificate
* @property {string} keyPath Path to private key
* @property {boolean} whitelistMode If enable whitelist mode
* @property {boolean} avoidLocalhost If avoid using 'localhost' for autorun in auto mode
* @property {boolean} basicAuthMode If enable basic authentication
* @property {boolean} requestProxyEnabled If enable outgoing request proxy
* @property {string} requestProxyUrl Request proxy URL
* @property {string[]} requestProxyBypass Request proxy bypass list
* @property {function(): URL} getIPv4ListenUrl Get IPv4 listen URL
* @property {function(): URL} getIPv6ListenUrl Get IPv6 listen URL
* @property {function(import('./server-startup.js').ServerStartupResult): Promise<string>} getAutorunHostname Get autorun hostname
* @property {function(string): URL} getAutorunUrl Get autorun URL
*/
/**
* Provides a command line arguments parser.
*/
export class CommandLineParser {
constructor() {
/** @type {CommandLineArguments} */
this.default = Object.freeze({
dataRoot: './data',
port: 8000,
listen: false,
listenAddressIPv6: '[::]',
listenAddressIPv4: '0.0.0.0',
enableIPv4: true,
enableIPv6: false,
dnsPreferIPv6: false,
autorun: false,
autorunHostname: 'auto',
autorunPortOverride: -1,
enableCorsProxy: false,
disableCsrf: false,
ssl: false,
certPath: 'certs/cert.pem',
keyPath: 'certs/privkey.pem',
whitelistMode: true,
avoidLocalhost: false,
basicAuthMode: false,
requestProxyEnabled: false,
requestProxyUrl: '',
requestProxyBypass: [],
getIPv4ListenUrl: function () {
throw new Error('getIPv4ListenUrl is not implemented');
},
getIPv6ListenUrl: function () {
throw new Error('getIPv6ListenUrl is not implemented');
},
getAutorunHostname: async function () {
throw new Error('getAutorunHostname is not implemented');
},
getAutorunUrl: function () {
throw new Error('getAutorunUrl is not implemented');
},
});
this.booleanAutoOptions = [true, false, 'auto'];
}
/**
* Parses command line arguments.
* Arguments that are not provided will be filled with config values.
* @param {string[]} args Process startup arguments.
* @returns {CommandLineArguments} Parsed command line arguments.
*/
parse(args) {
const cliArguments = yargs(hideBin(args))
.usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')
.option('enableIPv6', {
type: 'string',
default: null,
describe: 'Enables IPv6 protocol',
}).option('enableIPv4', {
type: 'string',
default: null,
describe: 'Enables IPv4 protocol',
}).option('port', {
type: 'number',
default: null,
describe: 'Sets the server listening port',
}).option('dnsPreferIPv6', {
type: 'boolean',
default: null,
describe: 'Prefers IPv6 for DNS\nYou should probably have the enabled if you\'re on an IPv6 only network',
}).option('autorun', {
type: 'boolean',
default: null,
describe: 'Automatically launch SillyTavern in the browser',
}).option('autorunHostname', {
type: 'string',
default: null,
describe: 'Sets the autorun hostname, probably best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'',
}).option('autorunPortOverride', {
type: 'string',
default: null,
describe: 'Overrides the port for autorun with open your browser with this port and ignore what port the server is running on. -1 is use server port',
}).option('listen', {
type: 'boolean',
default: null,
describe: 'Whether to listen on all network interfaces',
}).option('listenAddressIPv6', {
type: 'string',
default: null,
describe: 'Specific IPv6 address to listen to',
}).option('listenAddressIPv4', {
type: 'string',
default: null,
describe: 'Specific IPv4 address to listen to',
}).option('corsProxy', {
type: 'boolean',
default: null,
describe: 'Enables CORS proxy',
}).option('disableCsrf', {
type: 'boolean',
default: null,
describe: 'Disables CSRF protection - NOT RECOMMENDED',
}).option('ssl', {
type: 'boolean',
default: null,
describe: 'Enables SSL',
}).option('certPath', {
type: 'string',
default: null,
describe: 'Path to SSL certificate file',
}).option('keyPath', {
type: 'string',
default: null,
describe: 'Path to SSL private key file',
}).option('whitelist', {
type: 'boolean',
default: null,
describe: 'Enables whitelist mode',
}).option('dataRoot', {
type: 'string',
default: null,
describe: 'Root directory for data storage',
}).option('avoidLocalhost', {
type: 'boolean',
default: null,
describe: 'Avoids using \'localhost\' for autorun in auto mode.\nUse if you don\'t have \'localhost\' in your hosts file',
}).option('basicAuthMode', {
type: 'boolean',
default: null,
describe: 'Enables basic authentication',
}).option('requestProxyEnabled', {
type: 'boolean',
default: null,
describe: 'Enables a use of proxy for outgoing requests',
}).option('requestProxyUrl', {
type: 'string',
default: null,
describe: 'Request proxy URL (HTTP or SOCKS protocols)',
}).option('requestProxyBypass', {
type: 'array',
describe: 'Request proxy bypass list (space separated list of hosts)',
}).parseSync();
/** @type {CommandLineArguments} */
const result = {
dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),
port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),
listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),
listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', this.default.listenAddressIPv6),
listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', this.default.listenAddressIPv4),
enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', this.default.enableIPv4)) ?? this.default.enableIPv4,
enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', this.default.enableIPv6)) ?? this.default.enableIPv6,
dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', this.default.dnsPreferIPv6, 'boolean'),
autorun: cliArguments.autorun ?? getConfigValue('autorun', this.default.autorun, 'boolean'),
autorunHostname: cliArguments.autorunHostname ?? getConfigValue('autorunHostname', this.default.autorunHostname),
autorunPortOverride: cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', this.default.autorunPortOverride, 'number'),
enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', this.default.enableCorsProxy, 'boolean'),
disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', this.default.disableCsrf, 'boolean'),
ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', this.default.ssl, 'boolean'),
certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', this.default.certPath),
keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', this.default.keyPath),
whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', this.default.whitelistMode, 'boolean'),
avoidLocalhost: cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', this.default.avoidLocalhost, 'boolean'),
basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', this.default.basicAuthMode, 'boolean'),
requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', this.default.requestProxyEnabled, 'boolean'),
requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', this.default.requestProxyUrl),
requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', this.default.requestProxyBypass),
getIPv4ListenUrl: function () {
const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4);
return new URL(
(this.ssl ? 'https://' : 'http://') +
(this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
(':' + this.port),
);
},
getIPv6ListenUrl: function () {
const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6);
return new URL(
(this.ssl ? 'https://' : 'http://') +
(this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') +
(':' + this.port),
);
},
getAutorunHostname: async function ({ useIPv6, useIPv4 }) {
if (this.autorunHostname === 'auto') {
let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
if (useIPv6 && useIPv4) {
return (this.avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
}
if (useIPv6) {
return '[::1]';
}
if (useIPv4) {
return '127.0.0.1';
}
}
return this.autorunHostname;
},
getAutorunUrl: function (hostname) {
const autorunPort = (this.autorunPortOverride >= 0) ? this.autorunPortOverride : this.port;
return new URL(
(this.ssl ? 'https://' : 'http://') +
(hostname) +
(':') +
(autorunPort),
);
},
};
if (!this.booleanAutoOptions.includes(result.enableIPv6)) {
console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv6);
result.enableIPv6 = this.default.enableIPv6;
}
if (!this.booleanAutoOptions.includes(result.enableIPv4)) {
console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', this.default.enableIPv4);
result.enableIPv4 = this.default.enableIPv4;
}
return result;
}
}

View File

@ -0,0 +1,42 @@
import fetch from 'node-fetch';
import { forwardFetchResponse } from '../util.js';
/**
* Middleware to proxy requests to a different domain
* @param {import('express').Request} req Express request object
* @param {import('express').Response} res Express response object
*/
export default async function corsProxyMiddleware(req, res) {
const url = req.params.url; // get the url from the request path
// Disallow circular requests
const serverUrl = req.protocol + '://' + req.get('host');
if (url.startsWith(serverUrl)) {
return res.status(400).send('Circular requests are not allowed');
}
try {
const headers = JSON.parse(JSON.stringify(req.headers));
const headersToRemove = [
'x-csrf-token', 'host', 'referer', 'origin', 'cookie',
'x-forwarded-for', 'x-forwarded-protocol', 'x-forwarded-proto',
'x-forwarded-host', 'x-real-ip', 'sec-fetch-mode',
'sec-fetch-site', 'sec-fetch-dest',
];
headersToRemove.forEach(header => delete headers[header]);
const bodyMethods = ['POST', 'PUT', 'PATCH'];
const response = await fetch(url, {
method: req.method,
headers: headers,
body: bodyMethods.includes(req.method) ? JSON.stringify(req.body) : undefined,
});
// Copy over relevant response params to the proxy response
forwardFetchResponse(response, res);
} catch (error) {
res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error);
}
}

View File

@ -38,50 +38,55 @@ const isESModule = (file) => path.extname(file) === '.mjs';
* be called before the server shuts down. * be called before the server shuts down.
*/ */
export async function loadPlugins(app, pluginsPath) { export async function loadPlugins(app, pluginsPath) {
const exitHooks = []; try {
const emptyFn = () => { }; const exitHooks = [];
const emptyFn = () => { };
// Server plugins are disabled. // Server plugins are disabled.
if (!enableServerPlugins) { if (!enableServerPlugins) {
return emptyFn; 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;
} }
// Not a JavaScript file. // Plugins directory does not exist.
if (!isCommonJS(file) && !isESModule(file)) { if (!fs.existsSync(pluginsPath)) {
continue; return emptyFn;
} }
await loadFromFile(app, pluginFilePath, exitHooks); const files = fs.readdirSync(pluginsPath);
}
if (loadedPlugins.size > 0) { // No plugins to load.
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!`); if (files.length === 0) {
} return emptyFn;
}
// Call all plugin "exit" functions at once and wait for them to finish await updatePlugins(pluginsPath);
return () => Promise.all(exitHooks.map(exitFn => exitFn()));
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) { async function loadFromDirectory(app, pluginDirectoryPath, exitHooks) {

386
src/server-startup.js Normal file
View File

@ -0,0 +1,386 @@
import https from 'node:https';
import http from 'node:http';
import fs from 'node:fs';
import { color, urlHostnameToIPv6, getHasIP } from './util.js';
// Express routers
import { router as userDataRouter } from './users.js';
import { router as usersPrivateRouter } from './endpoints/users-private.js';
import { router as usersAdminRouter } from './endpoints/users-admin.js';
import { router as movingUIRouter } from './endpoints/moving-ui.js';
import { router as imagesRouter } from './endpoints/images.js';
import { router as quickRepliesRouter } from './endpoints/quick-replies.js';
import { router as avatarsRouter } from './endpoints/avatars.js';
import { router as themesRouter } from './endpoints/themes.js';
import { router as openAiRouter } from './endpoints/openai.js';
import { router as googleRouter } from './endpoints/google.js';
import { router as anthropicRouter } from './endpoints/anthropic.js';
import { router as tokenizersRouter } from './endpoints/tokenizers.js';
import { router as presetsRouter } from './endpoints/presets.js';
import { router as secretsRouter } from './endpoints/secrets.js';
import { router as thumbnailRouter } from './endpoints/thumbnails.js';
import { router as novelAiRouter } from './endpoints/novelai.js';
import { router as extensionsRouter } from './endpoints/extensions.js';
import { router as assetsRouter } from './endpoints/assets.js';
import { router as filesRouter } from './endpoints/files.js';
import { router as charactersRouter } from './endpoints/characters.js';
import { router as chatsRouter } from './endpoints/chats.js';
import { router as groupsRouter } from './endpoints/groups.js';
import { router as worldInfoRouter } from './endpoints/worldinfo.js';
import { router as statsRouter } from './endpoints/stats.js';
import { router as contentManagerRouter } from './endpoints/content-manager.js';
import { router as settingsRouter } from './endpoints/settings.js';
import { router as backgroundsRouter } from './endpoints/backgrounds.js';
import { router as spritesRouter } from './endpoints/sprites.js';
import { router as stableDiffusionRouter } from './endpoints/stable-diffusion.js';
import { router as hordeRouter } from './endpoints/horde.js';
import { router as vectorsRouter } from './endpoints/vectors.js';
import { router as translateRouter } from './endpoints/translate.js';
import { router as classifyRouter } from './endpoints/classify.js';
import { router as captionRouter } from './endpoints/caption.js';
import { router as searchRouter } from './endpoints/search.js';
import { router as openRouterRouter } from './endpoints/openrouter.js';
import { router as chatCompletionsRouter } from './endpoints/backends/chat-completions.js';
import { router as koboldRouter } from './endpoints/backends/kobold.js';
import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
import { router as scaleAltRouter } from './endpoints/backends/scale-alt.js';
import { router as speechRouter } from './endpoints/speech.js';
import { router as azureRouter } from './endpoints/azure.js';
/**
* @typedef {object} ServerStartupResult
* @property {boolean} v6Failed If the server failed to start on IPv6
* @property {boolean} v4Failed If the server failed to start on IPv4
* @property {boolean} useIPv6 If use IPv6
* @property {boolean} useIPv4 If use IPv4
*/
/**
* Redirect deprecated API endpoints to their replacements.
* @param {import('express').Express} app The Express app to use
*/
export function redirectDeprecatedEndpoints(app) {
/**
* Redirect a deprecated API endpoint URL to its replacement. Because fetch, form submissions, and $.ajax follow
* redirects, this is transparent to client-side code.
* @param {string} src The URL to redirect from.
* @param {string} destination The URL to redirect to.
*/
function redirect(src, destination) {
app.use(src, (req, res) => {
console.warn(`API endpoint ${src} is deprecated; use ${destination} instead`);
// HTTP 301 causes the request to become a GET. 308 preserves the request method.
res.redirect(308, destination);
});
}
redirect('/createcharacter', '/api/characters/create');
redirect('/renamecharacter', '/api/characters/rename');
redirect('/editcharacter', '/api/characters/edit');
redirect('/editcharacterattribute', '/api/characters/edit-attribute');
redirect('/v2/editcharacterattribute', '/api/characters/merge-attributes');
redirect('/deletecharacter', '/api/characters/delete');
redirect('/getcharacters', '/api/characters/all');
redirect('/getonecharacter', '/api/characters/get');
redirect('/getallchatsofcharacter', '/api/characters/chats');
redirect('/importcharacter', '/api/characters/import');
redirect('/dupecharacter', '/api/characters/duplicate');
redirect('/exportcharacter', '/api/characters/export');
redirect('/savechat', '/api/chats/save');
redirect('/getchat', '/api/chats/get');
redirect('/renamechat', '/api/chats/rename');
redirect('/delchat', '/api/chats/delete');
redirect('/exportchat', '/api/chats/export');
redirect('/importgroupchat', '/api/chats/group/import');
redirect('/importchat', '/api/chats/import');
redirect('/getgroupchat', '/api/chats/group/get');
redirect('/deletegroupchat', '/api/chats/group/delete');
redirect('/savegroupchat', '/api/chats/group/save');
redirect('/getgroups', '/api/groups/all');
redirect('/creategroup', '/api/groups/create');
redirect('/editgroup', '/api/groups/edit');
redirect('/deletegroup', '/api/groups/delete');
redirect('/getworldinfo', '/api/worldinfo/get');
redirect('/deleteworldinfo', '/api/worldinfo/delete');
redirect('/importworldinfo', '/api/worldinfo/import');
redirect('/editworldinfo', '/api/worldinfo/edit');
redirect('/getstats', '/api/stats/get');
redirect('/recreatestats', '/api/stats/recreate');
redirect('/updatestats', '/api/stats/update');
redirect('/getbackgrounds', '/api/backgrounds/all');
redirect('/delbackground', '/api/backgrounds/delete');
redirect('/renamebackground', '/api/backgrounds/rename');
redirect('/downloadbackground', '/api/backgrounds/upload'); // yes, the downloadbackground endpoint actually uploads one
redirect('/savetheme', '/api/themes/save');
redirect('/getuseravatars', '/api/avatars/get');
redirect('/deleteuseravatar', '/api/avatars/delete');
redirect('/uploaduseravatar', '/api/avatars/upload');
redirect('/deletequickreply', '/api/quick-replies/delete');
redirect('/savequickreply', '/api/quick-replies/save');
redirect('/uploadimage', '/api/images/upload');
redirect('/listimgfiles/:folder', '/api/images/list/:folder');
redirect('/api/content/import', '/api/content/importURL');
redirect('/savemovingui', '/api/moving-ui/save');
redirect('/api/serpapi/search', '/api/search/serpapi');
redirect('/api/serpapi/visit', '/api/search/visit');
redirect('/api/serpapi/transcript', '/api/search/transcript');
}
/**
* Setup the routers for the endpoints.
* @param {import('express').Express} app The Express app to use
*/
export function setupPrivateEndpoints(app) {
app.use('/', userDataRouter);
app.use('/api/users', usersPrivateRouter);
app.use('/api/users', usersAdminRouter);
app.use('/api/moving-ui', movingUIRouter);
app.use('/api/images', imagesRouter);
app.use('/api/quick-replies', quickRepliesRouter);
app.use('/api/avatars', avatarsRouter);
app.use('/api/themes', themesRouter);
app.use('/api/openai', openAiRouter);
app.use('/api/google', googleRouter);
app.use('/api/anthropic', anthropicRouter);
app.use('/api/tokenizers', tokenizersRouter);
app.use('/api/presets', presetsRouter);
app.use('/api/secrets', secretsRouter);
app.use('/thumbnail', thumbnailRouter);
app.use('/api/novelai', novelAiRouter);
app.use('/api/extensions', extensionsRouter);
app.use('/api/assets', assetsRouter);
app.use('/api/files', filesRouter);
app.use('/api/characters', charactersRouter);
app.use('/api/chats', chatsRouter);
app.use('/api/groups', groupsRouter);
app.use('/api/worldinfo', worldInfoRouter);
app.use('/api/stats', statsRouter);
app.use('/api/backgrounds', backgroundsRouter);
app.use('/api/sprites', spritesRouter);
app.use('/api/content', contentManagerRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/sd', stableDiffusionRouter);
app.use('/api/horde', hordeRouter);
app.use('/api/vector', vectorsRouter);
app.use('/api/translate', translateRouter);
app.use('/api/extra/classify', classifyRouter);
app.use('/api/extra/caption', captionRouter);
app.use('/api/search', searchRouter);
app.use('/api/backends/text-completions', textCompletionsRouter);
app.use('/api/openrouter', openRouterRouter);
app.use('/api/backends/kobold', koboldRouter);
app.use('/api/backends/chat-completions', chatCompletionsRouter);
app.use('/api/backends/scale-alt', scaleAltRouter);
app.use('/api/speech', speechRouter);
app.use('/api/azure', azureRouter);
}
/**
* Utilities for starting the express server.
*/
export class ServerStartup {
/**
* Creates a new ServerStartup instance.
* @param {import('express').Express} app The Express app to use
* @param {import('./command-line.js').CommandLineArguments} cliArgs The command-line arguments
*/
constructor(app, cliArgs) {
this.app = app;
this.cliArgs = cliArgs;
}
/**
* Prints a fatal error message and exits the process.
* @param {string} message
*/
#fatal(message) {
console.error(color.red(message));
process.exit(1);
}
/**
* Checks if SSL options are valid. If not, it will print an error message and exit the process.
* @returns {void}
*/
#verifySslOptions() {
if (!this.cliArgs.ssl) return;
if (!this.cliArgs.certPath) {
this.#fatal('Error: SSL certificate path is required when using HTTPS. Check your config');
}
if (!this.cliArgs.keyPath) {
this.#fatal('Error: SSL key path is required when using HTTPS. Check your config');
}
if (!fs.existsSync(this.cliArgs.certPath)) {
this.#fatal('Error: SSL certificate path does not exist');
}
if (!fs.existsSync(this.cliArgs.keyPath)) {
this.#fatal('Error: SSL key path does not exist');
}
}
/**
* Creates an HTTPS server.
* @param {URL} url The URL to listen on
* @param {number} ipVersion the ip version to use
* @returns {Promise<void>} A promise that resolves when the server is listening
*/
#createHttpsServer(url, ipVersion) {
this.#verifySslOptions();
return new Promise((resolve, reject) => {
const sslOptions = {
cert: fs.readFileSync(this.cliArgs.certPath),
key: fs.readFileSync(this.cliArgs.keyPath),
};
const server = https.createServer(sslOptions, this.app);
server.on('error', reject);
server.on('listening', resolve);
let host = url.hostname;
if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
server.listen({
host: host,
port: Number(url.port || 443),
// see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
ipv6Only: true,
});
});
}
/**
* Creates an HTTP server.
* @param {URL} url The URL to listen on
* @param {number} ipVersion the ip version to use
* @returns {Promise<void>} A promise that resolves when the server is listening
*/
#createHttpServer(url, ipVersion) {
return new Promise((resolve, reject) => {
const server = http.createServer(this.app);
server.on('error', reject);
server.on('listening', resolve);
let host = url.hostname;
if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
server.listen({
host: host,
port: Number(url.port || 80),
// see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
ipv6Only: true,
});
});
}
/**
* Starts the server using http or https depending on config
* @param {boolean} useIPv6 If use IPv6
* @param {boolean} useIPv4 If use IPv4
* @returns {Promise<[boolean, boolean]>} A promise that resolves with an array of booleans indicating if the server failed to start on IPv6 and IPv4, respectively
*/
async #startHTTPorHTTPS(useIPv6, useIPv4) {
let v6Failed = false;
let v4Failed = false;
const createFunc = this.cliArgs.ssl ? this.#createHttpsServer.bind(this) : this.#createHttpServer.bind(this);
if (useIPv6) {
try {
await createFunc(this.cliArgs.getIPv6ListenUrl(), 6);
} catch (error) {
console.error('Warning: failed to start server on IPv6');
console.error(error);
v6Failed = true;
}
}
if (useIPv4) {
try {
await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
} catch (error) {
console.error('Warning: failed to start server on IPv4');
console.error(error);
v4Failed = true;
}
}
return [v6Failed, v4Failed];
}
/**
* Handles the case where the server failed to start on one or both protocols.
* @param {ServerStartupResult} result The results of the server startup
* @returns {void}
*/
#handleServerListenFail({ v6Failed, v4Failed, useIPv6, useIPv4 }) {
if (v6Failed && !useIPv4) {
this.#fatal('Error: Failed to start server on IPv6 and IPv4 disabled');
}
if (v4Failed && !useIPv6) {
this.#fatal('Error: Failed to start server on IPv4 and IPv6 disabled');
}
if (v6Failed && v4Failed) {
this.#fatal('Error: Failed to start server on both IPv6 and IPv4');
}
}
/**
* Performs the server startup.
* @returns {Promise<ServerStartupResult>} A promise that resolves with an object containing the results of the server startup
*/
async start() {
let useIPv6 = (this.cliArgs.enableIPv6 === true);
let useIPv4 = (this.cliArgs.enableIPv4 === true);
if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') {
const ipQuery = await getHasIP();
let hasIPv6 = false, hasIPv4 = false;
hasIPv6 = this.cliArgs.listen ? ipQuery.hasIPv6Any : ipQuery.hasIPv6Local;
if (this.cliArgs.enableIPv6 === 'auto') {
useIPv6 = hasIPv6;
}
if (hasIPv6) {
if (useIPv6) {
console.log(color.green('IPv6 support detected'));
} else {
console.log('IPv6 support detected (but disabled)');
}
}
hasIPv4 = this.cliArgs.listen ? ipQuery.hasIPv4Any : ipQuery.hasIPv4Local;
if (this.cliArgs.enableIPv4 === 'auto') {
useIPv4 = hasIPv4;
}
if (hasIPv4) {
if (useIPv4) {
console.log(color.green('IPv4 support detected'));
} else {
console.log('IPv4 support detected (but disabled)');
}
}
if (this.cliArgs.enableIPv6 === 'auto' && this.cliArgs.enableIPv4 === 'auto') {
if (!hasIPv6 && !hasIPv4) {
console.error('Both IPv6 and IPv4 are not detected');
process.exit(1);
}
}
}
if (!useIPv6 && !useIPv4) {
console.error('Both IPv6 and IPv4 are disabled or not detected');
process.exit(1);
}
const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
const result = { v6Failed, v4Failed, useIPv6, useIPv4 };
this.#handleServerListenFail(result);
return result;
}
}

View File

@ -14,7 +14,7 @@ import archiver from 'archiver';
import _ from 'lodash'; import _ from 'lodash';
import { sync as writeFileAtomicSync } from 'write-file-atomic'; import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE } from './constants.js'; import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
import { getConfigValue, color, delay, generateTimestamp } from './util.js'; import { getConfigValue, color, delay, generateTimestamp } from './util.js';
import { readSecret, writeSecret } from './endpoints/secrets.js'; import { readSecret, writeSecret } from './endpoints/secrets.js';
import { getContentOfType } from './endpoints/content-manager.js'; import { getContentOfType } from './endpoints/content-manager.js';
@ -120,6 +120,93 @@ export async function ensurePublicDirectoriesExist() {
return directoriesList; 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<void>}
*/
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);
if (fs.existsSync(uploadsPath)) {
const uploads = fs.readdirSync(uploadsPath);
if (!uploads.length) {
return;
}
console.debug(`Cleaning uploads folder (${uploads.length} files)`);
uploads.forEach(file => {
const pathToFile = path.join(uploadsPath, file);
fs.unlinkSync(pathToFile);
});
}
} catch (err) {
console.error(err);
}
}
/** /**
* Gets a list of all user directories. * Gets a list of all user directories.
* @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories * @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
@ -478,6 +565,25 @@ export function getCookieSessionName() {
return `session-${suffix}`; return `session-${suffix}`;
} }
export function getSessionCookieAge() {
// Defaults to "no expiration" if not set
const configValue = getConfigValue('sessionTimeout', -1, 'number');
// Convert to milliseconds
if (configValue > 0) {
return configValue * 1000;
}
// "No expiration" is just 400 days as per RFC 6265
if (configValue < 0) {
return 400 * 24 * 60 * 60 * 1000;
}
// 0 means session cookie is deleted when the browser session ends
// (depends on the implementation of the browser)
return undefined;
}
/** /**
* Hashes a password using scrypt with the provided salt. * Hashes a password using scrypt with the provided salt.
* @param {string} password Password to hash * @param {string} password Password to hash
@ -777,6 +883,31 @@ export function requireLoginMiddleware(request, response, next) {
return 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. * 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 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from

View File

@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { promises as dnsPromise } from 'node:dns'; import { promises as dnsPromise } from 'node:dns';
import os from 'node:os';
import yaml from 'yaml'; import yaml from 'yaml';
import { sync as commandExistsSync } from 'command-exists'; import { sync as commandExistsSync } from 'command-exists';
@ -770,6 +771,53 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
} }
} }
/**
* Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
*
* @typedef {object} IPQueryResult
* @property {boolean} hasIPv6Any - Whether the computer has any IPv6 address, including (`::1`).
* @property {boolean} hasIPv4Any - Whether the computer has any IPv4 address, including (`127.0.0.1`).
* @property {boolean} hasIPv6Local - Whether the computer has local IPv6 address (`::1`).
* @property {boolean} hasIPv4Local - Whether the computer has local IPv4 address (`127.0.0.1`).
* @returns {Promise<IPQueryResult>} A promise that resolves to an array containing:
*/
export async function getHasIP() {
let hasIPv6Any = false;
let hasIPv6Local = false;
let hasIPv4Any = false;
let hasIPv4Local = false;
const interfaces = os.networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (iface === undefined) {
continue;
}
for (const info of iface) {
if (info.family === 'IPv6') {
hasIPv6Any = true;
if (info.address === '::1') {
hasIPv6Local = true;
}
}
if (info.family === 'IPv4') {
hasIPv4Any = true;
if (info.address === '127.0.0.1') {
hasIPv4Local = true;
}
}
if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
}
if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
}
return { hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local };
}
/** /**
* Converts various JavaScript primitives to boolean values. * Converts various JavaScript primitives to boolean values.
* Handles special case for "true"/"false" strings (case-insensitive) * Handles special case for "true"/"false" strings (case-insensitive)
@ -809,10 +857,10 @@ export function stringToBool(str) {
export function setupLogLevel() { export function setupLogLevel() {
const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number'); const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {}; globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { };
globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {}; globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { };
globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => {}; globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { };
globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => {}; globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { };
} }
/** /**
@ -1005,3 +1053,16 @@ export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
return null; return null;
} }
/**
* Set the title of the terminal window
* @param {string} title Desired title for the window
*/
export function setWindowTitle(title) {
if (process.platform === 'win32') {
process.title = title;
}
else {
process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
}
}