diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index 966c01129..e548f0d64 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
with:
node-version: 24
- - run: npm ci
+ - run: npm ci --omit=dev --ignore-scripts
publish-npm:
needs: build
@@ -30,5 +30,5 @@ jobs:
with:
node-version: 24
registry-url: https://registry.npmjs.org/
- - run: npm ci
+ - run: npm ci --omit=dev --ignore-scripts
- run: npm publish
diff --git a/Start.bat b/Start.bat
index f86f4333a..f9acf52ae 100644
--- a/Start.bat
+++ b/Start.bat
@@ -2,7 +2,6 @@
pushd %~dp0
set NODE_ENV=production
call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
-call npm run init
node server.js %*
pause
popd
diff --git a/UpdateAndStart.bat b/UpdateAndStart.bat
index b47d32b92..b142a1405 100644
--- a/UpdateAndStart.bat
+++ b/UpdateAndStart.bat
@@ -21,7 +21,6 @@ if %errorlevel% neq 0 (
)
set NODE_ENV=production
call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
-call npm run init
node server.js %*
:end
pause
diff --git a/UpdateForkAndStart.bat b/UpdateForkAndStart.bat
index 301e114cd..e02fd13d5 100644
--- a/UpdateForkAndStart.bat
+++ b/UpdateForkAndStart.bat
@@ -103,7 +103,6 @@ if %errorlevel% neq 0 (
echo Installing npm packages and starting server
set NODE_ENV=production
call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
-call npm run init
node server.js %*
:end
diff --git a/default/public/error/forbidden-by-whitelist.html b/default/content/errors/forbidden-by-whitelist.html
similarity index 100%
rename from default/public/error/forbidden-by-whitelist.html
rename to default/content/errors/forbidden-by-whitelist.html
diff --git a/default/public/error/host-not-allowed.html b/default/content/errors/host-not-allowed.html
similarity index 100%
rename from default/public/error/host-not-allowed.html
rename to default/content/errors/host-not-allowed.html
diff --git a/default/public/error/unauthorized.html b/default/content/errors/unauthorized.html
similarity index 100%
rename from default/public/error/unauthorized.html
rename to default/content/errors/unauthorized.html
diff --git a/default/public/error/url-not-found.html b/default/content/errors/url-not-found.html
similarity index 72%
rename from default/public/error/url-not-found.html
rename to default/content/errors/url-not-found.html
index 87974145f..9c881dfc2 100644
--- a/default/public/error/url-not-found.html
+++ b/default/content/errors/url-not-found.html
@@ -2,11 +2,11 @@
- Not found
+ Not Found
- Not found
+ Not Found
The requested URL was not found on this server.
diff --git a/default/content/index.json b/default/content/index.json
index e713accda..39191f07a 100644
--- a/default/content/index.json
+++ b/default/content/index.json
@@ -654,5 +654,25 @@
{
"filename": "presets/context/Gemma 4.json",
"type": "context"
+ },
+ {
+ "filename": "user.css",
+ "type": "stylesheet"
+ },
+ {
+ "filename": "errors/forbidden-by-whitelist.html",
+ "type": "error_page"
+ },
+ {
+ "filename": "errors/host-not-allowed.html",
+ "type": "error_page"
+ },
+ {
+ "filename": "errors/unauthorized.html",
+ "type": "error_page"
+ },
+ {
+ "filename": "errors/url-not-found.html",
+ "type": "error_page"
}
]
diff --git a/default/public/css/user.css b/default/content/user.css
similarity index 100%
rename from default/public/css/user.css
rename to default/content/user.css
diff --git a/public/css/!USER-CSS-README.md b/public/css/!USER-CSS-README.md
new file mode 100644
index 000000000..2fddf8644
--- /dev/null
+++ b/public/css/!USER-CSS-README.md
@@ -0,0 +1,7 @@
+# Looking for user.css?
+
+user.css is now located under your data root directory in the "_css" folder.
+
+Example for the default data root:
+
+/data/_css/user.css
diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js
index 5a51a9305..f034a35fb 100644
--- a/src/endpoints/content-manager.js
+++ b/src/endpoints/content-manager.js
@@ -53,8 +53,31 @@ export const CONTENT_TYPES = {
QUICK_REPLIES: 'quick_replies',
SYSPROMPT: 'sysprompt',
REASONING: 'reasoning',
+ ERROR_PAGE: 'error_page',
+ STYLESHEET: 'stylesheet',
};
+/**
+ * @enum {string}
+ */
+export const CONTENT_SCOPE = {
+ USER: 'user',
+ GLOBAL: 'global',
+};
+
+/**
+ * Gets the scope of a content type.
+ * @param {CONTENT_TYPES} type Content type
+ * @returns {CONTENT_SCOPE} Resolved content scope
+ */
+function getScopeByType(type) {
+ const globalTypes = [
+ CONTENT_TYPES.ERROR_PAGE,
+ CONTENT_TYPES.STYLESHEET,
+ ];
+ return globalTypes.includes(type) ? CONTENT_SCOPE.GLOBAL : CONTENT_SCOPE.USER;
+}
+
/**
* Gets the default presets from the content directory.
* @param {import('../users.js').UserDirectoryList} directories User directories
@@ -62,13 +85,13 @@ export const CONTENT_TYPES = {
*/
export function getDefaultPresets(directories) {
try {
- const contentIndex = getContentIndex();
+ const contentIndex = getContentIndex(CONTENT_SCOPE.USER);
const presets = [];
for (const contentItem of contentIndex) {
if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) {
contentItem.name = path.parse(contentItem.filename).name;
- contentItem.folder = getTargetByType(contentItem.type, directories);
+ contentItem.folder = getUserTargetByType(contentItem.type, directories);
presets.push(contentItem);
}
}
@@ -102,24 +125,18 @@ export function getDefaultPresetFile(filename) {
}
/**
- * Seeds content for a user.
+ * Seeds content from a content index into a target location.
* @param {ContentItem[]} contentIndex Content index
- * @param {import('../users.js').UserDirectoryList} directories User directories
- * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
- * @returns {Promise} Whether any content was added
+ * @param {string} contentLogPath Path to the content log file
+ * @param {(type: string) => string | null} resolveTarget Function to resolve the target directory for a content type
+ * @param {string[]} [forceCategories] List of categories to force check (even if content check is skipped)
+ * @returns {boolean} Whether any content was added
*/
-async function seedContentForUser(contentIndex, directories, forceCategories) {
+function seedContent(contentIndex, contentLogPath, resolveTarget, forceCategories) {
let anyContentAdded = false;
-
- if (!fs.existsSync(directories.root)) {
- fs.mkdirSync(directories.root, { recursive: true });
- }
-
- const contentLogPath = path.join(directories.root, 'content.log');
const contentLog = getContentLog(contentLogPath);
for (const contentItem of contentIndex) {
- // If the content item is already in the log, skip it
if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) {
continue;
}
@@ -136,7 +153,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
continue;
}
- const contentTarget = getTargetByType(contentItem.type, directories);
+ const contentTarget = resolveTarget(contentItem.type);
if (!contentTarget) {
console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`);
@@ -152,6 +169,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
continue;
}
+ fs.mkdirSync(contentTarget, { recursive: true });
fs.cpSync(contentPath, targetPath, { recursive: true, force: false });
setPermissionsSync(targetPath);
console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`);
@@ -162,6 +180,32 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
return anyContentAdded;
}
+/**
+ * Seeds content for a user.
+ * @param {ContentItem[]} contentIndex Content index
+ * @param {import('../users.js').UserDirectoryList} directories User directories
+ * @param {string[]} forceCategories List of categories to force check (even if content check is skipped)
+ * @returns {Promise} Whether any content was added
+ */
+async function seedContentForUser(contentIndex, directories, forceCategories) {
+ if (!fs.existsSync(directories.root)) {
+ fs.mkdirSync(directories.root, { recursive: true });
+ }
+
+ const contentLogPath = path.join(directories.root, 'content.log');
+ return seedContent(contentIndex, contentLogPath, (type) => getUserTargetByType(type, directories), forceCategories);
+}
+
+/**
+ * Seeds global content that is not user-specific, such as error pages.
+ * @param {ContentItem[]} contentIndex Content index
+ * @returns {Promise} Whether any content was added
+ */
+async function seedGlobalContent(contentIndex) {
+ const contentLogPath = path.join(globalThis.DATA_ROOT, 'content.log');
+ return seedContent(contentIndex, contentLogPath, getGlobalTargetByType);
+}
+
/**
* Checks for new content and seeds it for all users.
* @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
@@ -175,13 +219,19 @@ export async function checkForNewContent(directoriesList, forceCategories = [])
return;
}
- const contentIndex = getContentIndex();
+ const userContentIndex = getContentIndex(CONTENT_SCOPE.USER);
+ const globalContentIndex = getContentIndex(CONTENT_SCOPE.GLOBAL);
let anyContentAdded = false;
- for (const directories of directoriesList) {
- const seedResult = await seedContentForUser(contentIndex, directories, forceCategories);
+ const globalSeedResult = await seedGlobalContent(globalContentIndex);
+ if (globalSeedResult) {
+ anyContentAdded = true;
+ }
- if (seedResult) {
+ for (const directories of directoriesList) {
+ const userSeedResult = await seedContentForUser(userContentIndex, directories, forceCategories);
+
+ if (userSeedResult) {
anyContentAdded = true;
}
}
@@ -198,9 +248,10 @@ export async function checkForNewContent(directoriesList, forceCategories = [])
/**
* Gets combined content index from the content and scaffold directories.
+ * @param {CONTENT_SCOPE} scope Scope of content to get
* @returns {ContentItem[]} Array of content index
*/
-function getContentIndex() {
+function getContentIndex(scope = CONTENT_SCOPE.USER) {
const result = [];
if (fs.existsSync(scaffoldIndexPath)) {
@@ -209,6 +260,7 @@ function getContentIndex() {
if (Array.isArray(scaffoldIndex)) {
scaffoldIndex.forEach((item) => {
item.folder = scaffoldDirectory;
+ item.scope = getScopeByType(item.type);
});
result.push(...scaffoldIndex);
}
@@ -220,22 +272,24 @@ function getContentIndex() {
if (Array.isArray(contentIndex)) {
contentIndex.forEach((item) => {
item.folder = contentDirectory;
+ item.scope = getScopeByType(item.type);
});
result.push(...contentIndex);
}
}
- return result;
+ return result.filter((item) => item.scope === scope);
}
/**
* Gets content by type and format.
* @param {string} type Type of content
* @param {'json'|'string'|'raw'} format Format of content
+ * @param {CONTENT_SCOPE} scope Scope of content to get
* @returns {string[]|Buffer[]} Array of content
*/
-export function getContentOfType(type, format) {
- const contentIndex = getContentIndex();
+export function getContentOfType(type, format, scope = CONTENT_SCOPE.USER) {
+ const contentIndex = getContentIndex(scope);
const indexItems = contentIndex.filter((item) => item.type === type && item.folder);
const files = [];
for (const item of indexItems) {
@@ -269,7 +323,7 @@ export function getContentOfType(type, format) {
* @param {import('../users.js').UserDirectoryList} directories User directories
* @returns {string | null} Target directory
*/
-function getTargetByType(type, directories) {
+export function getUserTargetByType(type, directories) {
switch (type) {
case CONTENT_TYPES.SETTINGS:
return directories.root;
@@ -312,6 +366,22 @@ function getTargetByType(type, directories) {
}
}
+/**
+ * Gets the target directory for global content types.
+ * @param {CONTENT_TYPES} type Content type
+ * @returns {string | null} Target directory
+ */
+export function getGlobalTargetByType(type) {
+ switch (type) {
+ case CONTENT_TYPES.ERROR_PAGE:
+ return path.join(globalThis.DATA_ROOT, '_errors');
+ case CONTENT_TYPES.STYLESHEET:
+ return path.join(globalThis.DATA_ROOT, '_css');
+ default:
+ return null;
+ }
+}
+
/**
* Gets the content log from the content log file.
* @param {string} contentLogPath Path to the content log file
diff --git a/src/middleware/basicAuth.js b/src/middleware/basicAuth.js
index 672a4d43e..d0a65960f 100644
--- a/src/middleware/basicAuth.js
+++ b/src/middleware/basicAuth.js
@@ -3,6 +3,7 @@
* allow access to the endpoint after successful authentication.
*/
import { Buffer } from 'node:buffer';
+import path from 'node:path';
import storage from 'node-persist';
import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
import { getConfigValue, safeReadFileSync } from '../util.js';
@@ -11,7 +12,7 @@ const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean')
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');
const basicAuthMiddleware = async function (request, response, callback) {
- const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? '';
+ const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
const unauthorizedResponse = (res) => {
res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
return res.status(401).send(unauthorizedWebpage);
diff --git a/src/middleware/hostWhitelist.js b/src/middleware/hostWhitelist.js
index 02950396c..fef66b0d8 100644
--- a/src/middleware/hostWhitelist.js
+++ b/src/middleware/hostWhitelist.js
@@ -1,6 +1,5 @@
import path from 'node:path';
import { color, getConfigValue, safeReadFileSync } from '../util.js';
-import { serverDirectory } from '../server-directory.js';
import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware';
const knownHosts = new Set();
@@ -10,11 +9,9 @@ const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false);
const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', []));
const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean');
-const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? '';
-
const validationMiddleware = hostValidationMiddleware({
allowedHosts: hostWhitelist,
- generateErrorMessage: () => hostNotAllowedHtml,
+ generateErrorMessage: () => safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'host-not-allowed.html'))?.toString() ?? '',
errorResponseContentType: 'text/html',
});
diff --git a/src/middleware/userCss.js b/src/middleware/userCss.js
new file mode 100644
index 000000000..efd581e12
--- /dev/null
+++ b/src/middleware/userCss.js
@@ -0,0 +1,19 @@
+import path from 'node:path';
+import fs from 'node:fs';
+
+/**
+ * Provides an Express middleware function that serves a user-defined CSS file from the data directory if it exists.
+ * @type {import('express').Handler}
+ */
+export function userCssMiddleware(req, res, next) {
+ if (req.method === 'GET' && req.path === '/css/user.css') {
+ const userCssPath = path.resolve(path.join(globalThis.DATA_ROOT, '_css', 'user.css'));
+ if (fs.existsSync(userCssPath)) {
+ res.sendFile(userCssPath);
+ return;
+ }
+ }
+ next();
+}
+
+export default userCssMiddleware;
diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js
index 9e3c149ca..3934a0dc5 100644
--- a/src/middleware/whitelist.js
+++ b/src/middleware/whitelist.js
@@ -100,7 +100,7 @@ async function addDockerHostsToWhitelist() {
*/
export default async function getWhitelistMiddleware() {
const forbiddenWebpage = Handlebars.compile(
- safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
+ safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'forbidden-by-whitelist.html')) ?? '',
);
const noLogPaths = [
diff --git a/src/server-init.js b/src/server-init.js
index cc7e0da0a..7a16fe4f4 100644
--- a/src/server-init.js
+++ b/src/server-init.js
@@ -1,113 +1,11 @@
/**
* Scripts to be done before starting the server for the first time.
*/
-import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
-import yaml from 'yaml';
-import chalk from 'chalk';
-import { createRequire } from 'node:module';
import { addMissingConfigValues } from './config-init.js';
-/**
- * Colorizes console output.
- */
-const color = chalk;
-
-/**
- * Converts the old config.conf file to the new config.yaml format.
- */
-function convertConfig() {
- if (fs.existsSync('./config.conf')) {
- if (fs.existsSync('./config.yaml')) {
- console.log(color.yellow('Both config.conf and config.yaml exist. Please delete config.conf manually.'));
- return;
- }
-
- try {
- console.log(color.blue('Converting config.conf to config.yaml. Your old config.conf will be renamed to config.conf.bak'));
- fs.renameSync('./config.conf', './config.conf.cjs'); // Force loading as CommonJS
- const require = createRequire(import.meta.url);
- const config = require(path.join(process.cwd(), './config.conf.cjs'));
- fs.copyFileSync('./config.conf.cjs', './config.conf.bak');
- fs.rmSync('./config.conf.cjs');
- fs.writeFileSync('./config.yaml', yaml.stringify(config));
- console.log(color.green('Conversion successful. Please check your config.yaml and fix it if necessary.'));
- } catch (error) {
- console.error(color.red('FATAL: Config conversion failed. Please check your config.conf file and try again.'), error);
- return;
- }
- }
-}
-
-/**
- * Creates the default config files if they don't exist yet.
- */
-function createDefaultFiles() {
- /**
- * @typedef DefaultItem
- * @type {object}
- * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure.
- * @property {string} defaultPath - The path to the default item (typically in `default/`).
- * @property {string} productionPath - The path to the copied item for production use.
- */
-
- /** @type {DefaultItem[]} */
- const defaultItems = [
- {
- type: 'file',
- defaultPath: './default/config.yaml',
- productionPath: './config.yaml',
- },
- {
- type: 'directory',
- defaultPath: './default/public/',
- productionPath: './public/',
- },
- ];
-
- for (const defaultItem of defaultItems) {
- try {
- if (defaultItem.type === 'file') {
- if (!fs.existsSync(defaultItem.productionPath)) {
- fs.copyFileSync(
- defaultItem.defaultPath,
- defaultItem.productionPath,
- );
- console.log(
- color.green(`Created default file: ${defaultItem.productionPath}`),
- );
- }
- } else if (defaultItem.type === 'directory') {
- fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, {
- force: false, // Don't overwrite existing files!
- recursive: true,
- });
- console.log(
- color.green(`Synchronized missing files: ${defaultItem.productionPath}`),
- );
- } else {
- throw new Error(
- 'FATAL: Unexpected default file format in `server-init.js#createDefaultFiles()`.',
- );
- }
- } catch (error) {
- console.error(
- color.red(
- `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`,
- ),
- error,
- );
- }
- }
-}
-
try {
- // 0. Convert config.conf to config.yaml
- convertConfig();
- // 1. Create default config files
- createDefaultFiles();
- // 2. Add missing config values
addMissingConfigValues(path.join(process.cwd(), './config.yaml'));
} catch (error) {
console.error(error);
diff --git a/src/server-main.js b/src/server-main.js
index 077f2c50a..423baf412 100644
--- a/src/server-main.js
+++ b/src/server-main.js
@@ -37,6 +37,7 @@ import {
getSessionCookieAge,
verifySecuritySettings,
loginPageMiddleware,
+ migratePublicOverrides,
} from './users.js';
import getWebpackServeMiddleware from './middleware/webpack-serve.js';
@@ -48,6 +49,7 @@ import initRequestProxy from './request-proxy.js';
import cacheBuster from './middleware/cacheBuster.js';
import corsProxyMiddleware from './middleware/corsProxy.js';
import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
+import userCssMiddleware from './middleware/userCss.js';
import {
getVersion,
color,
@@ -229,6 +231,7 @@ app.get('/login', loginPageMiddleware);
// Host frontend assets
const webpackMiddleware = getWebpackServeMiddleware();
app.use(webpackMiddleware);
+app.use(userCssMiddleware);
app.use(express.static(path.join(serverDirectory, 'public'), {}));
// Public API
@@ -430,7 +433,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.
*/
function apply404Middleware() {
- const notFoundWebpage = safeReadFileSync(path.join(serverDirectory, 'public/error/url-not-found.html')) ?? '';
+ const notFoundWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'url-not-found.html')) ?? '';
app.use((req, res) => {
res.status(404).send(notFoundWebpage);
});
@@ -459,6 +462,7 @@ initUserStorage(globalThis.DATA_ROOT)
.then(ensurePublicDirectoriesExist)
.then(migrateUserData)
.then(migrateSystemPrompts)
+ .then(migratePublicOverrides)
.then(verifySecuritySettings)
.then(preSetupTasks)
.then(apply404Middleware)
diff --git a/src/users.js b/src/users.js
index 10216b679..d32859170 100644
--- a/src/users.js
+++ b/src/users.js
@@ -16,7 +16,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
import sanitize from 'sanitize-filename';
import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
-import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent } from './util.js';
+import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js';
import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js';
import { getContentOfType } from './endpoints/content-manager.js';
import { serverDirectory } from './server-directory.js';
@@ -484,6 +484,48 @@ export async function migrateSystemPrompts() {
}
}
+export async function migratePublicOverrides() {
+ const migrationMap = [
+ {
+ oldPath: path.join(serverDirectory, 'public', 'error', 'forbidden-by-whitelist.html'),
+ newPath: path.join(globalThis.DATA_ROOT, '_errors', 'forbidden-by-whitelist.html'),
+ },
+ {
+ oldPath: path.join(serverDirectory, 'public', 'error', 'host-not-allowed.html'),
+ newPath: path.join(globalThis.DATA_ROOT, '_errors', 'host-not-allowed.html'),
+ },
+ {
+ oldPath: path.join(serverDirectory, 'public', 'error', 'unauthorized.html'),
+ newPath: path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html'),
+ },
+ {
+ oldPath: path.join(serverDirectory, 'public', 'error', 'url-not-found.html'),
+ newPath: path.join(globalThis.DATA_ROOT, '_errors', 'url-not-found.html'),
+ },
+ {
+ oldPath: path.join(serverDirectory, 'public', 'css', 'user.css'),
+ newPath: path.join(globalThis.DATA_ROOT, '_css', 'user.css'),
+ },
+ ];
+
+ for (const { oldPath, newPath } of migrationMap) {
+ try {
+ if (fs.existsSync(newPath)) {
+ continue;
+ }
+ if (fs.existsSync(oldPath)) {
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
+ fs.cpSync(oldPath, newPath, { force: true });
+ fs.unlinkSync(oldPath);
+ setPermissionsSync(newPath);
+ console.log(`Migrated ${path.basename(oldPath)} to data root.`);
+ }
+ } catch (error) {
+ console.error(`Error migrating ${oldPath} to ${newPath}:`, error);
+ }
+ }
+}
+
/**
* Converts a user handle to a storage key.
* @param {string} handle User handle
diff --git a/start.sh b/start.sh
index 8dbd26584..573d80df4 100755
--- a/start.sh
+++ b/start.sh
@@ -11,7 +11,6 @@ fi
echo "Installing Node Modules..."
export NODE_ENV=production
npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts
-npm run init
echo "Entering SillyTavern..."
node "server.js" "$@"