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 | 151 | # Enables disk caching for character cards. Improves performances with large card libraries. |
| 152 | 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 | 163 | # Allow secret keys exposure via API |
| 155 | 164 | allowKeysExposure: false |
| 156 | 165 | # Skip new default content checks |
| @@ -1,4 +1,3 @@ | ||
| 1 | - | |
| 2 | 1 | import sanitize from 'sanitize-filename'; |
| 3 | 2 | import { promises as fsPromises } from 'node:fs'; |
| 4 | 3 | import path from 'node:path'; |
| @@ -10,6 +10,7 @@ import { getImages, tryParse } from '../util.js'; | ||
| 10 | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | 11 | import { applyAvatarCropResize } from './characters.js'; |
| 12 | 12 | import { invalidateThumbnail } from './thumbnails.js'; |
| 13 | +import cacheBuster from '../middleware/cacheBuster.js'; | |
| 13 | 14 | |
| 14 | 15 | export const router = express.Router(); |
| 15 | 16 | |
| @@ -49,7 +50,7 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r | ||
| 49 | 50 | // Remove previous thumbnail and bust cache if overwriting |
| 50 | 51 | if (request.body.overwrite_name) { |
| 51 | 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 | 56 | const filename = request.body.overwrite_name || `${Date.now()}.png`; |
| @@ -23,6 +23,7 @@ import { importRisuSprites } from './sprites.js'; | ||
| 23 | 23 | import { getUserDirectories } from '../users.js'; |
| 24 | 24 | import { getChatInfo } from './chats.js'; |
| 25 | 25 | import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js'; |
| 26 | +import cacheBuster from '../middleware/cacheBuster.js'; | |
| 26 | 27 | |
| 27 | 28 | // With 100 MB limit it would take roughly 3000 characters to reach this limit |
| 28 | 29 | const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb'); |
| @@ -1065,7 +1066,7 @@ router.post('/edit', validateAvatarUrlMiddleware, async function (request, respo | ||
| 1065 | 1066 | fs.unlinkSync(newAvatarPath); |
| 1066 | 1067 | |
| 1067 | 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 | 1072 | return response.sendStatus(200); |
| @@ -1,28 +1,110 @@ | ||
| 1 | 1 | import crypto from 'node:crypto'; |
| 2 | 2 | import { DEFAULT_USER } from '../constants.js'; |
| 3 | +import { getConfigValue } from '../util.js'; | |
| 3 | 4 | |
| 4 | 5 | /** |
| 5 | 6 | * MiddlewareSets the Clear-Site-Data header to bust the browser cache for the current user. |
| 6 | - * @returns {import('express').RequestHandler} | |
| 7 | 7 | */ |
| 8 | -export default function getCacheBusterMiddleware() { | |
| 8 | +class CacheBuster { | |
| 9 | 9 | /** |
| 10 | 10 | * @type {Set<string>} Handles/User-Agents that have already been busted. |
| 11 | + * @type {Set<string>} | |
| 11 | 12 | */ |
| 12 | 13 | const #keys = new Set(); |
| 13 | 14 | |
| 14 | - return (request, response, next) => { | |
| 15 | + /** | |
| 16 | + * User agent regex to match against requests. | |
| 17 | + * @type {RegExp | null} | |
| 18 | + */ | |
| 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 | + | |
| 39 | + /** | |
| 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. | |
| 44 | + */ | |
| 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 | + } | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * Middleware to bust the browser cache for the current user. | |
| 70 | + * @type {import('express').RequestHandler} | |
| 71 | + */ | |
| 72 | + #middleware(request, response, next) { | |
| 15 | 73 | const handle = request.user?.profile?.handle || DEFAULT_USER.handle; |
| 16 | 74 | const userAgent = request.headers['user-agent'] || ''; |
| 17 | 75 | const hash = crypto.createHash('sha256').update(userAgent).digest('hex'); |
| 18 | 76 | const key = `${handle}-${hash}`; |
| 19 | 77 | |
| 20 | 78 | if (this.#keys.has(key)) { |
| 21 | 79 | return next(); |
| 22 | 80 | } |
| 23 | 81 | |
| 24 | 82 | this.#keys.add(key); |
| 25 | - response.setHeader('Clear-Site-Data', '"cache"'); | |
| 83 | + this.bust(request, response); | |
| 26 | 84 | next(); |
| 27 | 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 | + } | |
| 28 | 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 | 44 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; |
| 45 | 45 | import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; |
| 46 | 46 | import initRequestProxy from './request-proxy.js'; |
| 47 | 47 | import getCacheBusterMiddlewarecacheBuster from './middleware/cacheBuster.js'; |
| 48 | 48 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 49 | 49 | import { |
| 50 | 50 | getVersion, |
| @@ -185,7 +185,7 @@ if (!cliArgs.disableCsrf) { | ||
| 185 | 185 | |
| 186 | 186 | // Static files |
| 187 | 187 | // Host index page |
| 188 | 188 | app.get('/', getCacheBusterMiddleware()cacheBuster.middleware, (request, response) => { |
| 189 | 189 | if (shouldRedirectToLogin(request)) { |
| 190 | 190 | const query = request.url.split('?')[1]; |
| 191 | 191 | const redirectUrl = query ? `/login?${query}` : '/login'; |