mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Refactor server startup
This commit is contained in:
@@ -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
|
||||
|
265
src/command-line.js
Normal file
265
src/command-line.js
Normal file
@@ -0,0 +1,265 @@
|
||||
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
|
||||
* @property {string} dataRoot
|
||||
* @property {number} port
|
||||
* @property {boolean} listen
|
||||
* @property {string} listenAddressIPv6
|
||||
* @property {string} listenAddressIPv4
|
||||
* @property {boolean|string} enableIPv4
|
||||
* @property {boolean|string} enableIPv6
|
||||
* @property {boolean} dnsPreferIPv6
|
||||
* @property {boolean} autorun
|
||||
* @property {string} autorunHostname
|
||||
* @property {number} autorunPortOverride
|
||||
* @property {boolean} enableCorsProxy
|
||||
* @property {boolean} disableCsrf
|
||||
* @property {boolean} ssl
|
||||
* @property {string} certPath
|
||||
* @property {string} keyPath
|
||||
* @property {boolean} whitelistMode
|
||||
* @property {boolean} avoidLocalhost
|
||||
* @property {boolean} basicAuthMode
|
||||
* @property {boolean} requestProxyEnabled
|
||||
* @property {string} requestProxyUrl
|
||||
* @property {string[]} requestProxyBypass
|
||||
* @property {function(): URL} getIPv4ListenUrl
|
||||
* @property {function(): URL} getIPv6ListenUrl
|
||||
* @property {function(boolean, boolean): Promise<string>} getAutorunHostname
|
||||
* @property {function(string): URL} getAutorunUrl
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the hostname to use for autorun in the browser.
|
||||
* @param {boolean} useIPv6 If use IPv6
|
||||
* @param {boolean} useIPv4 If use IPv4
|
||||
* @returns {Promise<string>} The hostname to use for autorun
|
||||
*/
|
||||
|
||||
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.
|
||||
* @param {string[]} args Process startup arguments.
|
||||
* @returns {CommandLineArguments} Parsed command line arguments.
|
||||
*/
|
||||
parse(args) {
|
||||
const cliArguments = yargs(hideBin(args))
|
||||
.usage('Usage: <your-start-script> <command> [options]')
|
||||
.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 port under which SillyTavern will run.\nIf not provided falls back to yaml config \'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\nIf not provided falls back to yaml config \'dnsPreferIPv6\'.',
|
||||
}).option('autorun', {
|
||||
type: 'boolean',
|
||||
default: null,
|
||||
describe: 'Automatically launch SillyTavern in the browser.\nAutorun is automatically disabled if --ssl is set to true.\nIf not provided falls back to yaml config \'autorun\'.',
|
||||
}).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: 'SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If false, will limit it only to internal localhost (127.0.0.1).\nIf not provided falls back to yaml config \'listen\'.',
|
||||
}).option('listenAddressIPv6', {
|
||||
type: 'string',
|
||||
default: null,
|
||||
describe: 'Set SillyTavern to listen to a specific IPv6 address. If not set, it will fallback to listen to all.',
|
||||
}).option('listenAddressIPv4', {
|
||||
type: 'string',
|
||||
default: null,
|
||||
describe: 'Set SillyTavern to listen to a specific IPv4 address. If not set, it will fallback to listen to all.',
|
||||
}).option('corsProxy', {
|
||||
type: 'boolean',
|
||||
default: null,
|
||||
describe: 'Enables CORS proxy\nIf not provided falls back to yaml config \'enableCorsProxy\'',
|
||||
}).option('disableCsrf', {
|
||||
type: 'boolean',
|
||||
default: null,
|
||||
describe: 'Disables CSRF protection',
|
||||
}).option('ssl', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe: 'Enables SSL',
|
||||
}).option('certPath', {
|
||||
type: 'string',
|
||||
default: 'certs/cert.pem',
|
||||
describe: 'Path to your certificate file.',
|
||||
}).option('keyPath', {
|
||||
type: 'string',
|
||||
default: 'certs/privkey.pem',
|
||||
describe: 'Path to your 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;
|
||||
}
|
||||
}
|
42
src/middleware/corsProxy.js
Normal file
42
src/middleware/corsProxy.js
Normal 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);
|
||||
}
|
||||
}
|
359
src/server-startup.js
Normal file
359
src/server-startup.js
Normal file
@@ -0,0 +1,359 @@
|
||||
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 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('/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @throws {Error} If the server fails to start
|
||||
*/
|
||||
#createHttpsServer(url, ipVersion) {
|
||||
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
|
||||
* @throws {Error} If the server fails to start
|
||||
*/
|
||||
#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('non-fatal error: failed to start server on IPv6');
|
||||
console.error(error);
|
||||
|
||||
v6Failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (useIPv4) {
|
||||
try {
|
||||
await createFunc(this.cliArgs.getIPv4ListenUrl(), 4);
|
||||
} catch (error) {
|
||||
console.error('non-fatal error: 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 {boolean} v6Failed If the server failed to start on IPv6
|
||||
* @param {boolean} v4Failed If the server failed to start on IPv4
|
||||
* @param {boolean} useIPv6 If use IPv6
|
||||
* @param {boolean} useIPv4 If use IPv4
|
||||
* @returns {void}
|
||||
*/
|
||||
#handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) {
|
||||
if (v6Failed && !useIPv4) {
|
||||
console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (v4Failed && !useIPv6) {
|
||||
console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (v6Failed && v4Failed) {
|
||||
console.error(color.red('fatal error: Failed to start server on both IPv6 and IPv4'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
let hasIPv6 = false,
|
||||
hasIPv4 = false,
|
||||
hasIPv6Local = false,
|
||||
hasIPv4Local = false,
|
||||
hasIPv6Any = false,
|
||||
hasIPv4Any = false;
|
||||
|
||||
if (this.cliArgs.enableIPv6 === 'auto' || this.cliArgs.enableIPv4 === 'auto') {
|
||||
[hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP();
|
||||
|
||||
hasIPv6 = this.cliArgs.listen ? hasIPv6Any : 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 ? hasIPv4Any : 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');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [v6Failed, v4Failed] = await this.#startHTTPorHTTPS(useIPv6, useIPv4);
|
||||
this.#handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4);
|
||||
return { v6Failed, v4Failed, useIPv6, useIPv4 };
|
||||
}
|
||||
}
|
42
src/users.js
42
src/users.js
@@ -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,27 @@ export async function ensurePublicDirectoriesExist() {
|
||||
return directoriesList;
|
||||
}
|
||||
|
||||
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 +499,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
|
||||
|
64
src/util.js
64
src/util.js
@@ -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 'os';
|
||||
|
||||
import yaml from 'yaml';
|
||||
import { sync as commandExistsSync } from 'command-exists';
|
||||
@@ -770,6 +771,56 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
|
||||
*
|
||||
* @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing:
|
||||
* - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`).
|
||||
* - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`).
|
||||
* - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`).
|
||||
* - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`).
|
||||
*/
|
||||
export async function getHasIP() {
|
||||
let hasIPv6 = false;
|
||||
let hasIPv6Local = false;
|
||||
|
||||
let hasIPv4 = 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') {
|
||||
hasIPv6 = true;
|
||||
if (info.address === '::1') {
|
||||
hasIPv6Local = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.family === 'IPv4') {
|
||||
hasIPv4 = true;
|
||||
if (info.address === '127.0.0.1') {
|
||||
hasIPv4Local = true;
|
||||
}
|
||||
}
|
||||
if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
|
||||
}
|
||||
if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
|
||||
}
|
||||
return [
|
||||
hasIPv6,
|
||||
hasIPv4,
|
||||
hasIPv6Local,
|
||||
hasIPv4Local,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts various JavaScript primitives to boolean values.
|
||||
* Handles special case for "true"/"false" strings (case-insensitive)
|
||||
@@ -1005,3 +1056,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`);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user