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:
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: true152 useDiskCache: true
153153
154# CACHE BUSTER CONFIGURATION
155# IMPORTANT: Requires localhost or a domain with HTTPS, otherwise will not work!
156cacheBuster:
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 API163# Allow secret keys exposure via API
155allowKeysExposure: false164allowKeysExposure: false
156# Skip new default content checks165# Skip new default content checks
src/byaf.js+0 -1
@@ -1,4 +1,3 @@
1
2import sanitize from 'sanitize-filename';1import sanitize from 'sanitize-filename';
3import { promises as fsPromises } from 'node:fs';2import { promises as fsPromises } from 'node:fs';
4import path from 'node:path';3import path from 'node:path';
src/endpoints/avatars.js+2 -1
@@ -10,6 +10,7 @@ import { getImages, tryParse } from '../util.js';
10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
11import { applyAvatarCropResize } from './characters.js';11import { applyAvatarCropResize } from './characters.js';
12import { invalidateThumbnail } from './thumbnails.js';12import { invalidateThumbnail } from './thumbnails.js';
13import cacheBuster from '../middleware/cacheBuster.js';
1314
14export const router = express.Router();15export const router = express.Router();
1516
@@ -49,7 +50,7 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r
49 // Remove previous thumbnail and bust cache if overwriting50 // 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 }
5455
55 const filename = request.body.overwrite_name || `${Date.now()}.png`;56 const filename = request.body.overwrite_name || `${Date.now()}.png`;
src/endpoints/characters.js+2 -1
@@ -23,6 +23,7 @@ import { importRisuSprites } from './sprites.js';
23import { getUserDirectories } from '../users.js';23import { getUserDirectories } from '../users.js';
24import { getChatInfo } from './chats.js';24import { getChatInfo } from './chats.js';
25import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js';25import { formatByafAsCharacterCard, getCharacterFromByafManifest, getImageBufferFromByafCharacter, getScenarioFromByafManifest } from '../byaf.js';
26import cacheBuster from '../middleware/cacheBuster.js';
2627
27// With 100 MB limit it would take roughly 3000 characters to reach this limit28// With 100 MB limit it would take roughly 3000 characters to reach this limit
28const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');29const 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);
10661067
1067 // Bust cache to reload the new avatar1068 // Bust cache to reload the new avatar
1068 response.setHeader('Clear-Site-Data', '"cache"');1069 cacheBuster.bust(request, response);
1069 }1070 }
10701071
1071 return response.sendStatus(200);1072 return response.sendStatus(200);
src/middleware/cacheBuster.js+92 -10
@@ -1,28 +1,110 @@
1import crypto from 'node:crypto';1import crypto from 'node:crypto';
2import { DEFAULT_USER } from '../constants.js';2import { DEFAULT_USER } from '../constants.js';
3import { getConfigValue } from '../util.js';
34
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 */
8class 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 */
8export 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 }
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) {
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}`;
1977
20 if (keys.has(key)) {78 if (this.#keys.has(key)) {
21 return next();79 return next();
22 }80 }
2381
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
109const instance = new CacheBuster();
110export default instance;
src/server-main.js+2 -2
@@ -44,7 +44,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js';
44import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';44import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
45import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';45import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
46import initRequestProxy from './request-proxy.js';46import initRequestProxy from './request-proxy.js';
47import getCacheBusterMiddleware from './middleware/cacheBuster.js';47import cacheBuster from './middleware/cacheBuster.js';
48import corsProxyMiddleware from './middleware/corsProxy.js';48import corsProxyMiddleware from './middleware/corsProxy.js';
49import {49import {
50 getVersion,50 getVersion,
@@ -185,7 +185,7 @@ if (!cliArgs.disableCsrf) {
185185
186// Static files186// Static files
187// Host index page187// Host index page
188app.get('/', getCacheBusterMiddleware(), (request, response) => {188app.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';