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
Signed| @@ -19,7 +19,7 @@ jobs: | ||
| 19 | 19 | - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 |
| 20 | 20 | with: |
| 21 | 21 | node-version: 24 |
| 22 | 22 | - run: npm ci --omit=dev --ignore-scripts |
| 23 | 23 | |
| 24 | 24 | publish-npm: |
| 25 | 25 | needs: build |
| @@ -30,5 +30,5 @@ jobs: | ||
| 30 | 30 | with: |
| 31 | 31 | node-version: 24 |
| 32 | 32 | registry-url: https://registry.npmjs.org/ |
| 33 | 33 | - run: npm ci --omit=dev --ignore-scripts |
| 34 | 34 | - run: npm publish |
| @@ -2,7 +2,6 @@ | ||
| 2 | 2 | pushd %~dp0 |
| 3 | 3 | set NODE_ENV=production |
| 4 | 4 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 5 | -call npm run init | |
| 6 | 5 | node server.js %* |
| 7 | 6 | pause |
| 8 | 7 | popd |
| @@ -21,7 +21,6 @@ if %errorlevel% neq 0 ( | ||
| 21 | 21 | ) |
| 22 | 22 | set NODE_ENV=production |
| 23 | 23 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 24 | -call npm run init | |
| 25 | 24 | node server.js %* |
| 26 | 25 | :end |
| 27 | 26 | pause |
| @@ -103,7 +103,6 @@ if %errorlevel% neq 0 ( | ||
| 103 | 103 | echo Installing npm packages and starting server |
| 104 | 104 | set NODE_ENV=production |
| 105 | 105 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 106 | -call npm run init | |
| 107 | 106 | node server.js %* |
| 108 | 107 | |
| 109 | 108 | :end |
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | <html> |
| 3 | 3 | |
| 4 | 4 | <head> |
| 5 | 5 | <title>Not foundFound</title> |
| 6 | 6 | </head> |
| 7 | 7 | |
| 8 | 8 | <body> |
| 9 | 9 | <h1>Not foundFound</h1> |
| 10 | 10 | <p> |
| 11 | 11 | The requested URL was not found on this server. |
| 12 | 12 | </p> |
| @@ -654,5 +654,25 @@ | ||
| 654 | 654 | { |
| 655 | 655 | "filename": "presets/context/Gemma 4.json", |
| 656 | 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 | ] |
| @@ -0,0 +1,7 @@ | ||
| 1 | +# Looking for user.css? | |
| 2 | + | |
| 3 | +user.css is now located under your data root directory in the "_css" folder. | |
| 4 | + | |
| 5 | +Example for the default data root: | |
| 6 | + | |
| 7 | +/data/_css/user.css | |
| @@ -53,22 +53,45 @@ export const CONTENT_TYPES = { | ||
| 53 | 53 | QUICK_REPLIES: 'quick_replies', |
| 54 | 54 | SYSPROMPT: 'sysprompt', |
| 55 | 55 | REASONING: 'reasoning', |
| 56 | + ERROR_PAGE: 'error_page', | |
| 57 | + STYLESHEET: 'stylesheet', | |
| 56 | 58 | }; |
| 57 | 59 | |
| 58 | 60 | /** |
| 61 | + * @enum {string} | |
| 62 | + */ | |
| 63 | +export 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 | + */ | |
| 73 | +function 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 | 82 | * Gets the default presets from the content directory. |
| 60 | 83 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 61 | 84 | * @returns {object[]} Array of default presets |
| 62 | 85 | */ |
| 63 | 86 | export function getDefaultPresets(directories) { |
| 64 | 87 | try { |
| 65 | 88 | const contentIndex = getContentIndex(CONTENT_SCOPE.USER); |
| 66 | 89 | const presets = []; |
| 67 | 90 | |
| 68 | 91 | for (const contentItem of contentIndex) { |
| 69 | 92 | if (contentItem.type.endsWith('_preset') || ['instruct', 'context', 'sysprompt', 'reasoning'].includes(contentItem.type)) { |
| 70 | 93 | contentItem.name = path.parse(contentItem.filename).name; |
| 71 | 94 | contentItem.folder = getTargetByTypegetUserTargetByType(contentItem.type, directories); |
| 72 | 95 | presets.push(contentItem); |
| 73 | 96 | } |
| 74 | 97 | } |
| @@ -102,24 +125,18 @@ export function getDefaultPresetFile(filename) { | ||
| 102 | 125 | } |
| 103 | 126 | |
| 104 | 127 | /** |
| 105 | 128 | * Seeds content forfrom a usercontent index into a target location. |
| 106 | 129 | * @param {ContentItem[]} contentIndex Content index |
| 107 | - * @param {import('../users.js').UserDirectoryList} directories User directories | |
| 130 | + * @param {string} contentLogPath Path to the content log file | |
| 108 | 131 | * @param {(type: string[]}) forceCategories=> Liststring of| categoriesnull} resolveTarget Function to forceresolve checkthe (eventarget ifdirectory contentfor checka iscontent skipped)type |
| 109 | - * @returns {Promise<boolean>} Whether any content was added | |
| 132 | + * @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 | */ |
| 111 | 135 | async function seedContentForUserseedContent(contentIndex, directoriescontentLogPath, resolveTarget, forceCategories) { |
| 112 | 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 | 137 | const contentLog = getContentLog(contentLogPath); |
| 120 | 138 | |
| 121 | 139 | for (const contentItem of contentIndex) { |
| 122 | - // If the content item is already in the log, skip it | |
| 123 | 140 | if (contentLog.includes(contentItem.filename) && !forceCategories?.includes(contentItem.type)) { |
| 124 | 141 | continue; |
| 125 | 142 | } |
| @@ -136,7 +153,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) { | ||
| 136 | 153 | continue; |
| 137 | 154 | } |
| 138 | 155 | |
| 139 | 156 | const contentTarget = getTargetByTyperesolveTarget(contentItem.type, directories); |
| 140 | 157 | |
| 141 | 158 | if (!contentTarget) { |
| 142 | 159 | console.warn(`Content file ${contentItem.filename} has unknown type ${contentItem.type}`); |
| @@ -152,6 +169,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) { | ||
| 152 | 169 | continue; |
| 153 | 170 | } |
| 154 | 171 | |
| 172 | + fs.mkdirSync(contentTarget, { recursive: true }); | |
| 155 | 173 | fs.cpSync(contentPath, targetPath, { recursive: true, force: false }); |
| 156 | 174 | setPermissionsSync(targetPath); |
| 157 | 175 | console.info(`Content file ${contentItem.filename} copied to ${contentTarget}`); |
| @@ -163,6 +181,32 @@ async function seedContentForUser(contentIndex, directories, forceCategories) { | ||
| 163 | 181 | } |
| 164 | 182 | |
| 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 | + */ | |
| 190 | +async 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 | + */ | |
| 204 | +async function seedGlobalContent(contentIndex) { | |
| 205 | + const contentLogPath = path.join(globalThis.DATA_ROOT, 'content.log'); | |
| 206 | + return seedContent(contentIndex, contentLogPath, getGlobalTargetByType); | |
| 207 | +} | |
| 208 | + | |
| 209 | +/** | |
| 166 | 210 | * Checks for new content and seeds it for all users. |
| 167 | 211 | * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories |
| 168 | 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 | 219 | return; |
| 176 | 220 | } |
| 177 | 221 | |
| 178 | 222 | const contentIndexuserContentIndex = getContentIndex(CONTENT_SCOPE.USER); |
| 223 | + const globalContentIndex = getContentIndex(CONTENT_SCOPE.GLOBAL); | |
| 179 | 224 | let anyContentAdded = false; |
| 180 | 225 | |
| 226 | + const globalSeedResult = await seedGlobalContent(globalContentIndex); | |
| 227 | + if (globalSeedResult) { | |
| 228 | + anyContentAdded = true; | |
| 229 | + } | |
| 230 | + | |
| 181 | 231 | for (const directories of directoriesList) { |
| 182 | 232 | const seedResultuserSeedResult = await seedContentForUser(contentIndexuserContentIndex, directories, forceCategories); |
| 183 | 233 | |
| 184 | 234 | if (seedResultuserSeedResult) { |
| 185 | 235 | anyContentAdded = true; |
| 186 | 236 | } |
| 187 | 237 | } |
| @@ -198,9 +248,10 @@ export async function checkForNewContent(directoriesList, forceCategories = []) | ||
| 198 | 248 | |
| 199 | 249 | /** |
| 200 | 250 | * Gets combined content index from the content and scaffold directories. |
| 251 | + * @param {CONTENT_SCOPE} scope Scope of content to get | |
| 201 | 252 | * @returns {ContentItem[]} Array of content index |
| 202 | 253 | */ |
| 203 | 254 | function getContentIndex(scope = CONTENT_SCOPE.USER) { |
| 204 | 255 | const result = []; |
| 205 | 256 | |
| 206 | 257 | if (fs.existsSync(scaffoldIndexPath)) { |
| @@ -209,6 +260,7 @@ function getContentIndex() { | ||
| 209 | 260 | if (Array.isArray(scaffoldIndex)) { |
| 210 | 261 | scaffoldIndex.forEach((item) => { |
| 211 | 262 | item.folder = scaffoldDirectory; |
| 263 | + item.scope = getScopeByType(item.type); | |
| 212 | 264 | }); |
| 213 | 265 | result.push(...scaffoldIndex); |
| 214 | 266 | } |
| @@ -220,22 +272,24 @@ function getContentIndex() { | ||
| 220 | 272 | if (Array.isArray(contentIndex)) { |
| 221 | 273 | contentIndex.forEach((item) => { |
| 222 | 274 | item.folder = contentDirectory; |
| 275 | + item.scope = getScopeByType(item.type); | |
| 223 | 276 | }); |
| 224 | 277 | result.push(...contentIndex); |
| 225 | 278 | } |
| 226 | 279 | } |
| 227 | 280 | |
| 228 | - return result; | |
| 281 | + return result.filter((item) => item.scope === scope); | |
| 229 | 282 | } |
| 230 | 283 | |
| 231 | 284 | /** |
| 232 | 285 | * Gets content by type and format. |
| 233 | 286 | * @param {string} type Type of content |
| 234 | 287 | * @param {'json'|'string'|'raw'} format Format of content |
| 288 | + * @param {CONTENT_SCOPE} scope Scope of content to get | |
| 235 | 289 | * @returns {string[]|Buffer[]} Array of content |
| 236 | 290 | */ |
| 237 | 291 | export function getContentOfType(type, format, scope = CONTENT_SCOPE.USER) { |
| 238 | 292 | const contentIndex = getContentIndex(scope); |
| 239 | 293 | const indexItems = contentIndex.filter((item) => item.type === type && item.folder); |
| 240 | 294 | const files = []; |
| 241 | 295 | for (const item of indexItems) { |
| @@ -269,7 +323,7 @@ export function getContentOfType(type, format) { | ||
| 269 | 323 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 270 | 324 | * @returns {string | null} Target directory |
| 271 | 325 | */ |
| 272 | 326 | export function getTargetByTypegetUserTargetByType(type, directories) { |
| 273 | 327 | switch (type) { |
| 274 | 328 | case CONTENT_TYPES.SETTINGS: |
| 275 | 329 | return directories.root; |
| @@ -313,6 +367,22 @@ function getTargetByType(type, directories) { | ||
| 313 | 367 | } |
| 314 | 368 | |
| 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 | + */ | |
| 374 | +export 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 | 386 | * Gets the content log from the content log file. |
| 317 | 387 | * @param {string} contentLogPath Path to the content log file |
| 318 | 388 | * @returns {string[]} Array of content log lines |
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * allow access to the endpoint after successful authentication. |
| 4 | 4 | */ |
| 5 | 5 | import { Buffer } from 'node:buffer'; |
| 6 | +import path from 'node:path'; | |
| 6 | 7 | import storage from 'node-persist'; |
| 7 | 8 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; |
| 8 | 9 | import { getConfigValue, safeReadFileSync } from '../util.js'; |
| @@ -11,7 +12,7 @@ const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean') | ||
| 11 | 12 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean'); |
| 12 | 13 | |
| 13 | 14 | const basicAuthMiddleware = async function (request, response, callback) { |
| 14 | 15 | const unauthorizedWebpage = safeReadFileSync('path./public/error/join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? ''; |
| 15 | 16 | const unauthorizedResponse = (res) => { |
| 16 | 17 | res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); |
| 17 | 18 | return res.status(401).send(unauthorizedWebpage); |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import path from 'node:path'; |
| 2 | 2 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; |
| 3 | -import { serverDirectory } from '../server-directory.js'; | |
| 4 | 3 | import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware'; |
| 5 | 4 | |
| 6 | 5 | const knownHosts = new Set(); |
| @@ -10,11 +9,9 @@ const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false); | ||
| 10 | 9 | const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', [])); |
| 11 | 10 | const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean'); |
| 12 | 11 | |
| 13 | -const hostNotAllowedHtml = safeReadFileSync(path.join(serverDirectory, 'public/error/host-not-allowed.html'))?.toString() ?? ''; | |
| 14 | - | |
| 15 | 12 | const validationMiddleware = hostValidationMiddleware({ |
| 16 | 13 | allowedHosts: hostWhitelist, |
| 17 | - generateErrorMessage: () => hostNotAllowedHtml, | |
| 14 | + generateErrorMessage: () => safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'host-not-allowed.html'))?.toString() ?? '', | |
| 18 | 15 | errorResponseContentType: 'text/html', |
| 19 | 16 | }); |
| 20 | 17 | |
| @@ -0,0 +1,19 @@ | ||
| 1 | +import path from 'node:path'; | |
| 2 | +import 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 | + */ | |
| 8 | +export 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 | + | |
| 19 | +export default userCssMiddleware; | |
| @@ -100,7 +100,7 @@ async function addDockerHostsToWhitelist() { | ||
| 100 | 100 | */ |
| 101 | 101 | export default async function getWhitelistMiddleware() { |
| 102 | 102 | const forbiddenWebpage = Handlebars.compile( |
| 103 | 103 | safeReadFileSync('path./public/error/join(globalThis.DATA_ROOT, '_errors', 'forbidden-by-whitelist.html')) ?? '', |
| 104 | 104 | ); |
| 105 | 105 | |
| 106 | 106 | const noLogPaths = [ |
| @@ -1,113 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Scripts to be done before starting the server for the first time. |
| 3 | 3 | */ |
| 4 | -import fs from 'node:fs'; | |
| 5 | 4 | import path from 'node:path'; |
| 6 | 5 | import process from 'node:process'; |
| 7 | -import yaml from 'yaml'; | |
| 8 | -import chalk from 'chalk'; | |
| 9 | -import { createRequire } from 'node:module'; | |
| 10 | 6 | import { addMissingConfigValues } from './config-init.js'; |
| 11 | 7 | |
| 12 | -/** | |
| 13 | - * Colorizes console output. | |
| 14 | - */ | |
| 15 | -const color = chalk; | |
| 16 | - | |
| 17 | -/** | |
| 18 | - * Converts the old config.conf file to the new config.yaml format. | |
| 19 | - */ | |
| 20 | -function 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 | - */ | |
| 46 | -function 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 | - | |
| 105 | 8 | try { |
| 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 | 9 | addMissingConfigValues(path.join(process.cwd(), './config.yaml')); |
| 112 | 10 | } catch (error) { |
| 113 | 11 | console.error(error); |
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | getSessionCookieAge, |
| 38 | 38 | verifySecuritySettings, |
| 39 | 39 | loginPageMiddleware, |
| 40 | + migratePublicOverrides, | |
| 40 | 41 | } from './users.js'; |
| 41 | 42 | |
| 42 | 43 | import getWebpackServeMiddleware from './middleware/webpack-serve.js'; |
| @@ -48,6 +49,7 @@ import initRequestProxy from './request-proxy.js'; | ||
| 48 | 49 | import cacheBuster from './middleware/cacheBuster.js'; |
| 49 | 50 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 50 | 51 | import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; |
| 52 | +import userCssMiddleware from './middleware/userCss.js'; | |
| 51 | 53 | import { |
| 52 | 54 | getVersion, |
| 53 | 55 | color, |
| @@ -229,6 +231,7 @@ app.get('/login', loginPageMiddleware); | ||
| 229 | 231 | // Host frontend assets |
| 230 | 232 | const webpackMiddleware = getWebpackServeMiddleware(); |
| 231 | 233 | app.use(webpackMiddleware); |
| 234 | +app.use(userCssMiddleware); | |
| 232 | 235 | app.use(express.static(path.join(serverDirectory, 'public'), {})); |
| 233 | 236 | |
| 234 | 237 | // Public API |
| @@ -430,7 +433,7 @@ async function postSetupTasks(result) { | ||
| 430 | 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 | */ |
| 432 | 435 | function apply404Middleware() { |
| 433 | 436 | const notFoundWebpage = safeReadFileSync(path.join(serverDirectoryglobalThis.DATA_ROOT, 'public/error/_errors', 'url-not-found.html')) ?? ''; |
| 434 | 437 | app.use((req, res) => { |
| 435 | 438 | res.status(404).send(notFoundWebpage); |
| 436 | 439 | }); |
| @@ -459,6 +462,7 @@ initUserStorage(globalThis.DATA_ROOT) | ||
| 459 | 462 | .then(ensurePublicDirectoriesExist) |
| 460 | 463 | .then(migrateUserData) |
| 461 | 464 | .then(migrateSystemPrompts) |
| 465 | + .then(migratePublicOverrides) | |
| 462 | 466 | .then(verifySecuritySettings) |
| 463 | 467 | .then(preSetupTasks) |
| 464 | 468 | .then(apply404Middleware) |
| @@ -16,7 +16,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; | ||
| 16 | 16 | import sanitize from 'sanitize-filename'; |
| 17 | 17 | |
| 18 | 18 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; |
| 19 | 19 | import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js'; |
| 20 | 20 | import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; |
| 21 | 21 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 22 | 22 | import { serverDirectory } from './server-directory.js'; |
| @@ -484,6 +484,48 @@ export async function migrateSystemPrompts() { | ||
| 484 | 484 | } |
| 485 | 485 | } |
| 486 | 486 | |
| 487 | +export 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 | 530 | * Converts a user handle to a storage key. |
| 489 | 531 | * @param {string} handle User handle |
| @@ -11,7 +11,6 @@ fi | ||
| 11 | 11 | echo "Installing Node Modules..." |
| 12 | 12 | export NODE_ENV=production |
| 13 | 13 | npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 14 | -npm run init | |
| 15 | 14 | |
| 16 | 15 | echo "Entering SillyTavern..." |
| 17 | 16 | node "server.js" "$@" |