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

c627d77c468a5757ca9b3cfafa65137ea2a1cc69

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
6 files changed, +107 -15Showing whitespace changes
default/config.yaml+9 -0
@@ -151,6 +151,15 @@ performance:
151151 # Enables disk caching for character cards. Improves performances with large card libraries.
152152 useDiskCache: true
153153
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+
154163# Allow secret keys exposure via API
155164allowKeysExposure: false
156165# Skip new default content checks
src/byaf.js+0 -1
@@ -1,4 +1,3 @@
1-
21import sanitize from 'sanitize-filename';
32import { promises as fsPromises } from 'node:fs';
43import path from 'node:path';
src/endpoints/avatars.js+2 -1
@@ -10,6 +10,7 @@ import { getImages, tryParse } from '../util.js';
1010import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111import { applyAvatarCropResize } from './characters.js';
1212import { invalidateThumbnail } from './thumbnails.js';
13+import cacheBuster from '../middleware/cacheBuster.js';
1314
1415export const router = express.Router();
1516
@@ -49,7 +50,7 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r
4950 // Remove previous thumbnail and bust cache if overwriting
5051 if (request.body.overwrite_name) {
5152 invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name));
52- response.setHeader('Clear-Site-Data', '"cache"');
53+ cacheBuster.bust(request, response);
5354 }
5455
5556 const filename = request.body.overwrite_name || `${Date.now()}.png`;
src/endpoints/characters.js+2 -1
@@ -23,6 +23,7 @@ import { importRisuSprites } from './sprites.js';
2323import { getUserDirectories } from '../users.js';
2424import { getChatInfo } from './chats.js';
2525import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js';
26+import cacheBuster from '../middleware/cacheBuster.js';
2627
2728// With 100 MB limit it would take roughly 3000 characters to reach this limit
2829const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');
@@ -1065,7 +1066,7 @@ router.post('/edit', validateAvatarUrlMiddleware, async function (request, respo
10651066 fs.unlinkSync(newAvatarPath);
10661067
10671068 // Bust cache to reload the new avatar
1068- response.setHeader('Clear-Site-Data', '"cache"');
1069+ cacheBuster.bust(request, response);
10691070 }
10701071
10711072 return response.sendStatus(200);
src/middleware/cacheBuster.js+92 -10
@@ -1,28 +1,110 @@
11import crypto from 'node:crypto';
22import { DEFAULT_USER } from '../constants.js';
3+import { getConfigValue } from '../util.js';
34
45/**
56 * MiddlewareSets the Clear-Site-Data header to bust the browser cache for the current user.
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}
718 */
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+
939 /**
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.
1144 */
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+ }
1367
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) {
1573 const handle = request.user?.profile?.handle || DEFAULT_USER.handle;
1674 const userAgent = request.headers['user-agent'] || '';
1775 const hash = crypto.createHash('sha256').update(userAgent).digest('hex');
1876 const key = `${handle}-${hash}`;
1977
2078 if (this.#keys.has(key)) {
2179 return next();
2280 }
2381
2482 this.#keys.add(key);
25- response.setHeader('Clear-Site-Data', '"cache"');
83+ this.bust(request, response);
2684 next();
27- };
2885 }
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;
src/server-main.js+2 -2
@@ -44,7 +44,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js';
4444import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
4545import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
4646import initRequestProxy from './request-proxy.js';
4747import getCacheBusterMiddlewarecacheBuster from './middleware/cacheBuster.js';
4848import corsProxyMiddleware from './middleware/corsProxy.js';
4949import {
5050 getVersion,
@@ -185,7 +185,7 @@ if (!cliArgs.disableCsrf) {
185185
186186// Static files
187187// Host index page
188188app.get('/', getCacheBusterMiddleware()cacheBuster.middleware, (request, response) => {
189189 if (shouldRedirectToLogin(request)) {
190190 const query = request.url.split('?')[1];
191191 const redirectUrl = query ? `/login?${query}` : '/login';