Allow read-only installation Fix #3453. Thanks to #3499, #3500 and #3521, most of the obstacles to read-only installation have been resolved. This PR addresses the final piece, ensuring that SillyTavern no longer changes directories to `serverDirectory` and outputs files there. Instead, it outputs or copies necessary files to the directory where it is being run. Now, `serverDirectory` is read-only for SillyTavern (i.e., SillyTavern will not attempt to modify `serverDirectory`). Additionally, this PR sets the permissions for copied `default-user` files to be writable, so even if SillyTavern is installed as read-only, the copied `default-user` folder can still be modified.

bf97686dfc99a1b01d10b043b794dd5626083f23

wrvsrx <wrvsrx@outlook.com>

Signed
9 files changed, +55 -73Showing whitespace changes
post-install.js+1 -47
@@ -3,7 +3,6 @@
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';8import _ from 'lodash';
@@ -283,57 +282,12 @@ function createDefaultFiles() {
283 }282 }
284}283}
285284
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 {285try {
330 // 0. Convert config.conf to config.yaml286 // 0. Convert config.conf to config.yaml
331 convertConfig();287 convertConfig();
332 // 1. Create default config files288 // 1. Create default config files
333 createDefaultFiles();289 createDefaultFiles();
334 // 2. Copy transformers WASM binaries from node_modules290 // 2. Add missing config values
335 copyWasmFiles();
336 // 3. Add missing config values
337 addMissingConfigValues();291 addMissingConfigValues();
338} catch (error) {292} catch (error) {
339 console.error(error);293 console.error(error);
server.js+4 -7
@@ -6,7 +6,6 @@ import util from 'node:util';
6import net from 'node:net';6import net from 'node:net';
7import dns from 'node:dns';7import dns from 'node:dns';
8import process from 'node:process';8import process from 'node:process';
9import { fileURLToPath } from 'node:url';
109
11import cors from 'cors';10import cors from 'cors';
12import { csrfSync } from 'csrf-sync';11import { csrfSync } from 'csrf-sync';
@@ -60,6 +59,7 @@ import {
60} from './src/util.js';59} from './src/util.js';
61import { UPLOADS_DIRECTORY } from './src/constants.js';60import { UPLOADS_DIRECTORY } from './src/constants.js';
62import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';61import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
62import { serverDirectory } from './src/server-directory.js';
6363
64// Routers64// Routers
65import { router as usersPublicRouter } from './src/endpoints/users-public.js';65import { router as usersPublicRouter } from './src/endpoints/users-public.js';
@@ -74,10 +74,7 @@ util.inspect.defaultOptions.maxArrayLength = null;
74util.inspect.defaultOptions.maxStringLength = null;74util.inspect.defaultOptions.maxStringLength = null;
75util.inspect.defaultOptions.depth = 4;75util.inspect.defaultOptions.depth = 4;
7676
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}`);77console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
80process.chdir(serverDirectory);
8178
82// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.79// 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-156470887080// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@@ -211,7 +208,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
211 return response.redirect(redirectUrl);208 return response.redirect(redirectUrl);
212 }209 }
213210
214 return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });211 return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') });
215});212});
216213
217// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)214// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
@@ -231,7 +228,7 @@ app.get('/login', loginPageMiddleware);
231// Host frontend assets228// Host frontend assets
232const webpackMiddleware = getWebpackServeMiddleware();229const webpackMiddleware = getWebpackServeMiddleware();
233app.use(webpackMiddleware);230app.use(webpackMiddleware);
234app.use(express.static(process.cwd() + '/public', {}));231app.use(express.static(path.join(serverDirectory, 'public'), {}));
235232
236// Public API233// Public API
237app.use('/api/users', usersPublicRouter);234app.use('/api/users', usersPublicRouter);
@@ -375,7 +372,7 @@ async function postSetupTasks(result) {
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.372 * 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 */373 */
377function apply404Middleware() {374function apply404Middleware() {
378 const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';375 const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? '';
379 app.use((req, res) => {376 app.use((req, res) => {
380 res.status(404).send(notFoundWebpage);377 res.status(404).send(notFoundWebpage);
381 });378 });
src/endpoints/content-manager.js+27 -3
@@ -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';
@@ -10,9 +9,10 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
109
11import { getConfigValue, color } from '../util.js';10import { getConfigValue, color } 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,30 @@ 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 function setPermissionsSync(targetPath_) {
153
154 function appendWritablePermission(filepath, stats) {
155 const currentMode = stats.mode;
156 const newMode = currentMode | 0o200;
157 if (newMode != currentMode) {
158 fs.chmodSync(filepath, newMode);
159 }
160 }
161
162 const stats = fs.statSync(targetPath_);
163
164 if (stats.isDirectory()) {
165 appendWritablePermission(targetPath_, stats);
166 const files = fs.readdirSync(targetPath_);
167
168 files.forEach((file) => {
169 setPermissionsSync(path.join(targetPath_, file));
170 });
171 } else {
172 appendWritablePermission(targetPath_, stats);
173 }
174 }
175 setPermissionsSync(targetPath);
152 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);176 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
153 anyContentAdded = true;177 anyContentAdded = true;
154 }178 }
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/transformers.js+3 -1
@@ -5,14 +5,16 @@ 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
11function configureTransformers() {12function 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;
15 console.log(env.backends.onnx.wasm.wasmPaths);
14 // Use WASM from a local folder to avoid CDN connections16 // Use WASM from a local folder to avoid CDN connections
15 env.backends.onnx.wasm.wasmPaths = path.join(process.cwd(), 'dist') + path.sep;17 env.backends.onnx.wasm.wasmPaths = path.join(serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep;
16}18}
1719
18const tasks = {20const 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+9 -9
@@ -17,6 +17,7 @@ import { default as simpleGit } from 'simple-git';
17import chalk from 'chalk';17import chalk from 'chalk';
18import { LOG_LEVELS } from './constants.js';18import { LOG_LEVELS } from './constants.js';
19import bytes from 'bytes';19import bytes from 'bytes';
20import { serverDirectory } from './server-directory.js';
2021
21/**22/**
22 * Parsed config object.23 * Parsed config object.
@@ -121,20 +122,19 @@ export async function getVersion() {
121122
122 try {123 try {
123 const require = createRequire(import.meta.url);124 const require = createRequire(import.meta.url);
124 const pkgJson = require(path.join(process.cwd(), './package.json'));125 const pkgJson = require(path.join(serverDirectory, './package.json'));
125 pkgVersion = pkgJson.version;126 pkgVersion = pkgJson.version;
126 if (commandExistsSync('git')) {127 if (commandExistsSync('git')) {
127 const git = simpleGit();128 const git = simpleGit({ baseDir: serverDirectory });
128 const cwd = process.cwd();129 gitRevision = await git.revparse(['--short', 'HEAD']);
129 gitRevision = await git.cwd(cwd).revparse(['--short', 'HEAD']);130 gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
130 gitBranch = await git.cwd(cwd).revparse(['--abbrev-ref', 'HEAD']);131 commitDate = await git.show(['-s', '--format=%ci', gitRevision]);
131 commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]);
132132
133 const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']);133 const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']);
134134
135 // Might fail, but exception is caught. Just don't run anything relevant after in this block...135 // 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']);136 const localLatest = await git.revparse(['HEAD']);
137 const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]);137 const remoteLatest = await git.revparse([trackingBranch]);
138 isLatest = localLatest === remoteLatest;138 isLatest = localLatest === remoteLatest;
139 }139 }
140 }140 }
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,