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 | - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 | 19 | - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 |
| 20 | with: | 20 | with: |
| 21 | node-version: 24 | 21 | node-version: 24 |
| 22 | - run: npm ci | 22 | - run: npm ci --omit=dev --ignore-scripts |
| 23 | 23 | ||
| 24 | publish-npm: | 24 | publish-npm: |
| 25 | needs: build | 25 | needs: build |
| @@ -30,5 +30,5 @@ jobs: | |||
| 30 | with: | 30 | with: |
| 31 | node-version: 24 | 31 | node-version: 24 |
| 32 | registry-url: https://registry.npmjs.org/ | 32 | registry-url: https://registry.npmjs.org/ |
| 33 | - run: npm ci | 33 | - run: npm ci --omit=dev --ignore-scripts |
| 34 | - run: npm publish | 34 | - run: npm publish |
| @@ -2,7 +2,6 @@ | |||
| 2 | pushd %~dp0 | 2 | pushd %~dp0 |
| 3 | set NODE_ENV=production | 3 | set NODE_ENV=production |
| 4 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts | 4 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 5 | call npm run init | ||
| 6 | node server.js %* | 5 | node server.js %* |
| 7 | pause | 6 | pause |
| 8 | popd | 7 | popd |
| @@ -21,7 +21,6 @@ if %errorlevel% neq 0 ( | |||
| 21 | ) | 21 | ) |
| 22 | set NODE_ENV=production | 22 | set NODE_ENV=production |
| 23 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts | 23 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 24 | call npm run init | ||
| 25 | node server.js %* | 24 | node server.js %* |
| 26 | :end | 25 | :end |
| 27 | pause | 26 | pause |
| @@ -103,7 +103,6 @@ if %errorlevel% neq 0 ( | |||
| 103 | echo Installing npm packages and starting server | 103 | echo Installing npm packages and starting server |
| 104 | set NODE_ENV=production | 104 | set NODE_ENV=production |
| 105 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts | 105 | call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 106 | call npm run init | ||
| 107 | node server.js %* | 106 | node server.js %* |
| 108 | 107 | ||
| 109 | :end | 108 | :end |
| @@ -2,11 +2,11 @@ | |||
| 2 | <html> | 2 | <html> |
| 3 | 3 | ||
| 4 | <head> | 4 | <head> |
| 5 | <title>Not found</title> | 5 | <title>Not Found</title> |
| 6 | </head> | 6 | </head> |
| 7 | 7 | ||
| 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> |
| @@ -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 | ] |
| @@ -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 | 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 | }; |
| 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 | * 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 directories | 83 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 61 | * @returns {object[]} Array of default presets | 84 | * @returns {object[]} Array of default presets |
| 62 | */ | 85 | */ |
| 63 | export function getDefaultPresets(directories) { | 86 | export function getDefaultPresets(directories) { |
| 64 | try { | 87 | try { |
| 65 | const contentIndex = getContentIndex(); | 88 | const contentIndex = getContentIndex(CONTENT_SCOPE.USER); |
| 66 | const presets = []; | 89 | const presets = []; |
| 67 | 90 | ||
| 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 | } |
| 103 | 126 | ||
| 104 | /** | 127 | /** |
| 105 | * Seeds content for a user. | 128 | * Seeds content from a content index into a target location. |
| 106 | * @param {ContentItem[]} contentIndex Content index | 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 | * @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 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 | async function seedContentForUser(contentIndex, directories, forceCategories) { | 135 | function 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); |
| 120 | 138 | ||
| 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 | } |
| 138 | 155 | ||
| 139 | const contentTarget = getTargetByType(contentItem.type, directories); | 156 | const contentTarget = resolveTarget(contentItem.type); |
| 140 | 157 | ||
| 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 | } |
| 154 | 171 | ||
| 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 | } |
| 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 | * 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 directories | 211 | * @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 | } |
| 177 | 221 | ||
| 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; |
| 180 | 225 | ||
| 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); |
| 183 | 233 | ||
| 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 = []) | |||
| 198 | 248 | ||
| 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 index | 252 | * @returns {ContentItem[]} Array of content index |
| 202 | */ | 253 | */ |
| 203 | function getContentIndex() { | 254 | function getContentIndex(scope = CONTENT_SCOPE.USER) { |
| 204 | const result = []; | 255 | const result = []; |
| 205 | 256 | ||
| 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 | } |
| 227 | 280 | ||
| 228 | return result; | 281 | return result.filter((item) => item.scope === scope); |
| 229 | } | 282 | } |
| 230 | 283 | ||
| 231 | /** | 284 | /** |
| 232 | * Gets content by type and format. | 285 | * Gets content by type and format. |
| 233 | * @param {string} type Type of content | 286 | * @param {string} type Type of content |
| 234 | * @param {'json'|'string'|'raw'} format Format of content | 287 | * @param {'json'|'string'|'raw'} format Format of content |
| 288 | * @param {CONTENT_SCOPE} scope Scope of content to get | ||
| 235 | * @returns {string[]|Buffer[]} Array of content | 289 | * @returns {string[]|Buffer[]} Array of content |
| 236 | */ | 290 | */ |
| 237 | export function getContentOfType(type, format) { | 291 | export 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 directories | 323 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 270 | * @returns {string | null} Target directory | 324 | * @returns {string | null} Target directory |
| 271 | */ | 325 | */ |
| 272 | function getTargetByType(type, directories) { | 326 | export 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 | } |
| 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 | * 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 file | 387 | * @param {string} contentLogPath Path to the content log file |
| 318 | * @returns {string[]} Array of content log lines | 388 | * @returns {string[]} Array of content log lines |
| @@ -3,6 +3,7 @@ | |||
| 3 | * allow access to the endpoint after successful authentication. | 3 | * allow access to the endpoint after successful authentication. |
| 4 | */ | 4 | */ |
| 5 | import { Buffer } from 'node:buffer'; | 5 | import { Buffer } from 'node:buffer'; |
| 6 | import path from 'node:path'; | ||
| 6 | import storage from 'node-persist'; | 7 | import storage from 'node-persist'; |
| 7 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; | 8 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; |
| 8 | import { getConfigValue, safeReadFileSync } from '../util.js'; | 9 | import { getConfigValue, safeReadFileSync } from '../util.js'; |
| @@ -11,7 +12,7 @@ const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean') | |||
| 11 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean'); | 12 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean'); |
| 12 | 13 | ||
| 13 | const basicAuthMiddleware = async function (request, response, callback) { | 14 | const 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); |
| @@ -1,6 +1,5 @@ | |||
| 1 | import path from 'node:path'; | 1 | import path from 'node:path'; |
| 2 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; | 2 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; |
| 3 | import { serverDirectory } from '../server-directory.js'; | ||
| 4 | import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware'; | 3 | import { isHostAllowed, hostValidationMiddleware } from 'host-validation-middleware'; |
| 5 | 4 | ||
| 6 | const knownHosts = new Set(); | 5 | const knownHosts = new Set(); |
| @@ -10,11 +9,9 @@ const hostWhitelistEnabled = !!getConfigValue('hostWhitelist.enabled', false); | |||
| 10 | const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', [])); | 9 | const hostWhitelist = Object.freeze(getConfigValue('hostWhitelist.hosts', [])); |
| 11 | const hostWhitelistScan = !!getConfigValue('hostWhitelist.scan', false, 'boolean'); | 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 | const validationMiddleware = hostValidationMiddleware({ | 12 | const 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 | }); |
| 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 | export default async function getWhitelistMiddleware() { | 101 | export 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 | ); |
| 105 | 105 | ||
| 106 | const noLogPaths = [ | 106 | const noLogPaths = [ |
| @@ -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 | */ |
| 4 | import fs from 'node:fs'; | ||
| 5 | import path from 'node:path'; | 4 | import path from 'node:path'; |
| 6 | import process from 'node:process'; | 5 | import process from 'node:process'; |
| 7 | import yaml from 'yaml'; | ||
| 8 | import chalk from 'chalk'; | ||
| 9 | import { createRequire } from 'node:module'; | ||
| 10 | import { addMissingConfigValues } from './config-init.js'; | 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 | try { | 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 | 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); |
| @@ -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'; |
| 41 | 42 | ||
| 42 | import getWebpackServeMiddleware from './middleware/webpack-serve.js'; | 43 | import getWebpackServeMiddleware from './middleware/webpack-serve.js'; |
| @@ -48,6 +49,7 @@ import initRequestProxy from './request-proxy.js'; | |||
| 48 | import cacheBuster from './middleware/cacheBuster.js'; | 49 | import cacheBuster from './middleware/cacheBuster.js'; |
| 49 | import corsProxyMiddleware from './middleware/corsProxy.js'; | 50 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 50 | import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; | 51 | import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; |
| 52 | import userCssMiddleware from './middleware/userCss.js'; | ||
| 51 | import { | 53 | import { |
| 52 | getVersion, | 54 | getVersion, |
| 53 | color, | 55 | color, |
| @@ -229,6 +231,7 @@ app.get('/login', loginPageMiddleware); | |||
| 229 | // Host frontend assets | 231 | // Host frontend assets |
| 230 | const webpackMiddleware = getWebpackServeMiddleware(); | 232 | const webpackMiddleware = getWebpackServeMiddleware(); |
| 231 | app.use(webpackMiddleware); | 233 | app.use(webpackMiddleware); |
| 234 | app.use(userCssMiddleware); | ||
| 232 | app.use(express.static(path.join(serverDirectory, 'public'), {})); | 235 | app.use(express.static(path.join(serverDirectory, 'public'), {})); |
| 233 | 236 | ||
| 234 | // Public API | 237 | // 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 | */ |
| 432 | function apply404Middleware() { | 435 | function 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) |
| @@ -16,7 +16,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; | |||
| 16 | import sanitize from 'sanitize-filename'; | 16 | import sanitize from 'sanitize-filename'; |
| 17 | 17 | ||
| 18 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; | 18 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; |
| 19 | import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent } from './util.js'; | 19 | import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js'; |
| 20 | import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; | 20 | import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; |
| 21 | import { getContentOfType } from './endpoints/content-manager.js'; | 21 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 22 | import { serverDirectory } from './server-directory.js'; | 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 | * Converts a user handle to a storage key. | 530 | * Converts a user handle to a storage key. |
| 489 | * @param {string} handle User handle | 531 | * @param {string} handle User handle |
| @@ -11,7 +11,6 @@ fi | |||
| 11 | echo "Installing Node Modules..." | 11 | echo "Installing Node Modules..." |
| 12 | export NODE_ENV=production | 12 | export NODE_ENV=production |
| 13 | npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts | 13 | npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts |
| 14 | npm run init | ||
| 15 | 14 | ||
| 16 | echo "Entering SillyTavern..." | 15 | echo "Entering SillyTavern..." |
| 17 | node "server.js" "$@" | 16 | node "server.js" "$@" |