Immutable public and global content management (#5390) * Use custom init script instead of postinstall * Revert changes to start scripts in src\electron * Add global data to content manager * Add migration for public overrides and user.css location update * Update npm publish workflow to use 'omit=dev' flag in npm ci commands * Rename user.css readme file * Fix indentation in userCssMiddleware function * Add directory creation for content target * Restore template compile location * Move stylesheet up in index.json * Use path.resolve for user.css file path in userCssMiddleware * Correct capitalization in "Not Found" error page title and heading * Remove init run from startup scripts * Simplify user CSS file path resolution * Update userCssMiddleware comment

8e8f501279e51768fadd61ee9073336a45dc5965

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

Signed
20 files changed, +195 -141Ignore whitespace
.github/workflows/npm-publish.yml+2 -2
@@ -19,7 +19,7 @@ jobs:
19 - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v319 - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
20 with:20 with:
21 node-version: 2421 node-version: 24
22 - run: npm ci22 - run: npm ci --omit=dev --ignore-scripts
2323
24 publish-npm:24 publish-npm:
25 needs: build25 needs: build
@@ -30,5 +30,5 @@ jobs:
30 with:30 with:
31 node-version: 2431 node-version: 24
32 registry-url: https://registry.npmjs.org/32 registry-url: https://registry.npmjs.org/
33 - run: npm ci33 - run: npm ci --omit=dev --ignore-scripts
34 - run: npm publish34 - run: npm publish
Start.bat+0 -1
@@ -2,7 +2,6 @@
2pushd %~dp02pushd %~dp0
3set NODE_ENV=production3set NODE_ENV=production
4call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts4call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
5call npm run init
6node server.js %*5node server.js %*
7pause6pause
8popd7popd
UpdateAndStart.bat+0 -1
@@ -21,7 +21,6 @@ if %errorlevel% neq 0 (
21)21)
22set NODE_ENV=production22set NODE_ENV=production
23call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts23call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
24call npm run init
25node server.js %*24node server.js %*
26:end25:end
27pause26pause
UpdateForkAndStart.bat+0 -1
@@ -103,7 +103,6 @@ if %errorlevel% neq 0 (
103echo Installing npm packages and starting server103echo Installing npm packages and starting server
104set NODE_ENV=production104set NODE_ENV=production
105call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts105call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
106call npm run init
107node server.js %*106node server.js %*
108107
109:end108:end
default/public/error/forbidden-by-whitelist.html → default/content/errors/forbidden-by-whitelist.html+0 -0
default/public/error/host-not-allowed.html → default/content/errors/host-not-allowed.html+0 -0
default/public/error/unauthorized.html → default/content/errors/unauthorized.html+0 -0
default/public/error/url-not-found.html → default/content/errors/url-not-found.html+2 -2
@@ -2,11 +2,11 @@
2<html>2<html>
33
4<head>4<head>
5 <title>Not found</title>5 <title>Not Found</title>
6</head>6</head>
77
8<body>8<body>
9 <h1>Not found</h1>9 <h1>Not Found</h1>
10 <p>10 <p>
11 The requested URL was not found on this server.11 The requested URL was not found on this server.
12 </p>12 </p>
default/content/index.json+20 -0
@@ -654,5 +654,25 @@
654 {654 {
655 "filename": "presets/context/Gemma 4.json",655 "filename": "presets/context/Gemma 4.json",
656 "type": "context"656 "type": "context"
657 },
658 {
659 "filename": "user.css",
660 "type": "stylesheet"
661 },
662 {
663 "filename": "errors/forbidden-by-whitelist.html",
664 "type": "error_page"
665 },
666 {
667 "filename": "errors/host-not-allowed.html",
668 "type": "error_page"
669 },
670 {
671 "filename": "errors/unauthorized.html",
672 "type": "error_page"
673 },
674 {
675 "filename": "errors/url-not-found.html",
676 "type": "error_page"
657 }677 }
658]678]
default/public/css/user.css → default/content/user.css+0 -0
public/css/!USER-CSS-README.md+7 -0
@@ -0,0 +1,7 @@
1# Looking for user.css?
2
3user.css is now located under your data root directory in the "_css" folder.
4
5Example for the default data root:
6
7/data/_css/user.css
src/endpoints/content-manager.js+93 -23
@@ -53,22 +53,45 @@ export const CONTENT_TYPES = {
53 QUICK_REPLIES: 'quick_replies',53 QUICK_REPLIES: 'quick_replies',
54 SYSPROMPT: 'sysprompt',54 SYSPROMPT: 'sysprompt',
55 REASONING: 'reasoning',55 REASONING: 'reasoning',
56 ERROR_PAGE: 'error_page',
57 STYLESHEET: 'stylesheet',
56};58};
5759
58/**60/**
61 * @enum {string}
62 */
63export const CONTENT_SCOPE = {
64 USER: 'user',
65 GLOBAL: 'global',
66};
67
68/**
69 * Gets the scope of a content type.
70 * @param {CONTENT_TYPES} type Content type
71 * @returns {CONTENT_SCOPE} Resolved content scope
72 */
73function getScopeByType(type) {
74 const globalTypes = [
75 CONTENT_TYPES.ERROR_PAGE,
76 CONTENT_TYPES.STYLESHEET,
77 ];
78 return globalTypes.includes(type) ? CONTENT_SCOPE.GLOBAL : CONTENT_SCOPE.USER;
79}
80
81/**
59 * Gets the default presets from the content directory.82 * Gets the default presets from the content directory.
60 * @param {import('../users.js').UserDirectoryList} directories User directories83 * @param {import('../users.js').UserDirectoryList} directories User directories
61 * @returns {object[]} Array of default presets84 * @returns {object[]} Array of default presets
62 */85 */
63export function getDefaultPresets(directories) {86export function getDefaultPresets(directories) {
64 try {87 try {
65 const contentIndex = getContentIndex();88 const contentIndex = getContentIndex(CONTENT_SCOPE.USER);
66 const presets = [];89 const presets = [];
6790
68 for (const contentItem of contentIndex) {91 for (const contentItem of contentIndex) {
69 if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) {92 if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) {
70 contentItem.name = path.parse(contentItem.filename).name;93 contentItem.name = path.parse(contentItem.filename).name;
71 contentItem.folder = getTargetByType(contentItem.type, directories);94 contentItem.folder = getUserTargetByType(contentItem.type, directories);
72 presets.push(contentItem);95 presets.push(contentItem);
73 }96 }
74 }97 }
@@ -102,24 +125,18 @@ export function getDefaultPresetFile(filename) {
102}125}
103126
104/**127/**
105 * Seeds content for a user.128 * Seeds content from a content index into a target location.
106 * @param {ContentItem[]} contentIndex Content index129 * @param {ContentItem[]} contentIndex Content index
107 * @param {import('../users.js').UserDirectoryList} directories User directories130 * @param {string} contentLogPath Path to the content log file
108 * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)131 * @param {(type: string) => string | null} resolveTarget Function to resolve the target directory for a content type
109 * @returns {Promise<boolean>} Whether any content was added132 * @param {string[]} [forceCategories] List of categories to force check (even if content check is skipped)
133 * @returns {boolean} Whether any content was added
110 */134 */
111async function seedContentForUser(contentIndex, directories, forceCategories) {135function seedContent(contentIndex, contentLogPath, resolveTarget, forceCategories) {
112 let anyContentAdded = false;136 let anyContentAdded = false;
113
114 if (!fs.existsSync(directories.root)) {
115 fs.mkdirSync(directories.root, { recursive: true });
116 }
117
118 const contentLogPath = path.join(directories.root, 'content.log');
119 const contentLog = getContentLog(contentLogPath);137 const contentLog = getContentLog(contentLogPath);
120138
121 for (const contentItem of contentIndex) {139 for (const contentItem of contentIndex) {
122 // If the content item is already in the log, skip it
123 if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {140 if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {
124 continue;141 continue;
125 }142 }
@@ -136,7 +153,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
136 continue;153 continue;
137 }154 }
138155
139 const contentTarget = getTargetByType(contentItem.type, directories);156 const contentTarget = resolveTarget(contentItem.type);
140157
141 if (!contentTarget) {158 if (!contentTarget) {
142 console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);159 console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
@@ -152,6 +169,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
152 continue;169 continue;
153 }170 }
154171
172 fs.mkdirSync(contentTarget, { recursive: true });
155 fs.cpSync(contentPath, targetPath, { recursive: true, force: false });173 fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
156 setPermissionsSync(targetPath);174 setPermissionsSync(targetPath);
157 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);175 console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
@@ -163,6 +181,32 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
163}181}
164182
165/**183/**
184 * Seeds content for a user.
185 * @param {ContentItem[]} contentIndex Content index
186 * @param {import('../users.js').UserDirectoryList} directories User directories
187 * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
188 * @returns {Promise<boolean>} Whether any content was added
189 */
190async function seedContentForUser(contentIndex, directories, forceCategories) {
191 if (!fs.existsSync(directories.root)) {
192 fs.mkdirSync(directories.root, { recursive: true });
193 }
194
195 const contentLogPath = path.join(directories.root, 'content.log');
196 return seedContent(contentIndex, contentLogPath, (type) => getUserTargetByType(type, directories), forceCategories);
197}
198
199/**
200 * Seeds global content that is not user-specific, such as error pages.
201 * @param {ContentItem[]} contentIndex Content index
202 * @returns {Promise<boolean>} Whether any content was added
203 */
204async function seedGlobalContent(contentIndex) {
205 const contentLogPath = path.join(globalThis.DATA_ROOT, 'content.log');
206 return seedContent(contentIndex, contentLogPath, getGlobalTargetByType);
207}
208
209/**
166 * Checks for new content and seeds it for all users.210 * Checks for new content and seeds it for all users.
167 * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories211 * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
168 * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)212 * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
@@ -175,13 +219,19 @@ export async function checkForNewContent(directoriesList, forceCategories = [])
175 return;219 return;
176 }220 }
177221
178 const contentIndex = getContentIndex();222 const userContentIndex = getContentIndex(CONTENT_SCOPE.USER);
223 const globalContentIndex = getContentIndex(CONTENT_SCOPE.GLOBAL);
179 let anyContentAdded = false;224 let anyContentAdded = false;
180225
226 const globalSeedResult = await seedGlobalContent(globalContentIndex);
227 if (globalSeedResult) {
228 anyContentAdded = true;
229 }
230
181 for (const directories of directoriesList) {231 for (const directories of directoriesList) {
182 const seedResult = await seedContentForUser(contentIndex, directories, forceCategories);232 const userSeedResult = await seedContentForUser(userContentIndex, directories, forceCategories);
183233
184 if (seedResult) {234 if (userSeedResult) {
185 anyContentAdded = true;235 anyContentAdded = true;
186 }236 }
187 }237 }
@@ -198,9 +248,10 @@ export async function checkForNewContent(directoriesList, forceCategories = [])
198248
199/**249/**
200 * Gets combined content index from the content and scaffold directories.250 * Gets combined content index from the content and scaffold directories.
251 * @param {CONTENT_SCOPE} scope Scope of content to get
201 * @returns {ContentItem[]} Array of content index252 * @returns {ContentItem[]} Array of content index
202 */253 */
203function getContentIndex() {254function getContentIndex(scope = CONTENT_SCOPE.USER) {
204 const result = [];255 const result = [];
205256
206 if (fs.existsSync(scaffoldIndexPath)) {257 if (fs.existsSync(scaffoldIndexPath)) {
@@ -209,6 +260,7 @@ function getContentIndex() {
209 if (Array.isArray(scaffoldIndex)) {260 if (Array.isArray(scaffoldIndex)) {
210 scaffoldIndex.forEach((item) => {261 scaffoldIndex.forEach((item) => {
211 item.folder = scaffoldDirectory;262 item.folder = scaffoldDirectory;
263 item.scope = getScopeByType(item.type);
212 });264 });
213 result.push(...scaffoldIndex);265 result.push(...scaffoldIndex);
214 }266 }
@@ -220,22 +272,24 @@ function getContentIndex() {
220 if (Array.isArray(contentIndex)) {272 if (Array.isArray(contentIndex)) {
221 contentIndex.forEach((item) => {273 contentIndex.forEach((item) => {
222 item.folder = contentDirectory;274 item.folder = contentDirectory;
275 item.scope = getScopeByType(item.type);
223 });276 });
224 result.push(...contentIndex);277 result.push(...contentIndex);
225 }278 }
226 }279 }
227280
228 return result;281 return result.filter((item) => item.scope === scope);
229}282}
230283
231/**284/**
232 * Gets content by type and format.285 * Gets content by type and format.
233 * @param {string} type Type of content286 * @param {string} type Type of content
234 * @param {'json'|'string'|'raw'} format Format of content287 * @param {'json'|'string'|'raw'} format Format of content
288 * @param {CONTENT_SCOPE} scope Scope of content to get
235 * @returns {string[]|Buffer[]} Array of content289 * @returns {string[]|Buffer[]} Array of content
236 */290 */
237export function getContentOfType(type, format) {291export function getContentOfType(type, format, scope = CONTENT_SCOPE.USER) {
238 const contentIndex = getContentIndex();292 const contentIndex = getContentIndex(scope);
239 const indexItems = contentIndex.filter((item) => item.type === type && item.folder);293 const indexItems = contentIndex.filter((item) => item.type === type && item.folder);
240 const files = [];294 const files = [];
241 for (const item of indexItems) {295 for (const item of indexItems) {
@@ -269,7 +323,7 @@ export function getContentOfType(type, format) {
269 * @param {import('../users.js').UserDirectoryList} directories User directories323 * @param {import('../users.js').UserDirectoryList} directories User directories
270 * @returns {string | null} Target directory324 * @returns {string | null} Target directory
271 */325 */
272function getTargetByType(type, directories) {326export function getUserTargetByType(type, directories) {
273 switch (type) {327 switch (type) {
274 case CONTENT_TYPES.SETTINGS:328 case CONTENT_TYPES.SETTINGS:
275 return directories.root;329 return directories.root;
@@ -313,6 +367,22 @@ function getTargetByType(type, directories) {
313}367}
314368
315/**369/**
370 * Gets the target directory for global content types.
371 * @param {CONTENT_TYPES} type Content type
372 * @returns {string | null} Target directory
373 */
374export function getGlobalTargetByType(type) {
375 switch (type) {
376 case CONTENT_TYPES.ERROR_PAGE:
377 return path.join(globalThis.DATA_ROOT, '_errors');
378 case CONTENT_TYPES.STYLESHEET:
379 return path.join(globalThis.DATA_ROOT, '_css');
380 default:
381 return null;
382 }
383}
384
385/**
316 * Gets the content log from the content log file.386 * Gets the content log from the content log file.
317 * @param {string} contentLogPath Path to the content log file387 * @param {string} contentLogPath Path to the content log file
318 * @returns {string[]} Array of content log lines388 * @returns {string[]} Array of content log lines
src/middleware/basicAuth.js+2 -1
@@ -3,6 +3,7 @@
3 * allow access to the endpoint after successful authentication.3 * allow access to the endpoint after successful authentication.
4 */4 */
5import { Buffer } from 'node:buffer';5import { Buffer } from 'node:buffer';
6import path from 'node:path';
6import storage from 'node-persist';7import storage from 'node-persist';
7import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';8import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
8import { getConfigValue, safeReadFileSync } from '../util.js';9import { getConfigValue, safeReadFileSync } from '../util.js';
@@ -11,7 +12,7 @@ const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean')
11const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');12const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
1213
13const basicAuthMiddleware = async function (request, response, callback) {14const basicAuthMiddleware = async function (request, response, callback) {
14 const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';15 const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
15 const unauthorizedResponse = (res) => {16 const unauthorizedResponse = (res) => {
16 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');17 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
17 return res.status(401).send(unauthorizedWebpage);18 return res.status(401).send(unauthorizedWebpage);
src/middleware/hostWhitelist.js+1 -4
@@ -1,6 +1,5 @@
1import path from 'node:path';1import path from 'node:path';
2import { color, getConfigValue, safeReadFileSync } from '../util.js';2import { color, getConfigValue, safeReadFileSync } from '../util.js';
3import { serverDirectory } from '../server-directory.js';
4import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware';3import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware';
54
6const knownHosts = new Set();5const knownHosts = new Set();
@@ -10,11 +9,9 @@ const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false);
10const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', []));9const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', []));
11const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean');10const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean');
1211
13const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? '';
14
15const validationMiddleware = hostValidationMiddleware({12const validationMiddleware = hostValidationMiddleware({
16 allowedHosts: hostWhitelist,13 allowedHosts: hostWhitelist,
17 generateErrorMessage: () => hostNotAllowedHtml,14 generateErrorMessage: () => safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'host-not-allowed.html'))?.toString() ?? '',
18 errorResponseContentType: 'text/html',15 errorResponseContentType: 'text/html',
19});16});
2017
src/middleware/userCss.js+19 -0
@@ -0,0 +1,19 @@
1import path from 'node:path';
2import fs from 'node:fs';
3
4/**
5 * Provides an Express middleware function that serves a user-defined CSS file from the data directory if it exists.
6 * @type {import('express').Handler}
7 */
8export function userCssMiddleware(req, res, next) {
9 if (req.method === 'GET' && req.path === '/css/user.css') {
10 const userCssPath = path.resolve(path.join(globalThis.DATA_ROOT, '_css', 'user.css'));
11 if (fs.existsSync(userCssPath)) {
12 res.sendFile(userCssPath);
13 return;
14 }
15 }
16 next();
17}
18
19export default userCssMiddleware;
src/middleware/whitelist.js+1 -1
@@ -100,7 +100,7 @@ async function addDockerHostsToWhitelist() {
100 */100 */
101export default async function getWhitelistMiddleware() {101export default async function getWhitelistMiddleware() {
102 const forbiddenWebpage = Handlebars.compile(102 const forbiddenWebpage = Handlebars.compile(
103 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',103 safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'forbidden-by-whitelist.html')) ?? '',
104 );104 );
105105
106 const noLogPaths = [106 const noLogPaths = [
src/server-init.js+0 -102
@@ -1,113 +1,11 @@
1/**1/**
2 * Scripts to be done before starting the server for the first time.2 * Scripts to be done before starting the server for the first time.
3 */3 */
4import fs from 'node:fs';
5import path from 'node:path';4import path from 'node:path';
6import process from 'node:process';5import process from 'node:process';
7import yaml from 'yaml';
8import chalk from 'chalk';
9import { createRequire } from 'node:module';
10import { addMissingConfigValues } from './config-init.js';6import { addMissingConfigValues } from './config-init.js';
117
12/**
13 * Colorizes console output.
14 */
15const color = chalk;
16
17/**
18 * Converts the old config.conf file to the new config.yaml format.
19 */
20function convertConfig() {
21 if (fs.existsSync('./config.conf')) {
22 if (fs.existsSync('./config.yaml')) {
23 console.log(color.yellow('Both config.conf and config.yaml exist. Please delete config.conf manually.'));
24 return;
25 }
26
27 try {
28 console.log(color.blue('Converting config.conf to config.yaml. Your old config.conf will be renamed to config.conf.bak'));
29 fs.renameSync('./config.conf', './config.conf.cjs'); // Force loading as CommonJS
30 const require = createRequire(import.meta.url);
31 const config = require(path.join(process.cwd(), './config.conf.cjs'));
32 fs.copyFileSync('./config.conf.cjs', './config.conf.bak');
33 fs.rmSync('./config.conf.cjs');
34 fs.writeFileSync('./config.yaml', yaml.stringify(config));
35 console.log(color.green('Conversion successful. Please check your config.yaml and fix it if necessary.'));
36 } catch (error) {
37 console.error(color.red('FATAL: Config conversion failed. Please check your config.conf file and try again.'), error);
38 return;
39 }
40 }
41}
42
43/**
44 * Creates the default config files if they don't exist yet.
45 */
46function createDefaultFiles() {
47 /**
48 * @typedef DefaultItem
49 * @type {object}
50 * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure.
51 * @property {string} defaultPath - The path to the default item (typically in `default/`).
52 * @property {string} productionPath - The path to the copied item for production use.
53 */
54
55 /** @type {DefaultItem[]} */
56 const defaultItems = [
57 {
58 type: 'file',
59 defaultPath: './default/config.yaml',
60 productionPath: './config.yaml',
61 },
62 {
63 type: 'directory',
64 defaultPath: './default/public/',
65 productionPath: './public/',
66 },
67 ];
68
69 for (const defaultItem of defaultItems) {
70 try {
71 if (defaultItem.type === 'file') {
72 if (!fs.existsSync(defaultItem.productionPath)) {
73 fs.copyFileSync(
74 defaultItem.defaultPath,
75 defaultItem.productionPath,
76 );
77 console.log(
78 color.green(`Created default file: ${defaultItem.productionPath}`),
79 );
80 }
81 } else if (defaultItem.type === 'directory') {
82 fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, {
83 force: false, // Don't overwrite existing files!
84 recursive: true,
85 });
86 console.log(
87 color.green(`Synchronized missing files: ${defaultItem.productionPath}`),
88 );
89 } else {
90 throw new Error(
91 'FATAL: Unexpected default file format in `server-init.js#createDefaultFiles()`.',
92 );
93 }
94 } catch (error) {
95 console.error(
96 color.red(
97 `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`,
98 ),
99 error,
100 );
101 }
102 }
103}
104
105try {8try {
106 // 0. Convert config.conf to config.yaml
107 convertConfig();
108 // 1. Create default config files
109 createDefaultFiles();
110 // 2. Add missing config values
111 addMissingConfigValues(path.join(process.cwd(), './config.yaml'));9 addMissingConfigValues(path.join(process.cwd(), './config.yaml'));
112} catch (error) {10} catch (error) {
113 console.error(error);11 console.error(error);
src/server-main.js+5 -1
@@ -37,6 +37,7 @@ import {
37 getSessionCookieAge,37 getSessionCookieAge,
38 verifySecuritySettings,38 verifySecuritySettings,
39 loginPageMiddleware,39 loginPageMiddleware,
40 migratePublicOverrides,
40} from './users.js';41} from './users.js';
4142
42import getWebpackServeMiddleware from './middleware/webpack-serve.js';43import getWebpackServeMiddleware from './middleware/webpack-serve.js';
@@ -48,6 +49,7 @@ import initRequestProxy from './request-proxy.js';
48import cacheBuster from './middleware/cacheBuster.js';49import cacheBuster from './middleware/cacheBuster.js';
49import corsProxyMiddleware from './middleware/corsProxy.js';50import corsProxyMiddleware from './middleware/corsProxy.js';
50import hostWhitelistMiddleware from './middleware/hostWhitelist.js';51import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
52import userCssMiddleware from './middleware/userCss.js';
51import {53import {
52 getVersion,54 getVersion,
53 color,55 color,
@@ -229,6 +231,7 @@ app.get('/login', loginPageMiddleware);
229// Host frontend assets231// Host frontend assets
230const webpackMiddleware = getWebpackServeMiddleware();232const webpackMiddleware = getWebpackServeMiddleware();
231app.use(webpackMiddleware);233app.use(webpackMiddleware);
234app.use(userCssMiddleware);
232app.use(express.static(path.join(serverDirectory, 'public'), {}));235app.use(express.static(path.join(serverDirectory, 'public'), {}));
233236
234// Public API237// Public API
@@ -430,7 +433,7 @@ async function postSetupTasks(result) {
430 * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.433 * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered.
431 */434 */
432function apply404Middleware() {435function apply404Middleware() {
433 const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? '';436 const notFoundWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'url-not-found.html')) ?? '';
434 app.use((req, res) => {437 app.use((req, res) => {
435 res.status(404).send(notFoundWebpage);438 res.status(404).send(notFoundWebpage);
436 });439 });
@@ -459,6 +462,7 @@ initUserStorage(globalThis.DATA_ROOT)
459 .then(ensurePublicDirectoriesExist)462 .then(ensurePublicDirectoriesExist)
460 .then(migrateUserData)463 .then(migrateUserData)
461 .then(migrateSystemPrompts)464 .then(migrateSystemPrompts)
465 .then(migratePublicOverrides)
462 .then(verifySecuritySettings)466 .then(verifySecuritySettings)
463 .then(preSetupTasks)467 .then(preSetupTasks)
464 .then(apply404Middleware)468 .then(apply404Middleware)
src/users.js+43 -1
@@ -16,7 +16,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
16import sanitize from 'sanitize-filename';16import sanitize from 'sanitize-filename';
1717
18import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';18import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
19import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent } from './util.js';19import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js';
20import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js';20import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js';
21import { getContentOfType } from './endpoints/content-manager.js';21import { getContentOfType } from './endpoints/content-manager.js';
22import { serverDirectory } from './server-directory.js';22import { serverDirectory } from './server-directory.js';
@@ -484,6 +484,48 @@ export async function migrateSystemPrompts() {
484 }484 }
485}485}
486486
487export async function migratePublicOverrides() {
488 const migrationMap = [
489 {
490 oldPath: path.join(serverDirectory, 'public', 'error', 'forbidden-by-whitelist.html'),
491 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'forbidden-by-whitelist.html'),
492 },
493 {
494 oldPath: path.join(serverDirectory, 'public', 'error', 'host-not-allowed.html'),
495 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'host-not-allowed.html'),
496 },
497 {
498 oldPath: path.join(serverDirectory, 'public', 'error', 'unauthorized.html'),
499 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html'),
500 },
501 {
502 oldPath: path.join(serverDirectory, 'public', 'error', 'url-not-found.html'),
503 newPath: path.join(globalThis.DATA_ROOT, '_errors', 'url-not-found.html'),
504 },
505 {
506 oldPath: path.join(serverDirectory, 'public', 'css', 'user.css'),
507 newPath: path.join(globalThis.DATA_ROOT, '_css', 'user.css'),
508 },
509 ];
510
511 for (const { oldPath, newPath } of migrationMap) {
512 try {
513 if (fs.existsSync(newPath)) {
514 continue;
515 }
516 if (fs.existsSync(oldPath)) {
517 fs.mkdirSync(path.dirname(newPath), { recursive: true });
518 fs.cpSync(oldPath, newPath, { force: true });
519 fs.unlinkSync(oldPath);
520 setPermissionsSync(newPath);
521 console.log(`Migrated ${path.basename(oldPath)} to data root.`);
522 }
523 } catch (error) {
524 console.error(`Error migrating ${oldPath} to ${newPath}:`, error);
525 }
526 }
527}
528
487/**529/**
488 * Converts a user handle to a storage key.530 * Converts a user handle to a storage key.
489 * @param {string} handle User handle531 * @param {string} handle User handle
start.sh+0 -1
@@ -11,7 +11,6 @@ fi
11echo "Installing Node Modules..."11echo "Installing Node Modules..."
12export NODE_ENV=production12export NODE_ENV=production
13npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts13npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
14npm run init
1514
16echo "Entering SillyTavern..."15echo "Entering SillyTavern..."
17node "server.js" "$@"16node "server.js" "$@"