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.
Signed| @@ -3,7 +3,6 @@ | |||
| 3 | */ | 3 | */ |
| 4 | import fs from 'node:fs'; | 4 | import fs from 'node:fs'; |
| 5 | import path from 'node:path'; | 5 | import path from 'node:path'; |
| 6 | import crypto from 'node:crypto'; | ||
| 7 | import process from 'node:process'; | 6 | import process from 'node:process'; |
| 8 | import yaml from 'yaml'; | 7 | import yaml from 'yaml'; |
| 9 | import _ from 'lodash'; | 8 | import _ from 'lodash'; |
| @@ -283,57 +282,12 @@ function createDefaultFiles() { | |||
| 283 | } | 282 | } |
| 284 | } | 283 | } |
| 285 | 284 | ||
| 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 | |||
| 329 | try { | 285 | try { |
| 330 | // 0. Convert config.conf to config.yaml | 286 | // 0. Convert config.conf to config.yaml |
| 331 | convertConfig(); | 287 | convertConfig(); |
| 332 | // 1. Create default config files | 288 | // 1. Create default config files |
| 333 | createDefaultFiles(); | 289 | createDefaultFiles(); |
| 334 | // 2. Copy transformers WASM binaries from node_modules | 290 | // 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); |
| @@ -6,7 +6,6 @@ import util from 'node:util'; | |||
| 6 | import net from 'node:net'; | 6 | import net from 'node:net'; |
| 7 | import dns from 'node:dns'; | 7 | import dns from 'node:dns'; |
| 8 | import process from 'node:process'; | 8 | import process from 'node:process'; |
| 9 | import { fileURLToPath } from 'node:url'; | ||
| 10 | 9 | ||
| 11 | import cors from 'cors'; | 10 | import cors from 'cors'; |
| 12 | import { csrfSync } from 'csrf-sync'; | 11 | import { csrfSync } from 'csrf-sync'; |
| @@ -60,6 +59,7 @@ import { | |||
| 60 | } from './src/util.js'; | 59 | } from './src/util.js'; |
| 61 | import { UPLOADS_DIRECTORY } from './src/constants.js'; | 60 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 62 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; | 61 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| 62 | import { serverDirectory } from './src/server-directory.js'; | ||
| 63 | 63 | ||
| 64 | // Routers | 64 | // Routers |
| 65 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; | 65 | import { router as usersPublicRouter } from './src/endpoints/users-public.js'; |
| @@ -74,10 +74,7 @@ util.inspect.defaultOptions.maxArrayLength = null; | |||
| 74 | util.inspect.defaultOptions.maxStringLength = null; | 74 | util.inspect.defaultOptions.maxStringLength = null; |
| 75 | util.inspect.defaultOptions.depth = 4; | 75 | util.inspect.defaultOptions.depth = 4; |
| 76 | 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}`); | 77 | console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`); |
| 80 | process.chdir(serverDirectory); | ||
| 81 | 78 | ||
| 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-1564708870 | 80 | // 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 | } |
| 213 | 210 | ||
| 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 | }); |
| 216 | 213 | ||
| 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 assets | 228 | // Host frontend assets |
| 232 | const webpackMiddleware = getWebpackServeMiddleware(); | 229 | const webpackMiddleware = getWebpackServeMiddleware(); |
| 233 | app.use(webpackMiddleware); | 230 | app.use(webpackMiddleware); |
| 234 | app.use(express.static(process.cwd() + '/public', {})); | 231 | app.use(express.static(path.join(serverDirectory, 'public'), {})); |
| 235 | 232 | ||
| 236 | // Public API | 233 | // Public API |
| 237 | app.use('/api/users', usersPublicRouter); | 234 | app.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 | */ |
| 377 | function apply404Middleware() { | 374 | function 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 | }); |
| @@ -1,6 +1,5 @@ | |||
| 1 | import fs from 'node:fs'; | 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import process from 'node:process'; | ||
| 4 | import { Buffer } from 'node:buffer'; | 3 | import { Buffer } from 'node:buffer'; |
| 5 | 4 | ||
| 6 | import express from 'express'; | 5 | import express from 'express'; |
| @@ -10,9 +9,10 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; | |||
| 10 | 9 | ||
| 11 | import { getConfigValue, color } from '../util.js'; | 10 | import { getConfigValue, color } from '../util.js'; |
| 12 | import { write } from '../character-card-parser.js'; | 11 | import { write } from '../character-card-parser.js'; |
| 12 | import { serverDirectory } from '../server-directory.js'; | ||
| 13 | 13 | ||
| 14 | const contentDirectory = path.join(process.cwd(), 'default/content'); | 14 | const contentDirectory = path.join(serverDirectory, 'default/content'); |
| 15 | const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold'); | 15 | const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold'); |
| 16 | const contentIndexPath = path.join(contentDirectory, 'index.json'); | 16 | const contentIndexPath = path.join(contentDirectory, 'index.json'); |
| 17 | const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json'); | 17 | const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json'); |
| 18 | 18 | ||
| @@ -149,6 +149,30 @@ async function seedContentForUser(contentIndex, directories, forceCategories) { | |||
| 149 | } | 149 | } |
| 150 | 150 | ||
| 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 | } |
| @@ -2,6 +2,7 @@ import fs from 'node:fs'; | |||
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import { fileURLToPath } from 'node:url'; | 3 | import { fileURLToPath } from 'node:url'; |
| 4 | import mime from 'mime-types'; | 4 | import mime from 'mime-types'; |
| 5 | import { serverDirectory } from './server-directory.js'; | ||
| 5 | 6 | ||
| 6 | const originalFetch = globalThis.fetch; | 7 | const originalFetch = globalThis.fetch; |
| 7 | 8 | ||
| @@ -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)) { |
| @@ -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))); | ||
| @@ -5,14 +5,16 @@ import { Buffer } from 'node:buffer'; | |||
| 5 | 5 | ||
| 6 | import { pipeline, env, RawImage } from 'sillytavern-transformers'; | 6 | import { pipeline, env, RawImage } from 'sillytavern-transformers'; |
| 7 | import { getConfigValue } from './util.js'; | 7 | import { getConfigValue } from './util.js'; |
| 8 | import { serverDirectory } from './server-directory.js'; | ||
| 8 | 9 | ||
| 9 | configureTransformers(); | 10 | configureTransformers(); |
| 10 | 11 | ||
| 11 | function configureTransformers() { | 12 | function configureTransformers() { |
| 12 | // Limit the number of threads to 1 to avoid issues on Android | 13 | // 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 connections | 16 | // 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 | } |
| 17 | 19 | ||
| 18 | const tasks = { | 20 | const tasks = { |
| @@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL | |||
| 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; | 18 | import { getConfigValue, color, delay, generateTimestamp } from './util.js'; |
| 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; | 19 | import { readSecret, writeSecret } from './endpoints/secrets.js'; |
| 20 | import { getContentOfType } from './endpoints/content-manager.js'; | 20 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 21 | import { serverDirectory } from './server-directory.js'; | ||
| 21 | 22 | ||
| 22 | export const KEY_PREFIX = 'user:'; | 23 | export const KEY_PREFIX = 'user:'; |
| 23 | const AVATAR_PREFIX = 'avatar:'; | 24 | const 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 | } |
| 907 | 908 | ||
| 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 | } |
| 910 | 911 | ||
| 911 | /** | 912 | /** |
| @@ -17,6 +17,7 @@ import { default as simpleGit } from 'simple-git'; | |||
| 17 | import chalk from 'chalk'; | 17 | import chalk from 'chalk'; |
| 18 | import { LOG_LEVELS } from './constants.js'; | 18 | import { LOG_LEVELS } from './constants.js'; |
| 19 | import bytes from 'bytes'; | 19 | import bytes from 'bytes'; |
| 20 | import { serverDirectory } from './server-directory.js'; | ||
| 20 | 21 | ||
| 21 | /** | 22 | /** |
| 22 | * Parsed config object. | 23 | * Parsed config object. |
| @@ -121,20 +122,19 @@ export async function getVersion() { | |||
| 121 | 122 | ||
| 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]); | ||
| 132 | 132 | ||
| 133 | const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']); | 133 | const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']); |
| 134 | 134 | ||
| 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 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | import process from 'node:process'; | 1 | import process from 'node:process'; |
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import isDocker from 'is-docker'; | 3 | import isDocker from 'is-docker'; |
| 4 | import { serverDirectory } from './src/server-directory.js'; | ||
| 4 | 5 | ||
| 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) { | |||
| 40 | 41 | ||
| 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, |