Merge pull request #3521 from SillyTavern/immutable-config Immutable config

e98172bb0eafe3bfe4a4bf63cd90829e293c4708

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
21 files changed, +169 -89Showing whitespace changes
default/config.yaml+0 -2
@@ -77,8 +77,6 @@ perUserBasicAuth: false
77## Set to 0 to expire session when the browser is closed77## Set to 0 to expire session when the browser is closed
78## Set to a negative number to disable session expiration78## Set to a negative number to disable session expiration
79sessionTimeout: -179sessionTimeout: -1
80# Used to sign session cookies. Will be auto-generated if not set
81cookieSecret: ''
82# Disable CSRF protection - NOT RECOMMENDED80# Disable CSRF protection - NOT RECOMMENDED
83disableCsrfProtection: false81disableCsrfProtection: false
84# Disable startup security checks - NOT RECOMMENDED82# Disable startup security checks - NOT RECOMMENDED
post-install.js+19 -1
@@ -109,6 +109,15 @@ const keyMigrationMap = [
109 newKey: 'logging.minLogLevel',109 newKey: 'logging.minLogLevel',
110 migrate: (value) => value,110 migrate: (value) => value,
111 },111 },
112 // uncomment one release after 1.12.13
113 /*
114 {
115 oldKey: 'cookieSecret',
116 newKey: 'cookieSecret',
117 migrate: () => void 0,
118 remove: true,
119 },
120 */
112];121];
113122
114/**123/**
@@ -168,8 +177,17 @@ function addMissingConfigValues() {
168177
169 // Migrate old keys to new keys178 // Migrate old keys to new keys
170 const migratedKeys = [];179 const migratedKeys = [];
171 for (const { oldKey, newKey, migrate } of keyMigrationMap) {180 for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
172 if (_.has(config, oldKey)) {181 if (_.has(config, oldKey)) {
182 if (remove) {
183 _.unset(config, oldKey);
184 migratedKeys.push({
185 oldKey,
186 newValue: void 0,
187 });
188 continue;
189 }
190
173 const oldValue = _.get(config, oldKey);191 const oldValue = _.get(config, oldKey);
174 const newValue = migrate(oldValue);192 const newValue = migrate(oldValue);
175 _.set(config, newKey, newValue);193 _.set(config, newKey, newValue);
server.js+26 -25
@@ -261,47 +261,47 @@ app.use(responseTime());
261261
262262
263/** @type {number} */263/** @type {number} */
264const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getConfigValue('port', DEFAULT_PORT);264const server_port = cliArguments.port ?? getConfigValue('port', DEFAULT_PORT, 'number');
265/** @type {boolean} */265/** @type {boolean} */
266const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl;266const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN, 'boolean')) && !cliArguments.ssl;
267/** @type {boolean} */267/** @type {boolean} */
268const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);268const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN, 'boolean');
269/** @type {string} */269/** @type {string} */
270const listenAddressIPv6 = cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', DEFAULT_LISTEN_ADDRESS_IPV6);270const listenAddressIPv6 = cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', DEFAULT_LISTEN_ADDRESS_IPV6);
271/** @type {string} */271/** @type {string} */
272const listenAddressIPv4 = cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', DEFAULT_LISTEN_ADDRESS_IPV4);272const listenAddressIPv4 = cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', DEFAULT_LISTEN_ADDRESS_IPV4);
273/** @type {boolean} */273/** @type {boolean} */
274const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY);274const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY, 'boolean');
275const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST);275const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST, 'boolean');
276/** @type {string} */276/** @type {string} */
277const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');277globalThis.DATA_ROOT = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
278/** @type {boolean} */278/** @type {boolean} */
279const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED);279const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED, 'boolean');
280const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH);280const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH, 'boolean');
281const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH);281const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH, 'boolean');
282/** @type {boolean} */282/** @type {boolean} */
283const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS);283const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS, 'boolean');
284284
285const uploadsPath = path.join(dataRoot, UPLOADS_DIRECTORY);285const uploadsPath = path.join(globalThis.DATA_ROOT, UPLOADS_DIRECTORY);
286286
287287
288/** @type {boolean | "auto"} */288/** @type {boolean | string} */
289let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6);289let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6)) ?? DEFAULT_ENABLE_IPV6;
290/** @type {boolean | "auto"} */290/** @type {boolean | string} */
291let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4);291let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4)) ?? DEFAULT_ENABLE_IPV4;
292292
293/** @type {string} */293/** @type {string} */
294const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);294const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);
295/** @type {number} */295/** @type {number} */
296const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT);296const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT, 'number');
297297
298/** @type {boolean} */298/** @type {boolean} */
299const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6);299const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6, 'boolean');
300300
301/** @type {boolean} */301/** @type {boolean} */
302const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST);302const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST, 'boolean');
303303
304const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED);304const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED, 'boolean');
305const proxyUrl = cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', DEFAULT_PROXY_URL);305const proxyUrl = cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', DEFAULT_PROXY_URL);
306const proxyBypass = cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', DEFAULT_PROXY_BYPASS);306const proxyBypass = cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', DEFAULT_PROXY_BYPASS);
307307
@@ -403,7 +403,7 @@ if (enableCorsProxy) {
403403
404function getSessionCookieAge() {404function getSessionCookieAge() {
405 // Defaults to "no expiration" if not set405 // Defaults to "no expiration" if not set
406 const configValue = getConfigValue('sessionTimeout', -1);406 const configValue = getConfigValue('sessionTimeout', -1, 'number');
407407
408 // Convert to milliseconds408 // Convert to milliseconds
409 if (configValue > 0) {409 if (configValue > 0) {
@@ -474,7 +474,7 @@ app.use(cookieSession({
474 sameSite: 'strict',474 sameSite: 'strict',
475 httpOnly: true,475 httpOnly: true,
476 maxAge: getSessionCookieAge(),476 maxAge: getSessionCookieAge(),
477 secret: getCookieSecret(),477 secret: getCookieSecret(globalThis.DATA_ROOT),
478}));478}));
479479
480app.use(setUserDataMiddleware);480app.use(setUserDataMiddleware);
@@ -884,8 +884,9 @@ const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
884 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',884 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
885 ));885 ));
886 } else if (!perUserBasicAuth) {886 } else if (!perUserBasicAuth) {
887 const basicAuthUser = getConfigValue('basicAuthUser', {});887 const basicAuthUserName = getConfigValue('basicAuthUser.username', '');
888 if (!basicAuthUser?.username || !basicAuthUser?.password) {888 const basicAuthUserPassword = getConfigValue('basicAuthUser.password', '');
889 if (!basicAuthUserName || !basicAuthUserPassword) {
889 console.warn(color.yellow(890 console.warn(color.yellow(
890 'Basic Authentication is enabled, but username or password is not set or empty!',891 'Basic Authentication is enabled, but username or password is not set or empty!',
891 ));892 ));
@@ -932,7 +933,7 @@ function setWindowTitle(title) {
932function logSecurityAlert(message) {933function logSecurityAlert(message) {
933 if (basicAuthMode || enableWhitelist) return; // safe!934 if (basicAuthMode || enableWhitelist) return; // safe!
934 console.error(color.red(message));935 console.error(color.red(message));
935 if (getConfigValue('securityOverride', false)) {936 if (getConfigValue('securityOverride', false, 'boolean')) {
936 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));937 console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
937 return;938 return;
938 }939 }
@@ -1145,7 +1146,7 @@ function apply404Middleware() {
1145}1146}
11461147
1147// User storage module needs to be initialized before starting the server1148// User storage module needs to be initialized before starting the server
1148initUserStorage(dataRoot)1149initUserStorage(globalThis.DATA_ROOT)
1149 .then(ensurePublicDirectoriesExist)1150 .then(ensurePublicDirectoriesExist)
1150 .then(migrateUserData)1151 .then(migrateUserData)
1151 .then(migrateSystemPrompts)1152 .then(migrateSystemPrompts)
src/endpoints/backends/chat-completions.js+4 -4
@@ -107,8 +107,8 @@ async function sendClaudeRequest(request, response) {
107 const apiUrl = new URL(request.body.reverse_proxy || API_CLAUDE).toString();107 const apiUrl = new URL(request.body.reverse_proxy || API_CLAUDE).toString();
108 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE);108 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE);
109 const divider = '-'.repeat(process.stdout.columns);109 const divider = '-'.repeat(process.stdout.columns);
110 const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false) && request.body.model.startsWith('claude-3');110 const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false, 'boolean') && request.body.model.startsWith('claude-3');
111 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);111 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');
112 // Disabled if not an integer or negative, or if the model doesn't support it112 // Disabled if not an integer or negative, or if the model doesn't support it
113 if (!Number.isInteger(cachingAtDepth) || cachingAtDepth < 0 || !request.body.model.startsWith('claude-3')) {113 if (!Number.isInteger(cachingAtDepth) || cachingAtDepth < 0 || !request.body.model.startsWith('claude-3')) {
114 cachingAtDepth = -1;114 cachingAtDepth = -1;
@@ -1004,7 +1004,7 @@ router.post('/generate', jsonParser, function (request, response) {
1004 bodyParams.logprobs = true;1004 bodyParams.logprobs = true;
1005 }1005 }
10061006
1007 if (getConfigValue('openai.randomizeUserId', false)) {1007 if (getConfigValue('openai.randomizeUserId', false, 'boolean')) {
1008 bodyParams['user'] = uuidv4();1008 bodyParams['user'] = uuidv4();
1009 }1009 }
1010 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {1010 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.OPENROUTER) {
@@ -1040,7 +1040,7 @@ router.post('/generate', jsonParser, function (request, response) {
1040 bodyParams['route'] = 'fallback';1040 bodyParams['route'] = 'fallback';
1041 }1041 }
10421042
1043 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);1043 let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number');
1044 if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model?.startsWith('anthropic/claude-3')) {1044 if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model?.startsWith('anthropic/claude-3')) {
1045 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth);1045 cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth);
1046 }1046 }
src/endpoints/backends/text-completions.js+2 -2
@@ -372,8 +372,8 @@ router.post('/generate', jsonParser, async function (request, response) {
372 }372 }
373373
374 if (request.body.api_type === TEXTGEN_TYPES.OLLAMA) {374 if (request.body.api_type === TEXTGEN_TYPES.OLLAMA) {
375 const keepAlive = getConfigValue('ollama.keepAlive', -1);375 const keepAlive = Number(getConfigValue('ollama.keepAlive', -1, 'number'));
376 const numBatch = getConfigValue('ollama.batchSize', -1);376 const numBatch = Number(getConfigValue('ollama.batchSize', -1, 'number'));
377 if (numBatch > 0) {377 if (numBatch > 0) {
378 request.body['num_batch'] = numBatch;378 request.body['num_batch'] = numBatch;
379 }379 }
src/endpoints/characters.js+1 -1
@@ -24,7 +24,7 @@ import { importRisuSprites } from './sprites.js';
24const defaultAvatarPath = './public/img/ai4.png';24const defaultAvatarPath = './public/img/ai4.png';
2525
26// KV-store for parsed character data26// KV-store for parsed character data
27const cacheCapacity = Number(getConfigValue('cardsCacheCapacity', 100)); // MB27const cacheCapacity = Number(getConfigValue('cardsCacheCapacity', 100, 'number')); // MB
28// With 100 MB limit it would take roughly 3000 characters to reach this limit28// With 100 MB limit it would take roughly 3000 characters to reach this limit
29const characterDataCache = new MemoryLimitedMap(1024 * 1024 * cacheCapacity);29const characterDataCache = new MemoryLimitedMap(1024 * 1024 * cacheCapacity);
30// Some Android devices require tighter memory management30// Some Android devices require tighter memory management
src/endpoints/chats.js+3 -3
@@ -19,9 +19,9 @@ import {
19 formatBytes,19 formatBytes,
20} from '../util.js';20} from '../util.js';
2121
22const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true);22const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
23const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1));23const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number'));
24const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000));24const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number'));
2525
26/**26/**
27 * Saves a chat to the backups directory.27 * Saves a chat to the backups directory.
src/endpoints/content-manager.js+1 -1
@@ -165,7 +165,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
165 */165 */
166export async function checkForNewContent(directoriesList, forceCategories = []) {166export async function checkForNewContent(directoriesList, forceCategories = []) {
167 try {167 try {
168 const contentCheckSkip = getConfigValue('skipContentCheck', false);168 const contentCheckSkip = getConfigValue('skipContentCheck', false, 'boolean');
169 if (contentCheckSkip && forceCategories?.length === 0) {169 if (contentCheckSkip && forceCategories?.length === 0) {
170 return;170 return;
171 }171 }
src/endpoints/secrets.js+2 -2
@@ -183,7 +183,7 @@ router.post('/read', jsonParser, (request, response) => {
183});183});
184184
185router.post('/view', jsonParser, async (request, response) => {185router.post('/view', jsonParser, async (request, response) => {
186 const allowKeysExposure = getConfigValue('allowKeysExposure', false);186 const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
187187
188 if (!allowKeysExposure) {188 if (!allowKeysExposure) {
189 console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');189 console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
@@ -205,7 +205,7 @@ router.post('/view', jsonParser, async (request, response) => {
205});205});
206206
207router.post('/find', jsonParser, (request, response) => {207router.post('/find', jsonParser, (request, response) => {
208 const allowKeysExposure = getConfigValue('allowKeysExposure', false);208 const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
209 const key = request.body.key;209 const key = request.body.key;
210210
211 if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {211 if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
src/endpoints/settings.js+3 -3
@@ -11,9 +11,9 @@ import { jsonParser } from '../express-common.js';
11import { getAllUserHandles, getUserDirectories } from '../users.js';11import { getAllUserHandles, getUserDirectories } from '../users.js';
12import { getFileNameValidationFunction } from '../middleware/validateFileName.js';12import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1313
14const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true);14const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');
15const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true);15const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');
16const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);16const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
1717
18// 10 minutes18// 10 minutes
19const AUTOSAVE_INTERVAL = 10 * 60 * 1000;19const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
src/endpoints/thumbnails.js+6 -3
@@ -12,12 +12,15 @@ import { getAllUserHandles, getUserDirectories } from '../users.js';
12import { getConfigValue } from '../util.js';12import { getConfigValue } from '../util.js';
13import { jsonParser } from '../express-common.js';13import { jsonParser } from '../express-common.js';
1414
15const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true);15const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');
16const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95))));16const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
17const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png';17const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png';
1818
19/** @type {Record<string, number[]>} */19/** @type {Record<string, number[]>} */
20const dimensions = getConfigValue('thumbnails.dimensions', { 'bg': [160, 90], 'avatar': [96, 144] });20const dimensions = {
21 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
22 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
23};
2124
22/**25/**
23 * Gets a path to thumbnail folder based on the type.26 * Gets a path to thumbnail folder based on the type.
src/endpoints/tokenizers.js+1 -1
@@ -56,7 +56,7 @@ export const TEXT_COMPLETION_MODELS = [
56];56];
5757
58const CHARS_PER_TOKEN = 3.35;58const CHARS_PER_TOKEN = 3.35;
59const IS_DOWNLOAD_ALLOWED = getConfigValue('enableDownloadableTokenizers', true);59const IS_DOWNLOAD_ALLOWED = getConfigValue('enableDownloadableTokenizers', true, 'boolean');
6060
61/**61/**
62 * Gets a path to the tokenizer model. Downloads the model if it's a URL.62 * Gets a path to the tokenizer model. Downloads the model if it's a URL.
src/endpoints/users-public.js+2 -2
@@ -7,8 +7,8 @@ import { jsonParser, getIpFromRequest, getRealIpFromHeader } from '../express-co
7import { color, Cache, getConfigValue } from '../util.js';7import { color, Cache, getConfigValue } from '../util.js';
8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
99
10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false);11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
12const MFA_CACHE = new Cache(5 * 60 * 1000);12const MFA_CACHE = new Cache(5 * 60 * 1000);
1313
14const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);14const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
src/middleware/accessLogWriter.js+1 -1
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3import { getRealIpFromHeader } from '../express-common.js';3import { getRealIpFromHeader } from '../express-common.js';
4import { color, getConfigValue } from '../util.js';4import { color, getConfigValue } from '../util.js';
55
6const enableAccessLog = getConfigValue('logging.enableAccessLog', true);6const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean');
77
8const knownIPs = new Set();8const knownIPs = new Set();
99
src/middleware/basicAuth.js+6 -5
@@ -5,10 +5,10 @@
5import { Buffer } from 'node:buffer';5import { Buffer } from 'node:buffer';
6import storage from 'node-persist';6import storage from 'node-persist';
7import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';7import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
8import { getConfig, getConfigValue, safeReadFileSync } from '../util.js';8import { getConfigValue, safeReadFileSync } from '../util.js';
99
10const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);10const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
11const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);11const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
1212
13const basicAuthMiddleware = async function (request, response, callback) {13const basicAuthMiddleware = async function (request, response, callback) {
14 const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';14 const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
@@ -17,7 +17,8 @@ const basicAuthMiddleware = async function (request, response, callback) {
17 return res.status(401).send(unauthorizedWebpage);17 return res.status(401).send(unauthorizedWebpage);
18 };18 };
1919
20 const config = getConfig();20 const basicAuthUserName = getConfigValue('basicAuthUser.username');
21 const basicAuthUserPassword = getConfigValue('basicAuthUser.password');
21 const authHeader = request.headers.authorization;22 const authHeader = request.headers.authorization;
2223
23 if (!authHeader) {24 if (!authHeader) {
@@ -35,7 +36,7 @@ const basicAuthMiddleware = async function (request, response, callback) {
35 .toString('utf8')36 .toString('utf8')
36 .split(':');37 .split(':');
3738
38 if (!usePerUserAuth && username === config.basicAuthUser.username && password === config.basicAuthUser.password) {39 if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {
39 return callback();40 return callback();
40 } else if (usePerUserAuth) {41 } else if (usePerUserAuth) {
41 const userHandles = await getAllUserHandles();42 const userHandles = await getAllUserHandles();
src/middleware/whitelist.js+1 -1
@@ -8,7 +8,7 @@ import { getIpFromRequest } from '../express-common.js';
8import { color, getConfigValue, safeReadFileSync } from '../util.js';8import { color, getConfigValue, safeReadFileSync } from '../util.js';
99
10const whitelistPath = path.join(process.cwd(), './whitelist.txt');10const whitelistPath = path.join(process.cwd(), './whitelist.txt');
11const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false);11const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false, 'boolean');
12let whitelist = getConfigValue('whitelist', []);12let whitelist = getConfigValue('whitelist', []);
1313
14if (fs.existsSync(whitelistPath)) {14if (fs.existsSync(whitelistPath)) {
src/plugin-loader.js+2 -2
@@ -7,8 +7,8 @@ import { default as git, CheckRepoActions } from 'simple-git';
7import { sync as commandExistsSync } from 'command-exists';7import { sync as commandExistsSync } from 'command-exists';
8import { getConfigValue, color } from './util.js';8import { getConfigValue, color } from './util.js';
99
10const enableServerPlugins = !!getConfigValue('enableServerPlugins', false);10const enableServerPlugins = !!getConfigValue('enableServerPlugins', false, 'boolean');
11const enableServerPluginsAutoUpdate = !!getConfigValue('enableServerPluginsAutoUpdate', true);11const enableServerPluginsAutoUpdate = !!getConfigValue('enableServerPluginsAutoUpdate', true, 'boolean');
1212
13/**13/**
14 * Map of loaded plugins.14 * Map of loaded plugins.
src/prompt-converters.js+1 -1
@@ -572,7 +572,7 @@ export function convertMistralMessages(messages, names) {
572 }572 }
573573
574 // Make the last assistant message a prefill574 // Make the last assistant message a prefill
575 const prefixEnabled = getConfigValue('mistral.enablePrefix', false);575 const prefixEnabled = getConfigValue('mistral.enablePrefix', false, 'boolean');
576 const lastMsg = messages[messages.length - 1];576 const lastMsg = messages[messages.length - 1];
577 if (prefixEnabled && messages.length > 0 && lastMsg?.role === 'assistant') {577 if (prefixEnabled && messages.length > 0 && lastMsg?.role === 'assistant') {
578 lastMsg.prefix = true;578 lastMsg.prefix = true;
src/transformers.js+1 -1
@@ -132,7 +132,7 @@ export async function getPipeline(task, forceModel = '') {
132132
133 const cacheDir = path.join(globalThis.DATA_ROOT, '_cache');133 const cacheDir = path.join(globalThis.DATA_ROOT, '_cache');
134 const model = forceModel || getModelForTask(task);134 const model = forceModel || getModelForTask(task);
135 const localOnly = !getConfigValue('extensions.models.autoDownload', true);135 const localOnly = !getConfigValue('extensions.models.autoDownload', true, 'boolean');
136 console.log('Initializing transformers.js pipeline for task', task, 'with model', model);136 console.log('Initializing transformers.js pipeline for task', task, 'with model', model);
137 const instance = await pipeline(task, model, { cache_dir: cacheDir, quantized: tasks[task].quantized ?? true, local_files_only: localOnly });137 const instance = await pipeline(task, model, { cache_dir: cacheDir, quantized: tasks[task].quantized ?? true, local_files_only: localOnly });
138 tasks[task].pipeline = instance;138 tasks[task].pipeline = instance;
src/users.js+28 -13
@@ -15,15 +15,15 @@ import _ from 'lodash';
15import { sync as writeFileAtomicSync } from 'write-file-atomic';15import { sync as writeFileAtomicSync } from 'write-file-atomic';
1616
17import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE } from './constants.js';17import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE } from './constants.js';
18import { getConfigValue, color, delay, setConfigValue, generateTimestamp } from './util.js';18import { getConfigValue, color, delay, generateTimestamp } from './util.js';
19import { readSecret, writeSecret } from './endpoints/secrets.js';19import { readSecret, writeSecret } from './endpoints/secrets.js';
20import { getContentOfType } from './endpoints/content-manager.js';20import { getContentOfType } from './endpoints/content-manager.js';
2121
22export const KEY_PREFIX = 'user:';22export const KEY_PREFIX = 'user:';
23const AVATAR_PREFIX = 'avatar:';23const AVATAR_PREFIX = 'avatar:';
24const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);24const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
25const AUTHELIA_AUTH = getConfigValue('autheliaAuth', false);25const AUTHELIA_AUTH = getConfigValue('autheliaAuth', false, 'boolean');
26const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false);26const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
27const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');27const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
2828
29/**29/**
@@ -32,9 +32,13 @@ const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
32 */32 */
33const DIRECTORIES_CACHE = new Map();33const DIRECTORIES_CACHE = new Map();
34const PUBLIC_USER_AVATAR = '/img/default-user.png';34const PUBLIC_USER_AVATAR = '/img/default-user.png';
35const COOKIE_SECRET_PATH = 'cookie-secret.txt';
3536
36const STORAGE_KEYS = {37const STORAGE_KEYS = {
37 csrfSecret: 'csrfSecret',38 csrfSecret: 'csrfSecret',
39 /**
40 * @deprecated Read from COOKIE_SECRET_PATH in DATA_ROOT instead.
41 */
38 cookieSecret: 'cookieSecret',42 cookieSecret: 'cookieSecret',
39};43};
4044
@@ -412,11 +416,10 @@ export function toAvatarKey(handle) {
412 * @returns {Promise<void>}416 * @returns {Promise<void>}
413 */417 */
414export async function initUserStorage(dataRoot) {418export async function initUserStorage(dataRoot) {
415 globalThis.DATA_ROOT = dataRoot;419 console.log('Using data root:', color.green(dataRoot));
416 console.log('Using data root:', color.green(globalThis.DATA_ROOT));
417 console.log();420 console.log();
418 await storage.init({421 await storage.init({
419 dir: path.join(globalThis.DATA_ROOT, '_storage'),422 dir: path.join(dataRoot, '_storage'),
420 ttl: false, // Never expire423 ttl: false, // Never expire
421 });424 });
422425
@@ -430,17 +433,29 @@ export async function initUserStorage(dataRoot) {
430433
431/**434/**
432 * Get the cookie secret from the config. If it doesn't exist, generate a new one.435 * Get the cookie secret from the config. If it doesn't exist, generate a new one.
436 * @param {string} dataRoot The root directory for user data
433 * @returns {string} The cookie secret437 * @returns {string} The cookie secret
434 */438 */
435export function getCookieSecret() {439export function getCookieSecret(dataRoot) {
436 let secret = getConfigValue(STORAGE_KEYS.cookieSecret);440 const cookieSecretPath = path.join(dataRoot, COOKIE_SECRET_PATH);
441
442 if (fs.existsSync(cookieSecretPath)) {
443 const stat = fs.statSync(cookieSecretPath);
444 if (stat.size > 0) {
445 return fs.readFileSync(cookieSecretPath, 'utf8');
446 }
447 }
437448
438 if (!secret) {449 const oldSecret = getConfigValue(STORAGE_KEYS.cookieSecret);
439 console.warn(color.yellow('Cookie secret is missing from config.yaml. Generating a new one...'));450 if (oldSecret) {
440 secret = crypto.randomBytes(64).toString('base64');451 console.log('Migrating cookie secret from config.yaml...');
441 setConfigValue(STORAGE_KEYS.cookieSecret, secret);452 writeFileAtomicSync(cookieSecretPath, oldSecret, { encoding: 'utf8' });
453 return oldSecret;
442 }454 }
443455
456 console.warn(color.yellow('Cookie secret is missing from data root. Generating a new one...'));
457 const secret = crypto.randomBytes(64).toString('base64');
458 writeFileAtomicSync(cookieSecretPath, secret, { encoding: 'utf8' });
444 return secret;459 return secret;
445}460}
446461
src/util.js+59 -15
@@ -9,7 +9,6 @@ import { promises as dnsPromise } from 'node:dns';
99
10import yaml from 'yaml';10import yaml from 'yaml';
11import { sync as commandExistsSync } from 'command-exists';11import { sync as commandExistsSync } from 'command-exists';
12import { sync as writeFileAtomicSync } from 'write-file-atomic';
13import _ from 'lodash';12import _ from 'lodash';
14import yauzl from 'yauzl';13import yauzl from 'yauzl';
15import mime from 'mime-types';14import mime from 'mime-types';
@@ -22,6 +21,14 @@ import { LOG_LEVELS } from './constants.js';
22let CACHED_CONFIG = null;21let CACHED_CONFIG = null;
2322
24/**23/**
24 * Converts a configuration key to an environment variable key.
25 * @param {string} key Configuration key
26 * @returns {string} Environment variable key
27 * @example keyToEnv('extensions.models.speechToText') // 'SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT'
28 */
29export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');
30
31/**
25 * Returns the config object from the config.yaml file.32 * Returns the config object from the config.yaml file.
26 * @returns {object} Config object33 * @returns {object} Config object
27 */34 */
@@ -51,24 +58,40 @@ export function getConfig() {
51 * Returns the value for the given key from the config object.58 * Returns the value for the given key from the config object.
52 * @param {string} key - Key to get from the config object59 * @param {string} key - Key to get from the config object
53 * @param {any} defaultValue - Default value to return if the key is not found60 * @param {any} defaultValue - Default value to return if the key is not found
61 * @param {'number'|'boolean'|null} typeConverter - Type to convert the value to
54 * @returns {any} Value for the given key62 * @returns {any} Value for the given key
55 */63 */
56export function getConfigValue(key, defaultValue = null) {64export function getConfigValue(key, defaultValue = null, typeConverter = null) {
65 function _getValue() {
66 const envKey = keyToEnv(key);
67 if (envKey in process.env) {
68 const needsJsonParse = defaultValue && typeof defaultValue === 'object';
69 const envValue = process.env[envKey];
70 return needsJsonParse ? (tryParse(envValue) ?? defaultValue) : envValue;
71 }
57 const config = getConfig();72 const config = getConfig();
58 return _.get(config, key, defaultValue);73 return _.get(config, key, defaultValue);
59 }74 }
6075
76 const value = _getValue();
77 switch (typeConverter) {
78 case 'number':
79 return isNaN(parseFloat(value)) ? defaultValue : parseFloat(value);
80 case 'boolean':
81 return toBoolean(value);
82 default:
83 return value;
84 }
85}
86
61/**87/**
62 * Sets a value for the given key in the config object and writes it to the config.yaml file.88 * THIS FUNCTION IS DEPRECATED AND ONLY EXISTS FOR BACKWARDS COMPATIBILITY. DON'T USE IT.
63 * @param {string} key Key to set89 * @param {any} _key Unused
64 * @param {any} value Value to set90 * @param {any} _value Unused
91 * @deprecated Configs are read-only. Use environment variables instead.
65 */92 */
66export function setConfigValue(key, value) {93export function setConfigValue(_key, _value) {
67 // Reset cache so that the next getConfig call will read the updated config file94 console.trace(color.yellow('setConfigValue is deprecated and should not be used.'));
68 CACHED_CONFIG = null;
69 const config = getConfig();
70 _.set(config, key, value);
71 writeFileAtomicSync('./config.yaml', yaml.stringify(config));
72}95}
7396
74/**97/**
@@ -394,7 +417,7 @@ export function generateTimestamp() {
394 * @param {number?} limit Maximum number of backups to keep. If null, the limit is determined by the `backups.common.numberOfBackups` config value.417 * @param {number?} limit Maximum number of backups to keep. If null, the limit is determined by the `backups.common.numberOfBackups` config value.
395 */418 */
396export function removeOldBackups(directory, prefix, limit = null) {419export function removeOldBackups(directory, prefix, limit = null) {
397 const MAX_BACKUPS = limit ?? Number(getConfigValue('backups.common.numberOfBackups', 50));420 const MAX_BACKUPS = limit ?? Number(getConfigValue('backups.common.numberOfBackups', 50, 'number'));
398421
399 let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));422 let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));
400 if (files.length > MAX_BACKUPS) {423 if (files.length > MAX_BACKUPS) {
@@ -747,6 +770,27 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
747 }770 }
748}771}
749772
773/**
774 * Converts various JavaScript primitives to boolean values.
775 * Handles special case for "true"/"false" strings (case-insensitive)
776 *
777 * @param {any} value - The value to convert to boolean
778 * @returns {boolean} - The boolean representation of the value
779 */
780export function toBoolean(value) {
781 // Handle string values case-insensitively
782 if (typeof value === 'string') {
783 // Trim and convert to lowercase for case-insensitive comparison
784 const trimmedLower = value.trim().toLowerCase();
785
786 // Handle explicit "true"/"false" strings
787 if (trimmedLower === 'true') return true;
788 if (trimmedLower === 'false') return false;
789 }
790
791 // Handle all other JavaScript values based on their "truthiness"
792 return Boolean(value);
793}
750794
751/**795/**
752 * converts string to boolean accepts 'true' or 'false' else it returns the string put in796 * converts string to boolean accepts 'true' or 'false' else it returns the string put in
@@ -754,8 +798,8 @@ export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
754 * @returns {boolean|string|null} boolean else original input string or null if input is798 * @returns {boolean|string|null} boolean else original input string or null if input is
755 */799 */
756export function stringToBool(str) {800export function stringToBool(str) {
757 if (str === 'true') return true;801 if (String(str).trim().toLowerCase() === 'true') return true;
758 if (str === 'false') return false;802 if (String(str).trim().toLowerCase() === 'false') return false;
759 return str;803 return str;
760}804}
761805
@@ -763,7 +807,7 @@ export function stringToBool(str) {
763 * Setup the minimum log level807 * Setup the minimum log level
764 */808 */
765export function setupLogLevel() {809export function setupLogLevel() {
766 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG);810 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
767811
768 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};812 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};
769 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};813 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};