mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
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.
This commit is contained in:
@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import crypto from 'node:crypto';
|
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import yaml from 'yaml';
|
import yaml from 'yaml';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
@ -283,57 +282,12 @@ function createDefaultFiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the MD5 hash of the given data.
|
|
||||||
* @param {Buffer} data Input data
|
|
||||||
* @returns {string} MD5 hash of the input data
|
|
||||||
*/
|
|
||||||
function getMd5Hash(data) {
|
|
||||||
return crypto
|
|
||||||
.createHash('md5')
|
|
||||||
.update(new Uint8Array(data))
|
|
||||||
.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies the WASM binaries from the sillytavern-transformers package to the dist folder.
|
|
||||||
*/
|
|
||||||
function copyWasmFiles() {
|
|
||||||
if (!fs.existsSync('./dist')) {
|
|
||||||
fs.mkdirSync('./dist');
|
|
||||||
}
|
|
||||||
|
|
||||||
const listDir = fs.readdirSync('./node_modules/sillytavern-transformers/dist');
|
|
||||||
|
|
||||||
for (const file of listDir) {
|
|
||||||
if (file.endsWith('.wasm')) {
|
|
||||||
const sourcePath = `./node_modules/sillytavern-transformers/dist/${file}`;
|
|
||||||
const targetPath = `./dist/${file}`;
|
|
||||||
|
|
||||||
// Don't copy if the file already exists and is the same checksum
|
|
||||||
if (fs.existsSync(targetPath)) {
|
|
||||||
const sourceChecksum = getMd5Hash(fs.readFileSync(sourcePath));
|
|
||||||
const targetChecksum = getMd5Hash(fs.readFileSync(targetPath));
|
|
||||||
|
|
||||||
if (sourceChecksum === targetChecksum) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
|
||||||
console.log(`${file} successfully copied to ./dist/${file}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 0. Convert config.conf to config.yaml
|
// 0. Convert config.conf to config.yaml
|
||||||
convertConfig();
|
convertConfig();
|
||||||
// 1. Create default config files
|
// 1. Create default config files
|
||||||
createDefaultFiles();
|
createDefaultFiles();
|
||||||
// 2. Copy transformers WASM binaries from node_modules
|
// 2. Add missing config values
|
||||||
copyWasmFiles();
|
|
||||||
// 3. Add missing config values
|
|
||||||
addMissingConfigValues();
|
addMissingConfigValues();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
11
server.js
11
server.js
@ -6,7 +6,6 @@ import util from 'node:util';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { csrfSync } from 'csrf-sync';
|
import { csrfSync } from 'csrf-sync';
|
||||||
@ -60,6 +59,7 @@ import {
|
|||||||
} from './src/util.js';
|
} from './src/util.js';
|
||||||
import { UPLOADS_DIRECTORY } from './src/constants.js';
|
import { UPLOADS_DIRECTORY } from './src/constants.js';
|
||||||
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
|
import { ensureThumbnailCache } from './src/endpoints/thumbnails.js';
|
||||||
|
import { serverDirectory } from './src/server-directory.js';
|
||||||
|
|
||||||
// Routers
|
// Routers
|
||||||
import { router as usersPublicRouter } from './src/endpoints/users-public.js';
|
import { router as usersPublicRouter } from './src/endpoints/users-public.js';
|
||||||
@ -74,10 +74,7 @@ util.inspect.defaultOptions.maxArrayLength = null;
|
|||||||
util.inspect.defaultOptions.maxStringLength = null;
|
util.inspect.defaultOptions.maxStringLength = null;
|
||||||
util.inspect.defaultOptions.depth = 4;
|
util.inspect.defaultOptions.depth = 4;
|
||||||
|
|
||||||
// Set a working directory for the server
|
|
||||||
const serverDirectory = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
|
console.log(`Node version: ${process.version}. Running in ${process.env.NODE_ENV} environment. Server directory: ${serverDirectory}`);
|
||||||
process.chdir(serverDirectory);
|
|
||||||
|
|
||||||
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
|
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0.
|
||||||
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
|
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
|
||||||
@ -211,7 +208,7 @@ app.get('/', getCacheBusterMiddleware(), (request, response) => {
|
|||||||
return response.redirect(redirectUrl);
|
return response.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.sendFile('index.html', { root: path.join(process.cwd(), 'public') });
|
return response.sendFile('index.html', { root: path.join(serverDirectory, 'public') });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
|
// Callback endpoint for OAuth PKCE flows (e.g. OpenRouter)
|
||||||
@ -231,7 +228,7 @@ app.get('/login', loginPageMiddleware);
|
|||||||
// Host frontend assets
|
// Host frontend assets
|
||||||
const webpackMiddleware = getWebpackServeMiddleware();
|
const webpackMiddleware = getWebpackServeMiddleware();
|
||||||
app.use(webpackMiddleware);
|
app.use(webpackMiddleware);
|
||||||
app.use(express.static(process.cwd() + '/public', {}));
|
app.use(express.static(path.join(serverDirectory, 'public'), {}));
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
app.use('/api/users', usersPublicRouter);
|
app.use('/api/users', usersPublicRouter);
|
||||||
@ -375,7 +372,7 @@ async function postSetupTasks(result) {
|
|||||||
* Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
|
* Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
|
||||||
*/
|
*/
|
||||||
function apply404Middleware() {
|
function apply404Middleware() {
|
||||||
const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? '';
|
const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? '';
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).send(notFoundWebpage);
|
res.status(404).send(notFoundWebpage);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
@ -10,9 +9,10 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
|||||||
|
|
||||||
import { getConfigValue, color } from '../util.js';
|
import { getConfigValue, color } from '../util.js';
|
||||||
import { write } from '../character-card-parser.js';
|
import { write } from '../character-card-parser.js';
|
||||||
|
import { serverDirectory } from '../server-directory.js';
|
||||||
|
|
||||||
const contentDirectory = path.join(process.cwd(), 'default/content');
|
const contentDirectory = path.join(serverDirectory, 'default/content');
|
||||||
const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold');
|
const scaffoldDirectory = path.join(serverDirectory, 'default/scaffold');
|
||||||
const contentIndexPath = path.join(contentDirectory, 'index.json');
|
const contentIndexPath = path.join(contentDirectory, 'index.json');
|
||||||
const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
|
const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
|
||||||
|
|
||||||
@ -149,6 +149,30 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
|
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
|
||||||
|
function setPermissionsSync(targetPath_) {
|
||||||
|
|
||||||
|
function appendWritablePermission(filepath, stats) {
|
||||||
|
const currentMode = stats.mode;
|
||||||
|
const newMode = currentMode | 0o200;
|
||||||
|
if (newMode != currentMode) {
|
||||||
|
fs.chmodSync(filepath, newMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(targetPath_);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
appendWritablePermission(targetPath_, stats);
|
||||||
|
const files = fs.readdirSync(targetPath_);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
setPermissionsSync(path.join(targetPath_, file));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
appendWritablePermission(targetPath_, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPermissionsSync(targetPath);
|
||||||
console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
|
console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
|
||||||
anyContentAdded = true;
|
anyContentAdded = true;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
import { serverDirectory } from './server-directory.js';
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@ -67,10 +68,9 @@ globalThis.fetch = async (/** @type {string | URL | Request} */ request, /** @ty
|
|||||||
}
|
}
|
||||||
const url = getRequestURL(request);
|
const url = getRequestURL(request);
|
||||||
const filePath = path.resolve(fileURLToPath(url));
|
const filePath = path.resolve(fileURLToPath(url));
|
||||||
const cwd = path.resolve(process.cwd()) + path.sep;
|
const isUnderServerDirectory = isPathUnderParent(serverDirectory, filePath);
|
||||||
const isUnderCwd = isPathUnderParent(cwd, filePath);
|
if (!isUnderServerDirectory) {
|
||||||
if (!isUnderCwd) {
|
throw new Error('Requested file path is outside of the server directory.');
|
||||||
throw new Error('Requested file path is outside of the current working directory.');
|
|
||||||
}
|
}
|
||||||
const parsedPath = path.parse(filePath);
|
const parsedPath = path.parse(filePath);
|
||||||
if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) {
|
if (!ALLOWED_EXTENSIONS.includes(parsedPath.ext)) {
|
||||||
|
3
src/server-directory.js
Normal file
3
src/server-directory.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
export const serverDirectory = path.dirname(import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)));
|
@ -5,14 +5,16 @@ import { Buffer } from 'node:buffer';
|
|||||||
|
|
||||||
import { pipeline, env, RawImage } from 'sillytavern-transformers';
|
import { pipeline, env, RawImage } from 'sillytavern-transformers';
|
||||||
import { getConfigValue } from './util.js';
|
import { getConfigValue } from './util.js';
|
||||||
|
import { serverDirectory } from './server-directory.js';
|
||||||
|
|
||||||
configureTransformers();
|
configureTransformers();
|
||||||
|
|
||||||
function configureTransformers() {
|
function configureTransformers() {
|
||||||
// Limit the number of threads to 1 to avoid issues on Android
|
// Limit the number of threads to 1 to avoid issues on Android
|
||||||
env.backends.onnx.wasm.numThreads = 1;
|
env.backends.onnx.wasm.numThreads = 1;
|
||||||
|
console.log(env.backends.onnx.wasm.wasmPaths);
|
||||||
// Use WASM from a local folder to avoid CDN connections
|
// Use WASM from a local folder to avoid CDN connections
|
||||||
env.backends.onnx.wasm.wasmPaths = path.join(process.cwd(), 'dist') + path.sep;
|
env.backends.onnx.wasm.wasmPaths = path.join(serverDirectory, 'node_modules', 'sillytavern-transformers', 'dist') + path.sep;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = {
|
const tasks = {
|
||||||
|
@ -18,6 +18,7 @@ import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FIL
|
|||||||
import { getConfigValue, color, delay, generateTimestamp } from './util.js';
|
import { getConfigValue, color, delay, generateTimestamp } from './util.js';
|
||||||
import { readSecret, writeSecret } from './endpoints/secrets.js';
|
import { readSecret, writeSecret } from './endpoints/secrets.js';
|
||||||
import { getContentOfType } from './endpoints/content-manager.js';
|
import { getContentOfType } from './endpoints/content-manager.js';
|
||||||
|
import { serverDirectory } from './server-directory.js';
|
||||||
|
|
||||||
export const KEY_PREFIX = 'user:';
|
export const KEY_PREFIX = 'user:';
|
||||||
const AVATAR_PREFIX = 'avatar:';
|
const AVATAR_PREFIX = 'avatar:';
|
||||||
@ -905,7 +906,7 @@ export async function loginPageMiddleware(request, response) {
|
|||||||
console.error('Error during auto-login:', error);
|
console.error('Error during auto-login:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.sendFile('login.html', { root: path.join(process.cwd(), 'public') });
|
return response.sendFile('login.html', { root: path.join(serverDirectory, 'public') });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
18
src/util.js
18
src/util.js
@ -17,6 +17,7 @@ import { default as simpleGit } from 'simple-git';
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { LOG_LEVELS } from './constants.js';
|
import { LOG_LEVELS } from './constants.js';
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
|
import { serverDirectory } from './server-directory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed config object.
|
* Parsed config object.
|
||||||
@ -121,20 +122,19 @@ export async function getVersion() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const pkgJson = require(path.join(process.cwd(), './package.json'));
|
const pkgJson = require(path.join(serverDirectory, './package.json'));
|
||||||
pkgVersion = pkgJson.version;
|
pkgVersion = pkgJson.version;
|
||||||
if (commandExistsSync('git')) {
|
if (commandExistsSync('git')) {
|
||||||
const git = simpleGit();
|
const git = simpleGit({ baseDir: serverDirectory });
|
||||||
const cwd = process.cwd();
|
gitRevision = await git.revparse(['--short', 'HEAD']);
|
||||||
gitRevision = await git.cwd(cwd).revparse(['--short', 'HEAD']);
|
gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
|
||||||
gitBranch = await git.cwd(cwd).revparse(['--abbrev-ref', 'HEAD']);
|
commitDate = await git.show(['-s', '--format=%ci', gitRevision]);
|
||||||
commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]);
|
|
||||||
|
|
||||||
const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']);
|
const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']);
|
||||||
|
|
||||||
// Might fail, but exception is caught. Just don't run anything relevant after in this block...
|
// Might fail, but exception is caught. Just don't run anything relevant after in this block...
|
||||||
const localLatest = await git.cwd(cwd).revparse(['HEAD']);
|
const localLatest = await git.revparse(['HEAD']);
|
||||||
const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]);
|
const remoteLatest = await git.revparse([trackingBranch]);
|
||||||
isLatest = localLatest === remoteLatest;
|
isLatest = localLatest === remoteLatest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import isDocker from 'is-docker';
|
import isDocker from 'is-docker';
|
||||||
|
import { serverDirectory } from './src/server-directory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Webpack configuration for the public/lib.js file.
|
* Get the Webpack configuration for the public/lib.js file.
|
||||||
@ -40,7 +41,7 @@ export default function getPublicLibConfig(forceDist = false) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
entry: './public/lib.js',
|
entry: path.join(serverDirectory, 'public/lib.js'),
|
||||||
cache: {
|
cache: {
|
||||||
type: 'filesystem',
|
type: 'filesystem',
|
||||||
cacheDirectory: cacheDirectory,
|
cacheDirectory: cacheDirectory,
|
||||||
|
Reference in New Issue
Block a user