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
| Option | Description | Type |
|-------------------------|------------------------------------------------------------------------------------------------------|----------|
| `--version` | Show version number | boolean |
| `--enableIPv6` | Enables IPv6. | boolean |
| `--enableIPv4` | Enables IPv4. | boolean |
| `--port` | Sets the port under which SillyTavern will run. If not provided falls back to yaml config 'port'. | number |
| `--dnsPreferIPv6` | Prefers IPv6 for dns. If not provided falls back to yaml config 'preferIPv6'. | boolean |
| `--autorun` | Automatically launch SillyTavern in the browser. If not provided falls back to yaml config 'autorun'.| boolean |
| `--autorunHostname` | The autorun hostname, probably best left on 'auto'. | string |
| `--autorunPortOverride` | Overrides the port for autorun. | string |
| `--listen` | SillyTavern is listening on all network interfaces. If not provided falls back to yaml config 'listen'.| boolean |
| `--corsProxy` | Enables CORS proxy. If not provided falls back to yaml config 'enableCorsProxy'. | boolean |
| `--disableCsrf` | Disables CSRF protection | boolean |
| `--ssl` | Enables SSL | boolean |
| `--certPath` | Path to your certificate file. | string |
| `--keyPath` | Path to your private key file. | string |
| `--whitelist` | Enables whitelist mode | boolean |
| `--dataRoot` | Root directory for data storage | string |
| `--avoidLocalhost` | Avoids using 'localhost' for autorun in auto mode. | boolean |
| `--basicAuthMode` | Enables basic authentication | 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 |
> \[!TIP]
> None of the arguments are required. If you don't provide them, SillyTavern will use the settings in `config.yaml`.
| Option | Description | Type |
|-------------------------|----------------------------------------------------------------------|----------|
| `--version` | Show version number | boolean |
| `--dataRoot` | Root directory for data storage | string |
| `--port` | Sets the port under which SillyTavern will run | number |
| `--listen` | SillyTavern will listen on all network interfaces | boolean |
| `--whitelist` | Enables whitelist mode | boolean |
| `--basicAuthMode` | Enables basic authentication | boolean |
| `--enableIPv4` | Enables IPv4 protocol | boolean |
| `--enableIPv6` | Enables IPv6 protocol | boolean |
| `--listenAddressIPv4` | Specific IPv4 address to listen to | string |
| `--listenAddressIPv6` | Specific IPv6 address to listen to | string |
| `--dnsPreferIPv6` | Prefers IPv6 for DNS | boolean |
| `--ssl` | Enables SSL | boolean |
| `--certPath` | Path to your certificate file | string |
| `--keyPath` | Path to your private key file | string |
| `--autorun` | Automatically launch SillyTavern in the browser | boolean |
| `--autorunHostname` | Autorun hostname | string |
| `--autorunPortOverride` | Overrides the port for autorun | string |
| `--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
@ -351,10 +356,29 @@ You may also want to configure SillyTavern user profiles with (optional) passwor
## Performance issues?
### General tips
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).
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
**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.
# - Specify a port to override the default.
autorunPortOverride: -1
# -- SSL options --
ssl:
enabled: false
certPath: "./certs/cert.pem"
keyPath: "./certs/privkey.pem"
# -- SECURITY CONFIGURATION --
# Toggle whitelist mode
whitelistMode: true

6
index.d.ts vendored
View File

@ -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;
}

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.
*/
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) {

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 { 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 { readSecret, writeSecret } from './endpoints/secrets.js';
import { getContentOfType } from './endpoints/content-manager.js';
@ -120,6 +120,93 @@ 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<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.
* @returns {Promise<import('./users.js').UserDirectoryList[]>} - The list of user directories
@ -478,6 +565,25 @@ export function getCookieSessionName() {
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.
* @param {string} password Password to hash
@ -777,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

View File

@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
import { createRequire } from 'node:module';
import { Buffer } from 'node:buffer';
import { promises as dnsPromise } from 'node:dns';
import os from 'node:os';
import yaml from 'yaml';
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.
* Handles special case for "true"/"false" strings (case-insensitive)
@ -809,10 +857,10 @@ export function stringToBool(str) {
export function setupLogLevel() {
const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};
globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};
globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => {};
globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => {};
globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { };
globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { };
globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { };
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);
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`);
}
}