Merge pull request #3857 from wrvsrx/allow-readonly-install Allow read-only installation

28ca8176f835f1e480c6cc2bdf141920f73c2b33

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

Signed
16 files changed, +691 -641Ignore whitespace
.dockerignore+1 -0
@@ -13,3 +13,4 @@ access.log
1313/cache
1414.DS_Store
1515/public/scripts/extensions/third-party
16+/colab
.github/readme.md+1 -0
@@ -350,6 +350,7 @@ Start.bat --port 8000 --listen false
350350| Option | Description | Type |
351351|-------------------------|----------------------------------------------------------------------|----------|
352352| `--version` | Show version number | boolean |
353+| `--configPath` | Override the path to the config.yaml file | string |
353354| `--dataRoot` | Root directory for data storage | string |
354355| `--port` | Sets the port under which SillyTavern will run | number |
355356| `--listen` | SillyTavern will listen on all network interfaces | boolean |
.npmignore+1 -0
@@ -12,3 +12,4 @@ access.log
1212.vscode
1313.git
1414/public/scripts/extensions/third-party
15+/colab
Dockerfile+3 -5
@@ -12,15 +12,13 @@ WORKDIR ${APP_HOME}
1212# Set NODE_ENV to production
1313ENV NODE_ENV=production
1414
1515# InstallBundle app dependenciessource
1616COPY package*.json post-install.js ./
17+
1718RUN \
1819 echo "*** Install npm packages ***" && \
1920 npm i --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force
2021
21-# Bundle app source
22-COPY . ./
23-
2422# Copy default chats, characters and user avatars to <folder>.default folder
2523RUN \
2624 rm -f "config.yaml" || true && \
post-install.js+3 -229
@@ -3,133 +3,17 @@
33 */
44import fs from 'node:fs';
55import path from 'node:path';
6-import crypto from 'node:crypto';
76import process from 'node:process';
87import yaml from 'yaml';
9-import _ from 'lodash';
108import chalk from 'chalk';
119import { createRequire } from 'node:module';
10+import { addMissingConfigValues } from './src/config-init.js';
1211
1312/**
1413 * Colorizes console output.
1514 */
1615const color = chalk;
1716
18-const keyMigrationMap = [
19- {
20- oldKey: 'disableThumbnails',
21- newKey: 'thumbnails.enabled',
22- migrate: (value) => !value,
23- },
24- {
25- oldKey: 'thumbnailsQuality',
26- newKey: 'thumbnails.quality',
27- migrate: (value) => value,
28- },
29- {
30- oldKey: 'avatarThumbnailsPng',
31- newKey: 'thumbnails.format',
32- migrate: (value) => (value ? 'png' : 'jpg'),
33- },
34- {
35- oldKey: 'disableChatBackup',
36- newKey: 'backups.chat.enabled',
37- migrate: (value) => !value,
38- },
39- {
40- oldKey: 'numberOfBackups',
41- newKey: 'backups.common.numberOfBackups',
42- migrate: (value) => value,
43- },
44- {
45- oldKey: 'maxTotalChatBackups',
46- newKey: 'backups.chat.maxTotalBackups',
47- migrate: (value) => value,
48- },
49- {
50- oldKey: 'chatBackupThrottleInterval',
51- newKey: 'backups.chat.throttleInterval',
52- migrate: (value) => value,
53- },
54- {
55- oldKey: 'enableExtensions',
56- newKey: 'extensions.enabled',
57- migrate: (value) => value,
58- },
59- {
60- oldKey: 'enableExtensionsAutoUpdate',
61- newKey: 'extensions.autoUpdate',
62- migrate: (value) => value,
63- },
64- {
65- oldKey: 'extras.disableAutoDownload',
66- newKey: 'extensions.models.autoDownload',
67- migrate: (value) => !value,
68- },
69- {
70- oldKey: 'extras.classificationModel',
71- newKey: 'extensions.models.classification',
72- migrate: (value) => value,
73- },
74- {
75- oldKey: 'extras.captioningModel',
76- newKey: 'extensions.models.captioning',
77- migrate: (value) => value,
78- },
79- {
80- oldKey: 'extras.embeddingModel',
81- newKey: 'extensions.models.embedding',
82- migrate: (value) => value,
83- },
84- {
85- oldKey: 'extras.speechToTextModel',
86- newKey: 'extensions.models.speechToText',
87- migrate: (value) => value,
88- },
89- {
90- oldKey: 'extras.textToSpeechModel',
91- newKey: 'extensions.models.textToSpeech',
92- migrate: (value) => value,
93- },
94- {
95- oldKey: 'minLogLevel',
96- newKey: 'logging.minLogLevel',
97- migrate: (value) => value,
98- },
99- {
100- oldKey: 'cardsCacheCapacity',
101- newKey: 'performance.memoryCacheCapacity',
102- migrate: (value) => `${value}mb`,
103- },
104- {
105- oldKey: 'cookieSecret',
106- newKey: 'cookieSecret',
107- migrate: () => void 0,
108- remove: true,
109- },
110-];
111-
112-/**
113- * Gets all keys from an object recursively.
114- * @param {object} obj Object to get all keys from
115- * @param {string} prefix Prefix to prepend to all keys
116- * @returns {string[]} Array of all keys in the object
117- */
118-function getAllKeys(obj, prefix = '') {
119- if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
120- return [];
121- }
122-
123- return _.flatMap(Object.keys(obj), key => {
124- const newPrefix = prefix ? `${prefix}.${key}` : key;
125- if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
126- return getAllKeys(obj[key], newPrefix);
127- } else {
128- return [newPrefix];
129- }
130- });
131-}
132-
13317/**
13418 * Converts the old config.conf file to the new config.yaml format.
13519 */
@@ -157,71 +41,6 @@ function convertConfig() {
15741}
15842
15943/**
160- * Compares the current config.yaml with the default config.yaml and adds any missing values.
161- */
162-function addMissingConfigValues() {
163- try {
164- const defaultConfig = yaml.parse(fs.readFileSync(path.join(process.cwd(), './default/config.yaml'), 'utf8'));
165- let config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
166-
167- // Migrate old keys to new keys
168- const migratedKeys = [];
169- for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
170- if (_.has(config, oldKey)) {
171- if (remove) {
172- _.unset(config, oldKey);
173- migratedKeys.push({
174- oldKey,
175- newValue: void 0,
176- });
177- continue;
178- }
179-
180- const oldValue = _.get(config, oldKey);
181- const newValue = migrate(oldValue);
182- _.set(config, newKey, newValue);
183- _.unset(config, oldKey);
184-
185- migratedKeys.push({
186- oldKey,
187- newKey,
188- oldValue,
189- newValue,
190- });
191- }
192- }
193-
194- // Get all keys from the original config
195- const originalKeys = getAllKeys(config);
196-
197- // Use lodash's defaultsDeep function to recursively apply default properties
198- config = _.defaultsDeep(config, defaultConfig);
199-
200- // Get all keys from the updated config
201- const updatedKeys = getAllKeys(config);
202-
203- // Find the keys that were added
204- const addedKeys = _.difference(updatedKeys, originalKeys);
205-
206- if (addedKeys.length === 0 && migratedKeys.length === 0) {
207- return;
208- }
209-
210- if (addedKeys.length > 0) {
211- console.log('Adding missing config values to config.yaml:', addedKeys);
212- }
213-
214- if (migratedKeys.length > 0) {
215- console.log('Migrating config values in config.yaml:', migratedKeys);
216- }
217-
218- fs.writeFileSync('./config.yaml', yaml.stringify(config));
219- } catch (error) {
220- console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
221- }
222-}
223-
224-/**
22544 * Creates the default config files if they don't exist yet.
22645 */
22746function createDefaultFiles() {
@@ -283,58 +102,13 @@ function createDefaultFiles() {
283102 }
284103}
285104
286-/**
287- * Returns the MD5 hash of the given data.
288- * @param {Buffer} data Input data
289- * @returns {string} MD5 hash of the input data
290- */
291-function getMd5Hash(data) {
292- return crypto
293- .createHash('md5')
294- .update(new Uint8Array(data))
295- .digest('hex');
296-}
297-
298-/**
299- * Copies the WASM binaries from the sillytavern-transformers package to the dist folder.
300- */
301-function copyWasmFiles() {
302- if (!fs.existsSync('./dist')) {
303- fs.mkdirSync('./dist');
304- }
305-
306- const listDir = fs.readdirSync('./node_modules/sillytavern-transformers/dist');
307-
308- for (const file of listDir) {
309- if (file.endsWith('.wasm')) {
310- const sourcePath = `./node_modules/sillytavern-transformers/dist/${file}`;
311- const targetPath = `./dist/${file}`;
312-
313- // Don't copy if the file already exists and is the same checksum
314- if (fs.existsSync(targetPath)) {
315- const sourceChecksum = getMd5Hash(fs.readFileSync(sourcePath));
316- const targetChecksum = getMd5Hash(fs.readFileSync(targetPath));
317-
318- if (sourceChecksum === targetChecksum) {
319- continue;
320- }
321- }
322-
323- fs.copyFileSync(sourcePath, targetPath);
324- console.log(`${file} successfully copied to ./dist/${file}`);
325- }
326- }
327-}
328-
329105try {
330106 // 0. Convert config.conf to config.yaml
331107 convertConfig();
332108 // 1. Create default config files
333109 createDefaultFiles();
334110 // 2. Copy transformers WASMAdd binariesmissing fromconfig node_modulesvalues
335- copyWasmFiles();
111+ addMissingConfigValues(path.join(process.cwd(), './config.yaml'));
336- // 3. Add missing config values
337- addMissingConfigValues();
338112} catch (error) {
339113 console.error(error);
340114}
server.js+5 -383
@@ -1,393 +1,15 @@
11#!/usr/bin/env node
2-
3-// native node modules
4-import path from 'node:path';
5-import util from 'node:util';
6-import net from 'node:net';
7-import dns from 'node:dns';
8-import process from 'node:process';
9-import { fileURLToPath } from 'node:url';
10-
11-import cors from 'cors';
12-import { csrfSync } from 'csrf-sync';
13-import express from 'express';
14-import compression from 'compression';
15-import cookieSession from 'cookie-session';
16-import multer from 'multer';
17-import responseTime from 'response-time';
18-import helmet from 'helmet';
19-import bodyParser from 'body-parser';
20-import open from 'open';
21-
22-// local library imports
23-import './src/fetch-patch.js';
24-import { serverEvents, EVENT_NAMES } from './src/server-events.js';
252import { CommandLineParser } from './src/command-line.js';
263import { loadPluginsserverDirectory } from './src/pluginserver-loaderdirectory.js';
27-import {
28- initUserStorage,
29- getCookieSecret,
30- getCookieSessionName,
31- ensurePublicDirectoriesExist,
32- getUserDirectoriesList,
33- migrateSystemPrompts,
34- migrateUserData,
35- requireLoginMiddleware,
36- setUserDataMiddleware,
37- shouldRedirectToLogin,
38- cleanUploads,
39- getSessionCookieAge,
40- verifySecuritySettings,
41- loginPageMiddleware,
42-} from './src/users.js';
43-
44-import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
45-import basicAuthMiddleware from './src/middleware/basicAuth.js';
46-import getWhitelistMiddleware from './src/middleware/whitelist.js';
47-import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
48-import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
49-import initRequestProxy from './src/request-proxy.js';
50-import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
51-import corsProxyMiddleware from './src/middleware/corsProxy.js';
52-import {
53- getVersion,
54- color,
55- removeColorFormatting,
56- getSeparator,
57- safeReadFileSync,
58- setupLogLevel,
59- setWindowTitle,
60-} from './src/util.js';
61-import { UPLOADS_DIRECTORY } from './src/constants.js';
62-import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
63-
64-// Routers
65-import { router as usersPublicRouter } from './src/endpoints/users-public.js';
66-import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
67-import { checkForNewContent } from './src/endpoints/content-manager.js';
68-import { init as settingsInit } from './src/endpoints/settings.js';
69-import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
70-import { diskCache } from './src/endpoints/characters.js';
71-
72-// Unrestrict console logs display limit
73-util.inspect.defaultOptions.maxArrayLength = null;
74-util.inspect.defaultOptions.maxStringLength = null;
75-util.inspect.defaultOptions.depth = 4;
76-
77-// Set a working directory for the server
78-const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
79-console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
80-process.chdir(serverDirectory);
81-
82-// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
83-// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
84-// Safe to remove once support for Node v20 is dropped.
85-if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) {
86- // @ts-ignore
87- if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
88-}
894
5+// config.yaml will be set when parsing command line arguments
906const cliArgs = new CommandLineParser().parse(process.argv);
917globalThis.DATA_ROOT = cliArgs.dataRoot;
928globalThis.COMMAND_LINE_ARGS = cliArgs;
93-
9+process.chdir(serverDirectory);
94-if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
95- console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
96- process.exit(1);
97-}
9810
9911try {
100- if (cliArgs.dnsPreferIPv6) {
12+ await import('./src/server-main.js');
101- dns.setDefaultResultOrder('ipv6first');
102- console.log('Preferring IPv6 for DNS resolution');
103- } else {
104- dns.setDefaultResultOrder('ipv4first');
105- console.log('Preferring IPv4 for DNS resolution');
106- }
10713} catch (error) {
10814 console.warnerror('Failed to set DNSA resolutioncritical order.error Possiblyhas unsupportedoccurred inwhile thisstarting Nodethe version.server:', error);
109-}
110-
111-const app = express();
112-app.use(helmet({
113- contentSecurityPolicy: false,
114-}));
115-app.use(compression());
116-app.use(responseTime());
117-
118-app.use(bodyParser.json({ limit: '200mb' }));
119-app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' }));
120-
121-// CORS Settings //
122-const CORS = cors({
123- origin: 'null',
124- methods: ['OPTIONS'],
125-});
126-
127-app.use(CORS);
128-
129-if (cliArgs.listen && cliArgs.basicAuthMode) {
130- app.use(basicAuthMiddleware);
131-}
132-
133-if (cliArgs.whitelistMode) {
134- const whitelistMiddleware = await getWhitelistMiddleware();
135- app.use(whitelistMiddleware);
136-}
137-
138-if (cliArgs.listen) {
139- app.use(accessLoggerMiddleware());
140-}
141-
142-if (cliArgs.enableCorsProxy) {
143- app.use('/proxy/:url(*)', corsProxyMiddleware);
144-} else {
145- app.use('/proxy/:url(*)', async (_, res) => {
146- const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
147- console.log(message);
148- res.status(404).send(message);
149- });
150-}
151-
152-app.use(cookieSession({
153- name: getCookieSessionName(),
154- sameSite: 'lax',
155- httpOnly: true,
156- maxAge: getSessionCookieAge(),
157- secret: getCookieSecret(globalThis.DATA_ROOT),
158-}));
159-
160-app.use(setUserDataMiddleware);
161-
162-// CSRF Protection //
163-if (!cliArgs.disableCsrf) {
164- const csrfSyncProtection = csrfSync({
165- getTokenFromState: (req) => {
166- if (!req.session) {
167- console.error('(CSRF error) getTokenFromState: Session object not initialized');
168- return;
169- }
170- return req.session.csrfToken;
171- },
172- getTokenFromRequest: (req) => {
173- return req.headers['x-csrf-token']?.toString();
174- },
175- storeTokenInState: (req, token) => {
176- if (!req.session) {
177- console.error('(CSRF error) storeTokenInState: Session object not initialized');
178- return;
179- }
180- req.session.csrfToken = token;
181- },
182- size: 32,
183- });
184-
185- app.get('/csrf-token', (req, res) => {
186- res.json({
187- 'token': csrfSyncProtection.generateToken(req),
188- });
189- });
190-
191- // Customize the error message
192- csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
193- csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
194-
195- app.use(csrfSyncProtection.csrfSynchronisedProtection);
196-} else {
197- console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
198- app.get('/csrf-token', (req, res) => {
199- res.json({
200- 'token': 'disabled',
201- });
202- });
203-}
204-
205-// Static files
206-// Host index page
207-app.get('/', getCacheBusterMiddleware(), (request, response) => {
208- if (shouldRedirectToLogin(request)) {
209- const query = request.url.split('?')[1];
210- const redirectUrl = query ? `/login?${query}` : '/login';
211- return response.redirect(redirectUrl);
212- }
213-
214- return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });
215-});
216-
217-// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
218-app.get('/callback/:source?', (request, response) => {
219- const source = request.params.source;
220- const query = request.url.split('?')[1];
221- const searchParams = new URLSearchParams();
222- source && searchParams.set('source', source);
223- query && searchParams.set('query', query);
224- const path = `/?${searchParams.toString()}`;
225- return response.redirect(307, path);
226-});
227-
228-// Host login page
229-app.get('/login', loginPageMiddleware);
230-
231-// Host frontend assets
232-const webpackMiddleware = getWebpackServeMiddleware();
233-app.use(webpackMiddleware);
234-app.use(express.static(process.cwd() + '/public', {}));
235-
236-// Public API
237-app.use('/api/users', usersPublicRouter);
238-
239-// Everything below this line requires authentication
240-app.use(requireLoginMiddleware);
241-app.get('/api/ping', (request, response) => {
242- if (request.query.extend && request.session) {
243- request.session.touch = Date.now();
244- }
245-
246- response.sendStatus(204);
247-});
248-
249-// File uploads
250-const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
251-app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
252-app.use(multerMonkeyPatch);
253-
254-app.get('/version', async function (_, response) {
255- const data = await getVersion();
256- response.send(data);
257-});
258-
259-redirectDeprecatedEndpoints(app);
260-setupPrivateEndpoints(app);
261-
262-/**
263- * Tasks that need to be run before the server starts listening.
264- * @returns {Promise<void>}
265- */
266-async function preSetupTasks() {
267- const version = await getVersion();
268-
269- // Print formatted header
270- console.log();
271- console.log(`SillyTavern ${version.pkgVersion}`);
272- if (version.gitBranch) {
273- console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`);
274- if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) {
275- console.log('INFO: Currently not on the latest commit.');
276- console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.');
277- }
278- }
279- console.log();
280-
281- const directories = await getUserDirectoriesList();
282- await checkForNewContent(directories);
283- await ensureThumbnailCache(directories);
284- await diskCache.verify(directories);
285- cleanUploads();
286- migrateAccessLog();
287-
288- await settingsInit();
289- await statsInit();
290-
291- const pluginsDirectory = path.join(serverDirectory, 'plugins');
292- const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
293- const consoleTitle = process.title;
294-
295- let isExiting = false;
296- const exitProcess = async () => {
297- if (isExiting) return;
298- isExiting = true;
299- await statsOnExit();
300- if (typeof cleanupPlugins === 'function') {
301- await cleanupPlugins();
302- }
303- diskCache.dispose();
304- setWindowTitle(consoleTitle);
305- process.exit();
306- };
307-
308- // Set up event listeners for a graceful shutdown
309- process.on('SIGINT', exitProcess);
310- process.on('SIGTERM', exitProcess);
311- process.on('uncaughtException', (err) => {
312- console.error('Uncaught exception:', err);
313- exitProcess();
314- });
315-
316- // Add request proxy.
317- initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass });
318-
319- // Wait for frontend libs to compile
320- await webpackMiddleware.runWebpackCompiler();
321-}
322-
323-/**
324- * Tasks that need to be run after the server starts listening.
325- * @param {import('./src/server-startup.js').ServerStartupResult} result The result of the server startup
326- * @returns {Promise<void>}
327- */
328-async function postSetupTasks(result) {
329- const autorunHostname = await cliArgs.getAutorunHostname(result);
330- const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
331-
332- if (cliArgs.autorun) {
333- try {
334- console.log('Launching in a browser...');
335- await open(autorunUrl.toString());
336- } catch (error) {
337- console.error('Failed to launch the browser. Open the URL manually.');
338- }
339- }
340-
341- setWindowTitle('SillyTavern WebServer');
342-
343- let logListen = 'SillyTavern is listening on';
344-
345- if (result.useIPv6 && !result.v6Failed) {
346- logListen += color.green(
347- ' IPv6: ' + cliArgs.getIPv6ListenUrl().host,
348- );
349- }
350-
351- if (result.useIPv4 && !result.v4Failed) {
352- logListen += color.green(
353- ' IPv4: ' + cliArgs.getIPv4ListenUrl().host,
354- );
355- }
356-
357- const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern';
358- const plainGoToLog = removeColorFormatting(goToLog);
359-
360- console.log(logListen);
361- if (cliArgs.listen) {
362- console.log();
363- console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
364- console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
365- }
366- console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
367- console.log(goToLog);
368- console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
369-
370- setupLogLevel();
371- serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl });
372-}
373-
374-/**
375- * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
376- */
377-function apply404Middleware() {
378- const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
379- app.use((req, res) => {
380- res.status(404).send(notFoundWebpage);
381- });
38215}
383-
384-// User storage module needs to be initialized before starting the server
385-initUserStorage(globalThis.DATA_ROOT)
386- .then(ensurePublicDirectoriesExist)
387- .then(migrateUserData)
388- .then(migrateSystemPrompts)
389- .then(verifySecuritySettings)
390- .then(preSetupTasks)
391- .then(apply404Middleware)
392- .then(() => new ServerStartup(app, cliArgs).start())
393- .then(postSetupTasks);
src/command-line.js+11 -0
@@ -2,9 +2,11 @@ import yargs from 'yargs/yargs';
22import { hideBin } from 'yargs/helpers';
33import ipRegex from 'ip-regex';
44import { canResolve, color, getConfigValue, stringToBool } from './util.js';
5+import { initConfig } from './config-init.js';
56
67/**
78 * @typedef {object} CommandLineArguments Parsed command line arguments
9+ * @property {string} configPath Path to the config file
810 * @property {string} dataRoot Data root directory
911 * @property {number} port Port number
1012 * @property {boolean} listen If SillyTavern is listening on all network interfaces
@@ -40,6 +42,7 @@ export class CommandLineParser {
4042 constructor() {
4143 /** @type {CommandLineArguments} */
4244 this.default = Object.freeze({
45+ configPath: './config.yaml',
4346 dataRoot: './data',
4447 port: 8000,
4548 listen: false,
@@ -88,6 +91,11 @@ export class CommandLineParser {
8891 parse(args) {
8992 const cliArguments = yargs(hideBin(args))
9093 .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')
94+ .option('configPath', {
95+ type: 'string',
96+ default: null,
97+ describe: 'Path to the config file',
98+ })
9199 .option('enableIPv6', {
92100 type: 'string',
93101 default: null,
@@ -177,8 +185,11 @@ export class CommandLineParser {
177185 describe: 'Request proxy bypass list (space separated list of hosts)',
178186 }).parseSync();
179187
188+ const configPath = cliArguments.configPath ?? this.default.configPath;
189+ initConfig(configPath);
180190 /** @type {CommandLineArguments} */
181191 const result = {
192+ configPath: configPath,
182193 dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),
183194 port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),
184195 listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),
src/config-init.js+197 -0
@@ -0,0 +1,197 @@
1+import fs from 'node:fs';
2+import path from 'node:path';
3+import yaml from 'yaml';
4+import color from 'chalk';
5+import _ from 'lodash';
6+import { serverDirectory } from './server-directory.js';
7+import { setConfigFilePath } from './util.js';
8+
9+const keyMigrationMap = [
10+ {
11+ oldKey: 'disableThumbnails',
12+ newKey: 'thumbnails.enabled',
13+ migrate: (value) => !value,
14+ },
15+ {
16+ oldKey: 'thumbnailsQuality',
17+ newKey: 'thumbnails.quality',
18+ migrate: (value) => value,
19+ },
20+ {
21+ oldKey: 'avatarThumbnailsPng',
22+ newKey: 'thumbnails.format',
23+ migrate: (value) => (value ? 'png' : 'jpg'),
24+ },
25+ {
26+ oldKey: 'disableChatBackup',
27+ newKey: 'backups.chat.enabled',
28+ migrate: (value) => !value,
29+ },
30+ {
31+ oldKey: 'numberOfBackups',
32+ newKey: 'backups.common.numberOfBackups',
33+ migrate: (value) => value,
34+ },
35+ {
36+ oldKey: 'maxTotalChatBackups',
37+ newKey: 'backups.chat.maxTotalBackups',
38+ migrate: (value) => value,
39+ },
40+ {
41+ oldKey: 'chatBackupThrottleInterval',
42+ newKey: 'backups.chat.throttleInterval',
43+ migrate: (value) => value,
44+ },
45+ {
46+ oldKey: 'enableExtensions',
47+ newKey: 'extensions.enabled',
48+ migrate: (value) => value,
49+ },
50+ {
51+ oldKey: 'enableExtensionsAutoUpdate',
52+ newKey: 'extensions.autoUpdate',
53+ migrate: (value) => value,
54+ },
55+ {
56+ oldKey: 'extras.disableAutoDownload',
57+ newKey: 'extensions.models.autoDownload',
58+ migrate: (value) => !value,
59+ },
60+ {
61+ oldKey: 'extras.classificationModel',
62+ newKey: 'extensions.models.classification',
63+ migrate: (value) => value,
64+ },
65+ {
66+ oldKey: 'extras.captioningModel',
67+ newKey: 'extensions.models.captioning',
68+ migrate: (value) => value,
69+ },
70+ {
71+ oldKey: 'extras.embeddingModel',
72+ newKey: 'extensions.models.embedding',
73+ migrate: (value) => value,
74+ },
75+ {
76+ oldKey: 'extras.speechToTextModel',
77+ newKey: 'extensions.models.speechToText',
78+ migrate: (value) => value,
79+ },
80+ {
81+ oldKey: 'extras.textToSpeechModel',
82+ newKey: 'extensions.models.textToSpeech',
83+ migrate: (value) => value,
84+ },
85+ {
86+ oldKey: 'minLogLevel',
87+ newKey: 'logging.minLogLevel',
88+ migrate: (value) => value,
89+ },
90+ {
91+ oldKey: 'cardsCacheCapacity',
92+ newKey: 'performance.memoryCacheCapacity',
93+ migrate: (value) => `${value}mb`,
94+ },
95+ {
96+ oldKey: 'cookieSecret',
97+ newKey: 'cookieSecret',
98+ migrate: () => void 0,
99+ remove: true,
100+ },
101+];
102+
103+/**
104+ * Gets all keys from an object recursively.
105+ * @param {object} obj Object to get all keys from
106+ * @param {string} prefix Prefix to prepend to all keys
107+ * @returns {string[]} Array of all keys in the object
108+ */
109+function getAllKeys(obj, prefix = '') {
110+ if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
111+ return [];
112+ }
113+
114+ return _.flatMap(Object.keys(obj), key => {
115+ const newPrefix = prefix ? `${prefix}.${key}` : key;
116+ if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
117+ return getAllKeys(obj[key], newPrefix);
118+ } else {
119+ return [newPrefix];
120+ }
121+ });
122+}
123+
124+/**
125+ * Compares the current config.yaml with the default config.yaml and adds any missing values.
126+ * @param {string} configPath Path to config.yaml
127+ */
128+export function addMissingConfigValues(configPath) {
129+ try {
130+ const defaultConfig = yaml.parse(fs.readFileSync(path.join(serverDirectory, './default/config.yaml'), 'utf8'));
131+ let config = yaml.parse(fs.readFileSync(configPath, 'utf8'));
132+
133+ // Migrate old keys to new keys
134+ const migratedKeys = [];
135+ for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
136+ if (_.has(config, oldKey)) {
137+ if (remove) {
138+ _.unset(config, oldKey);
139+ migratedKeys.push({
140+ oldKey,
141+ newValue: void 0,
142+ });
143+ continue;
144+ }
145+
146+ const oldValue = _.get(config, oldKey);
147+ const newValue = migrate(oldValue);
148+ _.set(config, newKey, newValue);
149+ _.unset(config, oldKey);
150+
151+ migratedKeys.push({
152+ oldKey,
153+ newKey,
154+ oldValue,
155+ newValue,
156+ });
157+ }
158+ }
159+
160+ // Get all keys from the original config
161+ const originalKeys = getAllKeys(config);
162+
163+ // Use lodash's defaultsDeep function to recursively apply default properties
164+ config = _.defaultsDeep(config, defaultConfig);
165+
166+ // Get all keys from the updated config
167+ const updatedKeys = getAllKeys(config);
168+
169+ // Find the keys that were added
170+ const addedKeys = _.difference(updatedKeys, originalKeys);
171+
172+ if (addedKeys.length === 0 && migratedKeys.length === 0) {
173+ return;
174+ }
175+
176+ if (addedKeys.length > 0) {
177+ console.log('Adding missing config values to config.yaml:', addedKeys);
178+ }
179+
180+ if (migratedKeys.length > 0) {
181+ console.log('Migrating config values in config.yaml:', migratedKeys);
182+ }
183+
184+ fs.writeFileSync(configPath, yaml.stringify(config));
185+ } catch (error) {
186+ console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
187+ }
188+}
189+
190+/**
191+ * Performs early initialization tasks before the server starts.
192+ * @param {string} configPath Path to config.yaml
193+ */
194+export function initConfig(configPath) {
195+ setConfigFilePath(configPath);
196+ addMissingConfigValues(configPath);
197+}
src/endpoints/content-manager.js+5 -4
@@ -1,6 +1,5 @@
11import fs from 'node:fs';
22import path from 'node:path';
3-import process from 'node:process';
43import { Buffer } from 'node:buffer';
54
65import express from 'express';
@@ -8,11 +7,12 @@ import fetch from 'node-fetch';
87import sanitize from 'sanitize-filename';
98import { sync as writeFileAtomicSync } from 'write-file-atomic';
109
1110import { getConfigValue, color, setPermissionsSync } from '../util.js';
1211import { write } from '../character-card-parser.js';
12+import { serverDirectory } from '../server-directory.js';
1313
1414const contentDirectory = path.join(process.cwd()serverDirectory, 'default/content');
1515const scaffoldDirectory = path.join(process.cwd()serverDirectory, 'default/scaffold');
1616const contentIndexPath = path.join(contentDirectory, 'index.json');
1717const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
1818
@@ -149,6 +149,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
149149 }
150150
151151 fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
152+ setPermissionsSync(targetPath);
152153 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
153154 anyContentAdded = true;
154155 }
src/fetch-patch.js+4 -4
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22import path from 'node:path';
33import { fileURLToPath } from 'node:url';
44import mime from 'mime-types';
5+import { serverDirectory } from './server-directory.js';
56
67const originalFetch = globalThis.fetch;
78
@@ -67,10 +68,9 @@ globalThis.fetch = async (/** @type {string | URL | Request} */ request, /** @ty
6768 }
6869 const url = getRequestURL(request);
6970 const filePath = path.resolve(fileURLToPath(url));
70- const cwd = path.resolve(process.cwd()) + path.sep;
71+ const isUnderServerDirectory = isPathUnderParent(serverDirectory, filePath);
71- const isUnderCwd = isPathUnderParent(cwd, filePath);
72+ if (!isUnderServerDirectory) {
72- if (!isUnderCwd) {
73+ throw new Error('Requested file path is outside of the server directory.');
73- throw new Error('Requested file path is outside of the current working directory.');
7474 }
7575 const parsedPath = path.parse(filePath);
7676 if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) {
src/server-directory.js+3 -0
@@ -0,0 +1,3 @@
1+import path from 'node:path';
2+import { fileURLToPath } from 'node:url';
3+export const serverDirectory = path.dirname(import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)));
src/server-main.js+386 -0
@@ -0,0 +1,386 @@
1+// native node modules
2+import path from 'node:path';
3+import util from 'node:util';
4+import net from 'node:net';
5+import dns from 'node:dns';
6+import process from 'node:process';
7+
8+import cors from 'cors';
9+import { csrfSync } from 'csrf-sync';
10+import express from 'express';
11+import compression from 'compression';
12+import cookieSession from 'cookie-session';
13+import multer from 'multer';
14+import responseTime from 'response-time';
15+import helmet from 'helmet';
16+import bodyParser from 'body-parser';
17+import open from 'open';
18+
19+// local library imports
20+import './fetch-patch.js';
21+import { serverDirectory } from './server-directory.js';
22+
23+console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
24+
25+// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
26+// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
27+// Safe to remove once support for Node v20 is dropped.
28+if (process.versions && process.versions.node && process.versions.node.match(/20\.[0-2]\.0/)) {
29+ // @ts-ignore
30+ if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
31+}
32+
33+import { serverEvents, EVENT_NAMES } from './server-events.js';
34+import { loadPlugins } from './plugin-loader.js';
35+import {
36+ initUserStorage,
37+ getCookieSecret,
38+ getCookieSessionName,
39+ ensurePublicDirectoriesExist,
40+ getUserDirectoriesList,
41+ migrateSystemPrompts,
42+ migrateUserData,
43+ requireLoginMiddleware,
44+ setUserDataMiddleware,
45+ shouldRedirectToLogin,
46+ cleanUploads,
47+ getSessionCookieAge,
48+ verifySecuritySettings,
49+ loginPageMiddleware,
50+} from './users.js';
51+
52+import getWebpackServeMiddleware from './middleware/webpack-serve.js';
53+import basicAuthMiddleware from './middleware/basicAuth.js';
54+import getWhitelistMiddleware from './middleware/whitelist.js';
55+import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
56+import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
57+import initRequestProxy from './request-proxy.js';
58+import getCacheBusterMiddleware from './middleware/cacheBuster.js';
59+import corsProxyMiddleware from './middleware/corsProxy.js';
60+import {
61+ getVersion,
62+ color,
63+ removeColorFormatting,
64+ getSeparator,
65+ safeReadFileSync,
66+ setupLogLevel,
67+ setWindowTitle,
68+} from './util.js';
69+import { UPLOADS_DIRECTORY } from './constants.js';
70+import { ensureThumbnailCache } from './endpoints/thumbnails.js';
71+
72+// Routers
73+import { router as usersPublicRouter } from './endpoints/users-public.js';
74+import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js';
75+import { checkForNewContent } from './endpoints/content-manager.js';
76+import { init as settingsInit } from './endpoints/settings.js';
77+import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
78+import { diskCache } from './endpoints/characters.js';
79+
80+// Unrestrict console logs display limit
81+util.inspect.defaultOptions.maxArrayLength = null;
82+util.inspect.defaultOptions.maxStringLength = null;
83+util.inspect.defaultOptions.depth = 4;
84+
85+const cliArgs = globalThis.COMMAND_LINE_ARGS;
86+
87+if (!cliArgs.enableIPv6 && !cliArgs.enableIPv4) {
88+ console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
89+ process.exit(1);
90+}
91+
92+try {
93+ if (cliArgs.dnsPreferIPv6) {
94+ dns.setDefaultResultOrder('ipv6first');
95+ console.log('Preferring IPv6 for DNS resolution');
96+ } else {
97+ dns.setDefaultResultOrder('ipv4first');
98+ console.log('Preferring IPv4 for DNS resolution');
99+ }
100+} catch (error) {
101+ console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.');
102+}
103+
104+const app = express();
105+app.use(helmet({
106+ contentSecurityPolicy: false,
107+}));
108+app.use(compression());
109+app.use(responseTime());
110+
111+app.use(bodyParser.json({ limit: '200mb' }));
112+app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' }));
113+
114+// CORS Settings //
115+const CORS = cors({
116+ origin: 'null',
117+ methods: ['OPTIONS'],
118+});
119+
120+app.use(CORS);
121+
122+if (cliArgs.listen && cliArgs.basicAuthMode) {
123+ app.use(basicAuthMiddleware);
124+}
125+
126+if (cliArgs.whitelistMode) {
127+ const whitelistMiddleware = await getWhitelistMiddleware();
128+ app.use(whitelistMiddleware);
129+}
130+
131+if (cliArgs.listen) {
132+ app.use(accessLoggerMiddleware());
133+}
134+
135+if (cliArgs.enableCorsProxy) {
136+ app.use('/proxy/:url(*)', corsProxyMiddleware);
137+} else {
138+ app.use('/proxy/:url(*)', async (_, res) => {
139+ const message = 'CORS proxy is disabled. Enable it in config.yaml or use the --corsProxy flag.';
140+ console.log(message);
141+ res.status(404).send(message);
142+ });
143+}
144+
145+app.use(cookieSession({
146+ name: getCookieSessionName(),
147+ sameSite: 'lax',
148+ httpOnly: true,
149+ maxAge: getSessionCookieAge(),
150+ secret: getCookieSecret(globalThis.DATA_ROOT),
151+}));
152+
153+app.use(setUserDataMiddleware);
154+
155+// CSRF Protection //
156+if (!cliArgs.disableCsrf) {
157+ const csrfSyncProtection = csrfSync({
158+ getTokenFromState: (req) => {
159+ if (!req.session) {
160+ console.error('(CSRF error) getTokenFromState: Session object not initialized');
161+ return;
162+ }
163+ return req.session.csrfToken;
164+ },
165+ getTokenFromRequest: (req) => {
166+ return req.headers['x-csrf-token']?.toString();
167+ },
168+ storeTokenInState: (req, token) => {
169+ if (!req.session) {
170+ console.error('(CSRF error) storeTokenInState: Session object not initialized');
171+ return;
172+ }
173+ req.session.csrfToken = token;
174+ },
175+ size: 32,
176+ });
177+
178+ app.get('/csrf-token', (req, res) => {
179+ res.json({
180+ 'token': csrfSyncProtection.generateToken(req),
181+ });
182+ });
183+
184+ // Customize the error message
185+ csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.');
186+ csrfSyncProtection.invalidCsrfTokenError.stack = undefined;
187+
188+ app.use(csrfSyncProtection.csrfSynchronisedProtection);
189+} else {
190+ console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n');
191+ app.get('/csrf-token', (req, res) => {
192+ res.json({
193+ 'token': 'disabled',
194+ });
195+ });
196+}
197+
198+// Static files
199+// Host index page
200+app.get('/', getCacheBusterMiddleware(), (request, response) => {
201+ if (shouldRedirectToLogin(request)) {
202+ const query = request.url.split('?')[1];
203+ const redirectUrl = query ? `/login?${query}` : '/login';
204+ return response.redirect(redirectUrl);
205+ }
206+
207+ return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') });
208+});
209+
210+// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
211+app.get('/callback/:source?', (request, response) => {
212+ const source = request.params.source;
213+ const query = request.url.split('?')[1];
214+ const searchParams = new URLSearchParams();
215+ source && searchParams.set('source', source);
216+ query && searchParams.set('query', query);
217+ const path = `/?${searchParams.toString()}`;
218+ return response.redirect(307, path);
219+});
220+
221+// Host login page
222+app.get('/login', loginPageMiddleware);
223+
224+// Host frontend assets
225+const webpackMiddleware = getWebpackServeMiddleware();
226+app.use(webpackMiddleware);
227+app.use(express.static(path.join(serverDirectory, 'public'), {}));
228+
229+// Public API
230+app.use('/api/users', usersPublicRouter);
231+
232+// Everything below this line requires authentication
233+app.use(requireLoginMiddleware);
234+app.get('/api/ping', (request, response) => {
235+ if (request.query.extend && request.session) {
236+ request.session.touch = Date.now();
237+ }
238+
239+ response.sendStatus(204);
240+});
241+
242+// File uploads
243+const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
244+app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
245+app.use(multerMonkeyPatch);
246+
247+app.get('/version', async function (_, response) {
248+ const data = await getVersion();
249+ response.send(data);
250+});
251+
252+redirectDeprecatedEndpoints(app);
253+setupPrivateEndpoints(app);
254+
255+/**
256+ * Tasks that need to be run before the server starts listening.
257+ * @returns {Promise<void>}
258+ */
259+async function preSetupTasks() {
260+ const version = await getVersion();
261+
262+ // Print formatted header
263+ console.log();
264+ console.log(`SillyTavern ${version.pkgVersion}`);
265+ if (version.gitBranch) {
266+ console.log(`Running '${version.gitBranch}' (${version.gitRevision}) - ${version.commitDate}`);
267+ if (!version.isLatest && ['staging', 'release'].includes(version.gitBranch)) {
268+ console.log('INFO: Currently not on the latest commit.');
269+ console.log(' Run \'git pull\' to update. If you have any merge conflicts, run \'git reset --hard\' and \'git pull\' to reset your branch.');
270+ }
271+ }
272+ console.log();
273+
274+ const directories = await getUserDirectoriesList();
275+ await checkForNewContent(directories);
276+ await ensureThumbnailCache(directories);
277+ await diskCache.verify(directories);
278+ cleanUploads();
279+ migrateAccessLog();
280+
281+ await settingsInit();
282+ await statsInit();
283+
284+ const pluginsDirectory = path.join(serverDirectory, 'plugins');
285+ const cleanupPlugins = await loadPlugins(app, pluginsDirectory);
286+ const consoleTitle = process.title;
287+
288+ let isExiting = false;
289+ const exitProcess = async () => {
290+ if (isExiting) return;
291+ isExiting = true;
292+ await statsOnExit();
293+ if (typeof cleanupPlugins === 'function') {
294+ await cleanupPlugins();
295+ }
296+ diskCache.dispose();
297+ setWindowTitle(consoleTitle);
298+ process.exit();
299+ };
300+
301+ // Set up event listeners for a graceful shutdown
302+ process.on('SIGINT', exitProcess);
303+ process.on('SIGTERM', exitProcess);
304+ process.on('uncaughtException', (err) => {
305+ console.error('Uncaught exception:', err);
306+ exitProcess();
307+ });
308+
309+ // Add request proxy.
310+ initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass });
311+
312+ // Wait for frontend libs to compile
313+ await webpackMiddleware.runWebpackCompiler();
314+}
315+
316+/**
317+ * Tasks that need to be run after the server starts listening.
318+ * @param {import('./server-startup.js').ServerStartupResult} result The result of the server startup
319+ * @returns {Promise<void>}
320+ */
321+async function postSetupTasks(result) {
322+ const autorunHostname = await cliArgs.getAutorunHostname(result);
323+ const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
324+
325+ if (cliArgs.autorun) {
326+ try {
327+ console.log('Launching in a browser...');
328+ await open(autorunUrl.toString());
329+ } catch (error) {
330+ console.error('Failed to launch the browser. Open the URL manually.');
331+ }
332+ }
333+
334+ setWindowTitle('SillyTavern WebServer');
335+
336+ let logListen = 'SillyTavern is listening on';
337+
338+ if (result.useIPv6 && !result.v6Failed) {
339+ logListen += color.green(
340+ ' IPv6: ' + cliArgs.getIPv6ListenUrl().host,
341+ );
342+ }
343+
344+ if (result.useIPv4 && !result.v4Failed) {
345+ logListen += color.green(
346+ ' IPv4: ' + cliArgs.getIPv4ListenUrl().host,
347+ );
348+ }
349+
350+ const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern';
351+ const plainGoToLog = removeColorFormatting(goToLog);
352+
353+ console.log(logListen);
354+ if (cliArgs.listen) {
355+ console.log();
356+ console.log('To limit connections to internal localhost only ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false".');
357+ console.log('Check the "access.log" file in the data directory to inspect incoming connections:', color.green(getAccessLogPath()));
358+ }
359+ console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
360+ console.log(goToLog);
361+ console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
362+
363+ setupLogLevel();
364+ serverEvents.emit(EVENT_NAMES.SERVER_STARTED, { url: autorunUrl });
365+}
366+
367+/**
368+ * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
369+ */
370+function apply404Middleware() {
371+ const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? '';
372+ app.use((req, res) => {
373+ res.status(404).send(notFoundWebpage);
374+ });
375+}
376+
377+// User storage module needs to be initialized before starting the server
378+initUserStorage(globalThis.DATA_ROOT)
379+ .then(ensurePublicDirectoriesExist)
380+ .then(migrateUserData)
381+ .then(migrateSystemPrompts)
382+ .then(verifySecuritySettings)
383+ .then(preSetupTasks)
384+ .then(apply404Middleware)
385+ .then(() => new ServerStartup(app, cliArgs).start())
386+ .then(postSetupTasks);
src/transformers.js+2 -1
@@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer';
55
66import { pipeline, env, RawImage } from 'sillytavern-transformers';
77import { getConfigValue } from './util.js';
8+import { serverDirectory } from './server-directory.js';
89
910configureTransformers();
1011
@@ -12,7 +13,7 @@ function configureTransformers() {
1213 // Limit the number of threads to 1 to avoid issues on Android
1314 env.backends.onnx.wasm.numThreads = 1;
1415 // Use WASM from a local folder to avoid CDN connections
1516 env.backends.onnx.wasm.wasmPaths = path.join(process.cwd()serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep;
1617}
1718
1819const tasks = {
src/users.js+2 -1
@@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL
1818import { getConfigValue, color, delay, generateTimestamp } from './util.js';
1919import { readSecret, writeSecret } from './endpoints/secrets.js';
2020import { getContentOfType } from './endpoints/content-manager.js';
21+import { serverDirectory } from './server-directory.js';
2122
2223export const KEY_PREFIX = 'user:';
2324const AVATAR_PREFIX = 'avatar:';
@@ -905,7 +906,7 @@ export async function loginPageMiddleware(request, response) {
905906 console.error('Error during auto-login:', error);
906907 }
907908
908909 return response.sendFile('login.html', { root: path.join(process.cwd()serverDirectory, 'public') });
909910}
910911
911912/**
src/util.js+65 -13
@@ -15,13 +15,15 @@ import yauzl from 'yauzl';
1515import mime from 'mime-types';
1616import { default as simpleGit } from 'simple-git';
1717import chalk from 'chalk';
18-import { LOG_LEVELS } from './constants.js';
1918import bytes from 'bytes';
19+import { LOG_LEVELS } from './constants.js';
20+import { serverDirectory } from './server-directory.js';
2021
2122/**
2223 * Parsed config object.
2324 */
2425let CACHED_CONFIG = null;
26+let CONFIG_PATH = null;
2527
2628/**
2729 * Converts a configuration key to an environment variable key.
@@ -32,22 +34,37 @@ let CACHED_CONFIG = null;
3234export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');
3335
3436/**
37+ * Set the config file path.
38+ * @param {string} configFilePath Path to the config file
39+ */
40+export function setConfigFilePath(configFilePath) {
41+ if (CONFIG_PATH !== null) {
42+ console.error(color.red('Config file path already set. Please restart the server to change the config file path.'));
43+ }
44+ CONFIG_PATH = path.resolve(configFilePath);
45+}
46+
47+/**
3548 * Returns the config object from the config.yaml file.
3649 * @returns {object} Config object
3750 */
3851export function getConfig() {
52+ if (CONFIG_PATH === null) {
53+ console.trace();
54+ console.error(color.red('No config file path set. Please set the config file path using setConfigFilePath().'));
55+ process.exit(1);
56+ }
3957 if (CACHED_CONFIG) {
4058 return CACHED_CONFIG;
4159 }
42-
60+ if (!fs.existsSync(CONFIG_PATH)) {
43- if (!fs.existsSync('./config.yaml')) {
4461 console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
4562 console.error(color.red('The program will now exit.'));
4663 process.exit(1);
4764 }
4865
4966 try {
5067 const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml')CONFIG_PATH, 'utf8'));
5168 CACHED_CONFIG = config;
5269 return config;
5370 } catch (error) {
@@ -121,20 +138,19 @@ export async function getVersion() {
121138
122139 try {
123140 const require = createRequire(import.meta.url);
124141 const pkgJson = require(path.join(process.cwd()serverDirectory, './package.json'));
125142 pkgVersion = pkgJson.version;
126143 if (commandExistsSync('git')) {
127144 const git = simpleGit({ baseDir: serverDirectory });
128- const cwd = process.cwd();
145+ gitRevision = await git.revparse(['--short', 'HEAD']);
129146 gitRevisiongitBranch = await git.cwd(cwd).revparse(['--shortabbrev-ref', 'HEAD']);
130147 gitBranchcommitDate = await git.cwd(cwd).revparseshow(['--abbrev-refs', 'HEAD--format=%ci', gitRevision]);
131- commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]);
132148
133149 const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']);
134150
135151 // Might fail, but exception is caught. Just don't run anything relevant after in this block...
136152 const localLatest = await git.cwd(cwd).revparse(['HEAD']);
137153 const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]);
138154 isLatest = localLatest === remoteLatest;
139155 }
140156 }
@@ -1071,3 +1087,39 @@ export function mutateJsonString(jsonString, mutation) {
10711087 return jsonString;
10721088 }
10731089}
1090+
1091+/**
1092+ * Sets the permissions of a file or directory to be writable.
1093+ * @param {string} targetPath Path to the file or directory
1094+ */
1095+export function setPermissionsSync(targetPath) {
1096+ /**
1097+ * Appends writable permission to the file mode.
1098+ * @param {string} filePath Path to the file
1099+ * @param {fs.Stats} stats File stats
1100+ */
1101+ function appendWritablePermission(filePath, stats) {
1102+ const currentMode = stats.mode;
1103+ const newMode = currentMode | 0o200;
1104+ if (newMode != currentMode) {
1105+ fs.chmodSync(filePath, newMode);
1106+ }
1107+ }
1108+
1109+ try {
1110+ const stats = fs.statSync(targetPath);
1111+
1112+ if (stats.isDirectory()) {
1113+ appendWritablePermission(targetPath, stats);
1114+ const files = fs.readdirSync(targetPath);
1115+
1116+ files.forEach((file) => {
1117+ setPermissionsSync(path.join(targetPath, file));
1118+ });
1119+ } else {
1120+ appendWritablePermission(targetPath, stats);
1121+ }
1122+ } catch (error) {
1123+ console.error(`Error setting write permissions for ${targetPath}:`, error);
1124+ }
1125+}
webpack.config.js+2 -1
@@ -1,6 +1,7 @@
11import process from 'node:process';
22import path from 'node:path';
33import isDocker from 'is-docker';
4+import { serverDirectory } from './src/server-directory.js';
45
56/**
67 * Get the Webpack configuration for the public/lib.js file.
@@ -40,7 +41,7 @@ export default function getPublicLibConfig(forceDist = false) {
4041
4142 return {
4243 mode: 'production',
4344 entry: 'path./join(serverDirectory, 'public/lib.js'),
4445 cache: {
4546 type: 'filesystem',
4647 cacheDirectory: cacheDirectory,