Add toggle and user agent filter for CacheBuster middleware (#4301) * Add toggle and user agent filter for CacheBuster middleware Closes #4290 [BUG] SillyTavern takes ~45 seconds to load initially * Use single instance of CacheBuster class * Cache config values * Consistent log header * Remove unnecessary empty line at the beginning of byaf.js
Signed| @@ -151,6 +151,15 @@ performance: | |||
| 151 | # Enables disk caching for character cards. Improves performances with large card libraries. | 151 | # Enables disk caching for character cards. Improves performances with large card libraries. |
| 152 | useDiskCache: true | 152 | useDiskCache: true |
| 153 | 153 | ||
| 154 | # CACHE BUSTER CONFIGURATION | ||
| 155 | # IMPORTANT: Requires localhost or a domain with HTTPS, otherwise will not work! | ||
| 156 | cacheBuster: | ||
| 157 | # Clear browser cache on first load or after uploading image files | ||
| 158 | enabled: false | ||
| 159 | # Only clear cache for the specified user agent regex pattern | ||
| 160 | # Example: 'firefox|safari' (case-insensitive) | ||
| 161 | userAgentPattern: '' | ||
| 162 | |||
| 154 | # Allow secret keys exposure via API | 163 | # Allow secret keys exposure via API |
| 155 | allowKeysExposure: false | 164 | allowKeysExposure: false |
| 156 | # Skip new default content checks | 165 | # Skip new default content checks |
| @@ -1,4 +1,3 @@ | |||
| 1 | |||
| 2 | import sanitize from 'sanitize-filename'; | 1 | import sanitize from 'sanitize-filename'; |
| 3 | import { promises as fsPromises } from 'node:fs'; | 2 | import { promises as fsPromises } from 'node:fs'; |
| 4 | import path from 'node:path'; | 3 | import path from 'node:path'; |
| @@ -10,6 +10,7 @@ import { getImages, tryParse } from '../util.js'; | |||
| 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | import { applyAvatarCropResize } from './characters.js'; | 11 | import { applyAvatarCropResize } from './characters.js'; |
| 12 | import { invalidateThumbnail } from './thumbnails.js'; | 12 | import { invalidateThumbnail } from './thumbnails.js'; |
| 13 | import cacheBuster from '../middleware/cacheBuster.js'; | ||
| 13 | 14 | ||
| 14 | export const router = express.Router(); | 15 | export const router = express.Router(); |
| 15 | 16 | ||
| @@ -49,7 +50,7 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r | |||
| 49 | // Remove previous thumbnail and bust cache if overwriting | 50 | // Remove previous thumbnail and bust cache if overwriting |
| 50 | if (request.body.overwrite_name) { | 51 | if (request.body.overwrite_name) { |
| 51 | invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name)); | 52 | invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name)); |
| 52 | response.setHeader('Clear-Site-Data', '"cache"'); | 53 | cacheBuster.bust(request, response); |
| 53 | } | 54 | } |
| 54 | 55 | ||
| 55 | const filename = request.body.overwrite_name || `${Date.now()}.png`; | 56 | const filename = request.body.overwrite_name || `${Date.now()}.png`; |
| @@ -23,6 +23,7 @@ import { importRisuSprites } from './sprites.js'; | |||
| 23 | import { getUserDirectories } from '../users.js'; | 23 | import { getUserDirectories } from '../users.js'; |
| 24 | import { getChatInfo } from './chats.js'; | 24 | import { getChatInfo } from './chats.js'; |
| 25 | import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js'; | 25 | import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js'; |
| 26 | import cacheBuster from '../middleware/cacheBuster.js'; | ||
| 26 | 27 | ||
| 27 | // With 100 MB limit it would take roughly 3000 characters to reach this limit | 28 | // With 100 MB limit it would take roughly 3000 characters to reach this limit |
| 28 | const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb'); | 29 | const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb'); |
| @@ -1065,7 +1066,7 @@ router.post('/edit', validateAvatarUrlMiddleware, async function (request, respo | |||
| 1065 | fs.unlinkSync(newAvatarPath); | 1066 | fs.unlinkSync(newAvatarPath); |
| 1066 | 1067 | ||
| 1067 | // Bust cache to reload the new avatar | 1068 | // Bust cache to reload the new avatar |
| 1068 | response.setHeader('Clear-Site-Data', '"cache"'); | 1069 | cacheBuster.bust(request, response); |
| 1069 | } | 1070 | } |
| 1070 | 1071 | ||
| 1071 | return response.sendStatus(200); | 1072 | return response.sendStatus(200); |
| @@ -1,28 +1,110 @@ | |||
| 1 | import crypto from 'node:crypto'; | 1 | import crypto from 'node:crypto'; |
| 2 | import { DEFAULT_USER } from '../constants.js'; | 2 | import { DEFAULT_USER } from '../constants.js'; |
| 3 | import { getConfigValue } from '../util.js'; | ||
| 3 | 4 | ||
| 4 | /** | 5 | /** |
| 5 | * Middleware to bust the browser cache for the current user. | 6 | * Sets the Clear-Site-Data header to bust the browser cache. |
| 6 | * @returns {import('express').RequestHandler} | 7 | */ |
| 8 | class CacheBuster { | ||
| 9 | /** | ||
| 10 | * Handles/User-Agents that have already been busted. | ||
| 11 | * @type {Set<string>} | ||
| 12 | */ | ||
| 13 | #keys = new Set(); | ||
| 14 | |||
| 15 | /** | ||
| 16 | * User agent regex to match against requests. | ||
| 17 | * @type {RegExp | null} | ||
| 7 | */ | 18 | */ |
| 8 | export default function getCacheBusterMiddleware() { | 19 | #userAgentRegex = null; |
| 20 | |||
| 21 | /** | ||
| 22 | * Whether the cache buster is enabled. | ||
| 23 | * @type {boolean | null} | ||
| 24 | */ | ||
| 25 | #isEnabled = null; | ||
| 26 | |||
| 27 | constructor() { | ||
| 28 | this.#isEnabled = !!getConfigValue('cacheBuster.enabled', false, 'boolean'); | ||
| 29 | const userAgentPattern = getConfigValue('cacheBuster.userAgentPattern', ''); | ||
| 30 | if (userAgentPattern) { | ||
| 31 | try { | ||
| 32 | this.#userAgentRegex = new RegExp(userAgentPattern, 'i'); | ||
| 33 | } catch { | ||
| 34 | console.error('[Cache Buster] Invalid user agent pattern:', userAgentPattern); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 9 | /** | 39 | /** |
| 10 | * @type {Set<string>} Handles/User-Agents that have already been busted. | 40 | * Check if the cache should be busted for the given request. |
| 41 | * @param {import('express').Request} request Express request object. | ||
| 42 | * @param {import('express').Response} response Express response object. | ||
| 43 | * @returns {boolean} Whether the cache should be busted. | ||
| 11 | */ | 44 | */ |
| 12 | const keys = new Set(); | 45 | shouldBust(request, response) { |
| 46 | // If disabled with config, don't do anything | ||
| 47 | if (!this.#isEnabled) { | ||
| 48 | return false; | ||
| 49 | } | ||
| 50 | |||
| 51 | // If response headers are already sent or response is ended | ||
| 52 | if (response.headersSent || response.writableEnded) { | ||
| 53 | console.warn('[Cache Buster] Response ended or headers already sent'); | ||
| 54 | return false; | ||
| 55 | } | ||
| 56 | |||
| 57 | // Check if the user agent matches the configured pattern | ||
| 58 | const userAgent = request.headers['user-agent'] || ''; | ||
| 59 | |||
| 60 | // Bust cache for all requests if no pattern is set | ||
| 61 | if (!this.#userAgentRegex) { | ||
| 62 | return true; | ||
| 63 | } | ||
| 64 | |||
| 65 | return this.#userAgentRegex.test(userAgent); | ||
| 66 | } | ||
| 13 | 67 | ||
| 14 | return (request, response, next) => { | 68 | /** |
| 69 | * Middleware to bust the browser cache for the current user. | ||
| 70 | * @type {import('express').RequestHandler} | ||
| 71 | */ | ||
| 72 | #middleware(request, response, next) { | ||
| 15 | const handle = request.user?.profile?.handle || DEFAULT_USER.handle; | 73 | const handle = request.user?.profile?.handle || DEFAULT_USER.handle; |
| 16 | const userAgent = request.headers['user-agent'] || ''; | 74 | const userAgent = request.headers['user-agent'] || ''; |
| 17 | const hash = crypto.createHash('sha256').update(userAgent).digest('hex'); | 75 | const hash = crypto.createHash('sha256').update(userAgent).digest('hex'); |
| 18 | const key = `${handle}-${hash}`; | 76 | const key = `${handle}-${hash}`; |
| 19 | 77 | ||
| 20 | if (keys.has(key)) { | 78 | if (this.#keys.has(key)) { |
| 21 | return next(); | 79 | return next(); |
| 22 | } | 80 | } |
| 23 | 81 | ||
| 24 | keys.add(key); | 82 | this.#keys.add(key); |
| 25 | response.setHeader('Clear-Site-Data', '"cache"'); | 83 | this.bust(request, response); |
| 26 | next(); | 84 | next(); |
| 27 | }; | ||
| 28 | } | 85 | } |
| 86 | |||
| 87 | /** | ||
| 88 | * Middleware to bust the browser cache for the current user. | ||
| 89 | * @returns {import('express').RequestHandler} The middleware function. | ||
| 90 | */ | ||
| 91 | get middleware() { | ||
| 92 | return this.#middleware.bind(this); | ||
| 93 | } | ||
| 94 | |||
| 95 | /** | ||
| 96 | * Bust the cache for the given response. | ||
| 97 | * @param {import('express').Request} request Express request object. | ||
| 98 | * @param {import('express').Response} response Express response object. | ||
| 99 | * @returns {void} | ||
| 100 | */ | ||
| 101 | bust(request, response) { | ||
| 102 | if (this.shouldBust(request, response)) { | ||
| 103 | response.setHeader('Clear-Site-Data', '"cache"'); | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | // Export a single instance for the entire application | ||
| 109 | const instance = new CacheBuster(); | ||
| 110 | export default instance; | ||
| @@ -44,7 +44,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js'; | |||
| 44 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; | 44 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; |
| 45 | import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; | 45 | import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; |
| 46 | import initRequestProxy from './request-proxy.js'; | 46 | import initRequestProxy from './request-proxy.js'; |
| 47 | import getCacheBusterMiddleware from './middleware/cacheBuster.js'; | 47 | import cacheBuster from './middleware/cacheBuster.js'; |
| 48 | import corsProxyMiddleware from './middleware/corsProxy.js'; | 48 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 49 | import { | 49 | import { |
| 50 | getVersion, | 50 | getVersion, |
| @@ -185,7 +185,7 @@ if (!cliArgs.disableCsrf) { | |||
| 185 | 185 | ||
| 186 | // Static files | 186 | // Static files |
| 187 | // Host index page | 187 | // Host index page |
| 188 | app.get('/', getCacheBusterMiddleware(), (request, response) => { | 188 | app.get('/', cacheBuster.middleware, (request, response) => { |
| 189 | if (shouldRedirectToLogin(request)) { | 189 | if (shouldRedirectToLogin(request)) { |
| 190 | const query = request.url.split('?')[1]; | 190 | const query = request.url.split('?')[1]; |
| 191 | const redirectUrl = query ? `/login?${query}` : '/login'; | 191 | const redirectUrl = query ? `/login?${query}` : '/login'; |