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 -641Showing whitespace changes
.dockerignore+1 -0
@@ -13,3 +13,4 @@ access.log
13/cache13/cache
14.DS_Store14.DS_Store
15/public/scripts/extensions/third-party15/public/scripts/extensions/third-party
16/colab
.github/readme.md+1 -0
@@ -350,6 +350,7 @@ Start.bat --port 8000 --listen false
350| Option | Description | Type |350| Option | Description | Type |
351|-------------------------|----------------------------------------------------------------------|----------|351|-------------------------|----------------------------------------------------------------------|----------|
352| `--version` | Show version number | boolean |352| `--version` | Show version number | boolean |
353| `--configPath` | Override the path to the config.yaml file | string |
353| `--dataRoot` | Root directory for data storage | string |354| `--dataRoot` | Root directory for data storage | string |
354| `--port` | Sets the port under which SillyTavern will run | number |355| `--port` | Sets the port under which SillyTavern will run | number |
355| `--listen` | SillyTavern will listen on all network interfaces | boolean |356| `--listen` | SillyTavern will listen on all network interfaces | boolean |
.npmignore+1 -0
@@ -12,3 +12,4 @@ access.log
12.vscode12.vscode
13.git13.git
14/public/scripts/extensions/third-party14/public/scripts/extensions/third-party
15/colab
Dockerfile+3 -5
@@ -12,15 +12,13 @@ WORKDIR ${APP_HOME}
12# Set NODE_ENV to production12# Set NODE_ENV to production
13ENV NODE_ENV=production13ENV NODE_ENV=production
1414
15# Install app dependencies15# Bundle app source
16COPY package*.json post-install.js ./16COPY . ./
17
17RUN \18RUN \
18 echo "*** Install npm packages ***" && \19 echo "*** Install npm packages ***" && \
19 npm i --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force20 npm i --no-audit --no-fund --loglevel=error --no-progress --omit=dev && npm cache clean --force
2021
21# Bundle app source
22COPY . ./
23
24# Copy default chats, characters and user avatars to <folder>.default folder22# Copy default chats, characters and user avatars to <folder>.default folder
25RUN \23RUN \
26 rm -f "config.yaml" || true && \24 rm -f "config.yaml" || true && \
post-install.js+3 -229
@@ -3,133 +3,17 @@
3 */3 */
4import fs from 'node:fs';4import fs from 'node:fs';
5import path from 'node:path';5import path from 'node:path';
6import crypto from 'node:crypto';
7import process from 'node:process';6import process from 'node:process';
8import yaml from 'yaml';7import yaml from 'yaml';
9import _ from 'lodash';
10import chalk from 'chalk';8import chalk from 'chalk';
11import { createRequire } from 'node:module';9import { createRequire } from 'node:module';
10import { addMissingConfigValues } from './src/config-init.js';
1211
13/**12/**
14 * Colorizes console output.13 * Colorizes console output.
15 */14 */
16const color = chalk;15const color = chalk;
1716
18const 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 */
118function 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
133/**17/**
134 * Converts the old config.conf file to the new config.yaml format.18 * Converts the old config.conf file to the new config.yaml format.
135 */19 */
@@ -157,71 +41,6 @@ function convertConfig() {
157}41}
15842
159/**43/**
160 * Compares the current config.yaml with the default config.yaml and adds any missing values.
161 */
162function 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/**
225 * Creates the default config files if they don't exist yet.44 * Creates the default config files if they don't exist yet.
226 */45 */
227function createDefaultFiles() {46function createDefaultFiles() {
@@ -283,58 +102,13 @@ function createDefaultFiles() {
283 }102 }
284}103}
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 */
291function 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 */
301function 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
329try {105try {
330 // 0. Convert config.conf to config.yaml106 // 0. Convert config.conf to config.yaml
331 convertConfig();107 convertConfig();
332 // 1. Create default config files108 // 1. Create default config files
333 createDefaultFiles();109 createDefaultFiles();
334 // 2. Copy transformers WASM binaries from node_modules110 // 2. Add missing config values
335 copyWasmFiles();111 addMissingConfigValues(path.join(process.cwd(), './config.yaml'));
336 // 3. Add missing config values
337 addMissingConfigValues();
338} catch (error) {112} catch (error) {
339 console.error(error);113 console.error(error);
340}114}
server.js+5 -383
@@ -1,393 +1,15 @@
1#!/usr/bin/env node1#!/usr/bin/env node
2
3// native node modules
4import path from 'node:path';
5import util from 'node:util';
6import net from 'node:net';
7import dns from 'node:dns';
8import process from 'node:process';
9import { fileURLToPath } from 'node:url';
10
11import cors from 'cors';
12import { csrfSync } from 'csrf-sync';
13import express from 'express';
14import compression from 'compression';
15import cookieSession from 'cookie-session';
16import multer from 'multer';
17import responseTime from 'response-time';
18import helmet from 'helmet';
19import bodyParser from 'body-parser';
20import open from 'open';
21
22// local library imports
23import './src/fetch-patch.js';
24import { serverEvents, EVENT_NAMES } from './src/server-events.js';
25import { CommandLineParser } from './src/command-line.js';2import { CommandLineParser } from './src/command-line.js';
26import { loadPlugins } from './src/plugin-loader.js';3import { serverDirectory } from './src/server-directory.js';
27import {
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
44import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
45import basicAuthMiddleware from './src/middleware/basicAuth.js';
46import getWhitelistMiddleware from './src/middleware/whitelist.js';
47import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
48import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
49import initRequestProxy from './src/request-proxy.js';
50import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
51import corsProxyMiddleware from './src/middleware/corsProxy.js';
52import {
53 getVersion,
54 color,
55 removeColorFormatting,
56 getSeparator,
57 safeReadFileSync,
58 setupLogLevel,
59 setWindowTitle,
60} from './src/util.js';
61import { UPLOADS_DIRECTORY } from './src/constants.js';
62import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
63
64// Routers
65import { router as usersPublicRouter } from './src/endpoints/users-public.js';
66import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.js';
67import { checkForNewContent } from './src/endpoints/content-manager.js';
68import { init as settingsInit } from './src/endpoints/settings.js';
69import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
70import { diskCache } from './src/endpoints/characters.js';
71
72// Unrestrict console logs display limit
73util.inspect.defaultOptions.maxArrayLength = null;
74util.inspect.defaultOptions.maxStringLength = null;
75util.inspect.defaultOptions.depth = 4;
76
77// Set a working directory for the server
78const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
79console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
80process.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.
85if (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
90const cliArgs = new CommandLineParser().parse(process.argv);6const cliArgs = new CommandLineParser().parse(process.argv);
91globalThis.DATA_ROOT = cliArgs.dataRoot;7globalThis.DATA_ROOT = cliArgs.dataRoot;
92globalThis.COMMAND_LINE_ARGS = cliArgs;8globalThis.COMMAND_LINE_ARGS = cliArgs;
9process.chdir(serverDirectory);
9310
94if (!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}
98
99try {
100 if (cliArgs.dnsPreferIPv6) {
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 }
107} catch (error) {
108 console.warn('Failed to set DNS resolution order. Possibly unsupported in this Node version.');
109}
110
111const app = express();
112app.use(helmet({
113 contentSecurityPolicy: false,
114}));
115app.use(compression());
116app.use(responseTime());
117
118app.use(bodyParser.json({ limit: '200mb' }));
119app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' }));
120
121// CORS Settings //
122const CORS = cors({
123 origin: 'null',
124 methods: ['OPTIONS'],
125});
126
127app.use(CORS);
128
129if (cliArgs.listen && cliArgs.basicAuthMode) {
130 app.use(basicAuthMiddleware);
131}
132
133if (cliArgs.whitelistMode) {
134 const whitelistMiddleware = await getWhitelistMiddleware();
135 app.use(whitelistMiddleware);
136}
137
138if (cliArgs.listen) {
139 app.use(accessLoggerMiddleware());
140}
141
142if (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
152app.use(cookieSession({
153 name: getCookieSessionName(),
154 sameSite: 'lax',
155 httpOnly: true,
156 maxAge: getSessionCookieAge(),
157 secret: getCookieSecret(globalThis.DATA_ROOT),
158}));
159
160app.use(setUserDataMiddleware);
161
162// CSRF Protection //
163if (!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
207app.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)
218app.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
229app.get('/login', loginPageMiddleware);
230
231// Host frontend assets
232const webpackMiddleware = getWebpackServeMiddleware();
233app.use(webpackMiddleware);
234app.use(express.static(process.cwd() + '/public', {}));
235
236// Public API
237app.use('/api/users', usersPublicRouter);
238
239// Everything below this line requires authentication
240app.use(requireLoginMiddleware);
241app.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
250const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
251app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
252app.use(multerMonkeyPatch);
253
254app.get('/version', async function (_, response) {
255 const data = await getVersion();
256 response.send(data);
257});
258
259redirectDeprecatedEndpoints(app);
260setupPrivateEndpoints(app);
261
262/**
263 * Tasks that need to be run before the server starts listening.
264 * @returns {Promise<void>}
265 */
266async 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 */
328async function postSetupTasks(result) {
329 const autorunHostname = await cliArgs.getAutorunHostname(result);
330 const autorunUrl = cliArgs.getAutorunUrl(autorunHostname);
331
332 if (cliArgs.autorun) {
333try {11try {
334 console.log('Launching in a browser...');12 await import('./src/server-main.js');
335 await open(autorunUrl.toString());
336} catch (error) {13} catch (error) {
337 console.error('Failed to launch the browser. Open the URL manually.');14 console.error('A critical error has occurred while starting the server:', error);
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}15}
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 */
377function apply404Middleware() {
378 const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
379 app.use((req, res) => {
380 res.status(404).send(notFoundWebpage);
381 });
382}
383
384// User storage module needs to be initialized before starting the server
385initUserStorage(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';
2import { hideBin } from 'yargs/helpers';2import { hideBin } from 'yargs/helpers';
3import ipRegex from 'ip-regex';3import ipRegex from 'ip-regex';
4import { canResolve, color, getConfigValue, stringToBool } from './util.js';4import { canResolve, color, getConfigValue, stringToBool } from './util.js';
5import { initConfig } from './config-init.js';
56
6/**7/**
7 * @typedef {object} CommandLineArguments Parsed command line arguments8 * @typedef {object} CommandLineArguments Parsed command line arguments
9 * @property {string} configPath Path to the config file
8 * @property {string} dataRoot Data root directory10 * @property {string} dataRoot Data root directory
9 * @property {number} port Port number11 * @property {number} port Port number
10 * @property {boolean} listen If SillyTavern is listening on all network interfaces12 * @property {boolean} listen If SillyTavern is listening on all network interfaces
@@ -40,6 +42,7 @@ export class CommandLineParser {
40 constructor() {42 constructor() {
41 /** @type {CommandLineArguments} */43 /** @type {CommandLineArguments} */
42 this.default = Object.freeze({44 this.default = Object.freeze({
45 configPath: './config.yaml',
43 dataRoot: './data',46 dataRoot: './data',
44 port: 8000,47 port: 8000,
45 listen: false,48 listen: false,
@@ -88,6 +91,11 @@ export class CommandLineParser {
88 parse(args) {91 parse(args) {
89 const cliArguments = yargs(hideBin(args))92 const cliArguments = yargs(hideBin(args))
90 .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')93 .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 })
91 .option('enableIPv6', {99 .option('enableIPv6', {
92 type: 'string',100 type: 'string',
93 default: null,101 default: null,
@@ -177,8 +185,11 @@ export class CommandLineParser {
177 describe: 'Request proxy bypass list (space separated list of hosts)',185 describe: 'Request proxy bypass list (space separated list of hosts)',
178 }).parseSync();186 }).parseSync();
179187
188 const configPath = cliArguments.configPath ?? this.default.configPath;
189 initConfig(configPath);
180 /** @type {CommandLineArguments} */190 /** @type {CommandLineArguments} */
181 const result = {191 const result = {
192 configPath: configPath,
182 dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),193 dataRoot: cliArguments.dataRoot ?? getConfigValue('dataRoot', this.default.dataRoot),
183 port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),194 port: cliArguments.port ?? getConfigValue('port', this.default.port, 'number'),
184 listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),195 listen: cliArguments.listen ?? getConfigValue('listen', this.default.listen, 'boolean'),
src/config-init.js+197 -0
@@ -0,0 +1,197 @@
1import fs from 'node:fs';
2import path from 'node:path';
3import yaml from 'yaml';
4import color from 'chalk';
5import _ from 'lodash';
6import { serverDirectory } from './server-directory.js';
7import { setConfigFilePath } from './util.js';
8
9const 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 */
109function 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 */
128export 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 */
194export function initConfig(configPath) {
195 setConfigFilePath(configPath);
196 addMissingConfigValues(configPath);
197}
src/endpoints/content-manager.js+5 -4
@@ -1,6 +1,5 @@
1import fs from 'node:fs';1import fs from 'node:fs';
2import path from 'node:path';2import path from 'node:path';
3import process from 'node:process';
4import { Buffer } from 'node:buffer';3import { Buffer } from 'node:buffer';
54
6import express from 'express';5import express from 'express';
@@ -8,11 +7,12 @@ import fetch from 'node-fetch';
8import sanitize from 'sanitize-filename';7import sanitize from 'sanitize-filename';
9import { sync as writeFileAtomicSync } from 'write-file-atomic';8import { sync as writeFileAtomicSync } from 'write-file-atomic';
109
11import { getConfigValue, color } from '../util.js';10import { getConfigValue, color, setPermissionsSync } from '../util.js';
12import { write } from '../character-card-parser.js';11import { write } from '../character-card-parser.js';
12import { serverDirectory } from '../server-directory.js';
1313
14const contentDirectory = path.join(process.cwd(), 'default/content');14const contentDirectory = path.join(serverDirectory, 'default/content');
15const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold');15const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold');
16const contentIndexPath = path.join(contentDirectory, 'index.json');16const contentIndexPath = path.join(contentDirectory, 'index.json');
17const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');17const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
1818
@@ -149,6 +149,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
149 }149 }
150150
151 fs.cpSync(contentPath, targetPath, { recursive: true, force: false });151 fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
152 setPermissionsSync(targetPath);
152 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);153 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
153 anyContentAdded = true;154 anyContentAdded = true;
154 }155 }
src/fetch-patch.js+4 -4
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2import path from 'node:path';2import path from 'node:path';
3import { fileURLToPath } from 'node:url';3import { fileURLToPath } from 'node:url';
4import mime from 'mime-types';4import mime from 'mime-types';
5import { serverDirectory } from './server-directory.js';
56
6const originalFetch = globalThis.fetch;7const originalFetch = globalThis.fetch;
78
@@ -67,10 +68,9 @@ globalThis.fetch = async (/** @type {string | URL | Request} */ request, /** @ty
67 }68 }
68 const url = getRequestURL(request);69 const url = getRequestURL(request);
69 const filePath = path.resolve(fileURLToPath(url));70 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.');
74 }74 }
75 const parsedPath = path.parse(filePath);75 const parsedPath = path.parse(filePath);
76 if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) {76 if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) {
src/server-directory.js+3 -0
@@ -0,0 +1,3 @@
1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3export 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
2import path from 'node:path';
3import util from 'node:util';
4import net from 'node:net';
5import dns from 'node:dns';
6import process from 'node:process';
7
8import cors from 'cors';
9import { csrfSync } from 'csrf-sync';
10import express from 'express';
11import compression from 'compression';
12import cookieSession from 'cookie-session';
13import multer from 'multer';
14import responseTime from 'response-time';
15import helmet from 'helmet';
16import bodyParser from 'body-parser';
17import open from 'open';
18
19// local library imports
20import './fetch-patch.js';
21import { serverDirectory } from './server-directory.js';
22
23console.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.
28if (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
33import { serverEvents, EVENT_NAMES } from './server-events.js';
34import { loadPlugins } from './plugin-loader.js';
35import {
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
52import getWebpackServeMiddleware from './middleware/webpack-serve.js';
53import basicAuthMiddleware from './middleware/basicAuth.js';
54import getWhitelistMiddleware from './middleware/whitelist.js';
55import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
56import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
57import initRequestProxy from './request-proxy.js';
58import getCacheBusterMiddleware from './middleware/cacheBuster.js';
59import corsProxyMiddleware from './middleware/corsProxy.js';
60import {
61 getVersion,
62 color,
63 removeColorFormatting,
64 getSeparator,
65 safeReadFileSync,
66 setupLogLevel,
67 setWindowTitle,
68} from './util.js';
69import { UPLOADS_DIRECTORY } from './constants.js';
70import { ensureThumbnailCache } from './endpoints/thumbnails.js';
71
72// Routers
73import { router as usersPublicRouter } from './endpoints/users-public.js';
74import { init as statsInit, onExit as statsOnExit } from './endpoints/stats.js';
75import { checkForNewContent } from './endpoints/content-manager.js';
76import { init as settingsInit } from './endpoints/settings.js';
77import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
78import { diskCache } from './endpoints/characters.js';
79
80// Unrestrict console logs display limit
81util.inspect.defaultOptions.maxArrayLength = null;
82util.inspect.defaultOptions.maxStringLength = null;
83util.inspect.defaultOptions.depth = 4;
84
85const cliArgs = globalThis.COMMAND_LINE_ARGS;
86
87if (!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
92try {
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
104const app = express();
105app.use(helmet({
106 contentSecurityPolicy: false,
107}));
108app.use(compression());
109app.use(responseTime());
110
111app.use(bodyParser.json({ limit: '200mb' }));
112app.use(bodyParser.urlencoded({ extended: true, limit: '200mb' }));
113
114// CORS Settings //
115const CORS = cors({
116 origin: 'null',
117 methods: ['OPTIONS'],
118});
119
120app.use(CORS);
121
122if (cliArgs.listen && cliArgs.basicAuthMode) {
123 app.use(basicAuthMiddleware);
124}
125
126if (cliArgs.whitelistMode) {
127 const whitelistMiddleware = await getWhitelistMiddleware();
128 app.use(whitelistMiddleware);
129}
130
131if (cliArgs.listen) {
132 app.use(accessLoggerMiddleware());
133}
134
135if (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
145app.use(cookieSession({
146 name: getCookieSessionName(),
147 sameSite: 'lax',
148 httpOnly: true,
149 maxAge: getSessionCookieAge(),
150 secret: getCookieSecret(globalThis.DATA_ROOT),
151}));
152
153app.use(setUserDataMiddleware);
154
155// CSRF Protection //
156if (!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
200app.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)
211app.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
222app.get('/login', loginPageMiddleware);
223
224// Host frontend assets
225const webpackMiddleware = getWebpackServeMiddleware();
226app.use(webpackMiddleware);
227app.use(express.static(path.join(serverDirectory, 'public'), {}));
228
229// Public API
230app.use('/api/users', usersPublicRouter);
231
232// Everything below this line requires authentication
233app.use(requireLoginMiddleware);
234app.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
243const uploadsPath = path.join(cliArgs.dataRoot, UPLOADS_DIRECTORY);
244app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar'));
245app.use(multerMonkeyPatch);
246
247app.get('/version', async function (_, response) {
248 const data = await getVersion();
249 response.send(data);
250});
251
252redirectDeprecatedEndpoints(app);
253setupPrivateEndpoints(app);
254
255/**
256 * Tasks that need to be run before the server starts listening.
257 * @returns {Promise<void>}
258 */
259async 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 */
321async 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 */
370function 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
378initUserStorage(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
6import { pipeline, env, RawImage } from 'sillytavern-transformers';6import { pipeline, env, RawImage } from 'sillytavern-transformers';
7import { getConfigValue } from './util.js';7import { getConfigValue } from './util.js';
8import { serverDirectory } from './server-directory.js';
89
9configureTransformers();10configureTransformers();
1011
@@ -12,7 +13,7 @@ function configureTransformers() {
12 // Limit the number of threads to 1 to avoid issues on Android13 // Limit the number of threads to 1 to avoid issues on Android
13 env.backends.onnx.wasm.numThreads = 1;14 env.backends.onnx.wasm.numThreads = 1;
14 // Use WASM from a local folder to avoid CDN connections15 // Use WASM from a local folder to avoid CDN connections
15 env.backends.onnx.wasm.wasmPaths = path.join(process.cwd(), 'dist') + path.sep;16 env.backends.onnx.wasm.wasmPaths = path.join(serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep;
16}17}
1718
18const tasks = {19const tasks = {
src/users.js+2 -1
@@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL
18import { getConfigValue, color, delay, 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';
21import { serverDirectory } from './server-directory.js';
2122
22export const KEY_PREFIX = 'user:';23export const KEY_PREFIX = 'user:';
23const AVATAR_PREFIX = 'avatar:';24const AVATAR_PREFIX = 'avatar:';
@@ -905,7 +906,7 @@ export async function loginPageMiddleware(request, response) {
905 console.error('Error during auto-login:', error);906 console.error('Error during auto-login:', error);
906 }907 }
907908
908 return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });909 return response.sendFile('login.html', { root: path.join(serverDirectory, 'public') });
909}910}
910911
911/**912/**
src/util.js+65 -13
@@ -15,13 +15,15 @@ import yauzl from 'yauzl';
15import mime from 'mime-types';15import mime from 'mime-types';
16import { default as simpleGit } from 'simple-git';16import { default as simpleGit } from 'simple-git';
17import chalk from 'chalk';17import chalk from 'chalk';
18import { LOG_LEVELS } from './constants.js';
19import bytes from 'bytes';18import bytes from 'bytes';
19import { LOG_LEVELS } from './constants.js';
20import { serverDirectory } from './server-directory.js';
2021
21/**22/**
22 * Parsed config object.23 * Parsed config object.
23 */24 */
24let CACHED_CONFIG = null;25let CACHED_CONFIG = null;
26let CONFIG_PATH = null;
2527
26/**28/**
27 * Converts a configuration key to an environment variable key.29 * Converts a configuration key to an environment variable key.
@@ -32,22 +34,37 @@ let CACHED_CONFIG = null;
32export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');34export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');
3335
34/**36/**
37 * Set the config file path.
38 * @param {string} configFilePath Path to the config file
39 */
40export 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/**
35 * Returns the config object from the config.yaml file.48 * Returns the config object from the config.yaml file.
36 * @returns {object} Config object49 * @returns {object} Config object
37 */50 */
38export function getConfig() {51export 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 }
39 if (CACHED_CONFIG) {57 if (CACHED_CONFIG) {
40 return CACHED_CONFIG;58 return CACHED_CONFIG;
41 }59 }
4260 if (!fs.existsSync(CONFIG_PATH)) {
43 if (!fs.existsSync('./config.yaml')) {
44 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.'));61 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.'));
45 console.error(color.red('The program will now exit.'));62 console.error(color.red('The program will now exit.'));
46 process.exit(1);63 process.exit(1);
47 }64 }
4865
49 try {66 try {
50 const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));67 const config = yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
51 CACHED_CONFIG = config;68 CACHED_CONFIG = config;
52 return config;69 return config;
53 } catch (error) {70 } catch (error) {
@@ -121,20 +138,19 @@ export async function getVersion() {
121138
122 try {139 try {
123 const require = createRequire(import.meta.url);140 const require = createRequire(import.meta.url);
124 const pkgJson = require(path.join(process.cwd(), './package.json'));141 const pkgJson = require(path.join(serverDirectory, './package.json'));
125 pkgVersion = pkgJson.version;142 pkgVersion = pkgJson.version;
126 if (commandExistsSync('git')) {143 if (commandExistsSync('git')) {
127 const git = simpleGit();144 const git = simpleGit({ baseDir: serverDirectory });
128 const cwd = process.cwd();145 gitRevision = await git.revparse(['--short', 'HEAD']);
129 gitRevision = await git.cwd(cwd).revparse(['--short', 'HEAD']);146 gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
130 gitBranch = await git.cwd(cwd).revparse(['--abbrev-ref', 'HEAD']);147 commitDate = await git.show(['-s', '--format=%ci', gitRevision]);
131 commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]);
132148
133 const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']);149 const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']);
134150
135 // Might fail, but exception is caught. Just don't run anything relevant after in this block...151 // Might fail, but exception is caught. Just don't run anything relevant after in this block...
136 const localLatest = await git.cwd(cwd).revparse(['HEAD']);152 const localLatest = await git.revparse(['HEAD']);
137 const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]);153 const remoteLatest = await git.revparse([trackingBranch]);
138 isLatest = localLatest === remoteLatest;154 isLatest = localLatest === remoteLatest;
139 }155 }
140 }156 }
@@ -1071,3 +1087,39 @@ export function mutateJsonString(jsonString, mutation) {
1071 return jsonString;1087 return jsonString;
1072 }1088 }
1073}1089}
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 */
1095export 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 @@
1import process from 'node:process';1import process from 'node:process';
2import path from 'node:path';2import path from 'node:path';
3import isDocker from 'is-docker';3import isDocker from 'is-docker';
4import { serverDirectory } from './src/server-directory.js';
45
5/**6/**
6 * Get the Webpack configuration for the public/lib.js file.7 * Get the Webpack configuration for the public/lib.js file.
@@ -40,7 +41,7 @@ export default function getPublicLibConfig(forceDist = false) {
4041
41 return {42 return {
42 mode: 'production',43 mode: 'production',
43 entry: './public/lib.js',44 entry: path.join(serverDirectory, 'public/lib.js'),
44 cache: {45 cache: {
45 type: 'filesystem',46 type: 'filesystem',
46 cacheDirectory: cacheDirectory,47 cacheDirectory: cacheDirectory,