const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const redis = require('../config/redis'); const dataPageCache = require('../services/dataPageCache'); const CACHE_BUSTER_KEYS = new Set([ '_', '_t', 't', 'timestamp', 'cacheBust', 'cacheBuster', 'nocache', ]); const DEFAULT_REDIS_TTL_SECONDS = Math.max( Number(process.env.PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 300, 30 ); const MAX_RECENT_EVENTS = 200; const pageCacheStats = { total: 0, hits: 0, misses: 0, stores: 0, bypasses: 0, invalidations: 0, labels: new Map(), recentEvents: [], lastResetAt: new Date().toISOString(), }; function hashValue(value) { if (!value) return ''; return crypto.createHash('sha256').update(String(value)).digest('hex'); } function normalizeQuery(query = {}) { return Object.keys(query || {}) .filter((key) => !CACHE_BUSTER_KEYS.has(key)) .sort() .reduce((acc, key) => { acc[key] = query[key]; return acc; }, {}); } function buildUserScope(req) { if (req.user) { return { sub: req.user.sub || req.user.id || '', role: req.user.role || '', }; } return { authHash: hashValue(req.headers.authorization || ''), }; } function verifyAuthHeaderIfPresent(req) { if (req.user) { return true; } const authHeader = String(req.headers.authorization || ''); if (!authHeader) { return true; } if (!authHeader.startsWith('Bearer ')) { return false; } try { req.user = jwt.verify(authHeader.substring(7), process.env.CUBEJS_SECRET); return true; } catch (_error) { return false; } } function getNamespacePrefix(namespace) { return `page-response:${namespace}:`; } function buildCacheKey(req, namespace, version) { return dataPageCache.buildHashedCacheKey( getNamespacePrefix(namespace), { method: req.method, path: `${req.baseUrl || ''}${req.path || ''}`, query: normalizeQuery(req.query), user: buildUserScope(req), acceptLanguage: req.headers['accept-language'] || '', }, version ); } function isCacheBypassed(req) { const cacheControl = String(req.headers['cache-control'] || '').toLowerCase(); return ( cacheControl.includes('no-cache') || cacheControl.includes('no-store') || req.query?._refresh === '1' || req.query?.refresh === '1' ); } function defaultShouldCacheBody(body) { if (body === undefined) return false; if (body?.success === false) return false; return true; } function recordPageCacheEvent(namespace, hit, details = {}) { pageCacheStats.total += 1; if (hit === 'redis') pageCacheStats.hits += 1; if (hit === 'miss') pageCacheStats.misses += 1; if (hit === 'store') pageCacheStats.stores += 1; if (hit === 'bypass') pageCacheStats.bypasses += 1; if (hit === 'invalidate') pageCacheStats.invalidations += 1; const labelStats = pageCacheStats.labels.get(namespace) || { label: namespace, total: 0, hits: 0, misses: 0, stores: 0, bypasses: 0, invalidations: 0, lastHit: null, lastSeenAt: null, }; labelStats.total += 1; labelStats.lastHit = hit; labelStats.lastSeenAt = new Date().toISOString(); if (hit === 'redis') labelStats.hits += 1; if (hit === 'miss') labelStats.misses += 1; if (hit === 'store') labelStats.stores += 1; if (hit === 'bypass') labelStats.bypasses += 1; if (hit === 'invalidate') labelStats.invalidations += 1; pageCacheStats.labels.set(namespace, labelStats); pageCacheStats.recentEvents.unshift({ namespace, hit, path: details.path || '', timestamp: new Date().toISOString(), }); if (pageCacheStats.recentEvents.length > MAX_RECENT_EVENTS) { pageCacheStats.recentEvents.length = MAX_RECENT_EVENTS; } } function getPageDataCacheStats() { return { total: pageCacheStats.total, hits: pageCacheStats.hits, misses: pageCacheStats.misses, stores: pageCacheStats.stores, bypasses: pageCacheStats.bypasses, invalidations: pageCacheStats.invalidations, hitRate: pageCacheStats.total ? Number((pageCacheStats.hits * 100 / pageCacheStats.total).toFixed(2)) : 0, lastResetAt: pageCacheStats.lastResetAt, labels: Array.from(pageCacheStats.labels.values()).sort((a, b) => b.total - a.total), recentEvents: pageCacheStats.recentEvents, }; } function resetPageDataCacheStats() { pageCacheStats.total = 0; pageCacheStats.hits = 0; pageCacheStats.misses = 0; pageCacheStats.stores = 0; pageCacheStats.bypasses = 0; pageCacheStats.invalidations = 0; pageCacheStats.labels.clear(); pageCacheStats.recentEvents = []; pageCacheStats.lastResetAt = new Date().toISOString(); } function createPageDataCache(options = {}) { const namespace = options.namespace || 'default'; const version = options.version || 'v1'; const redisTtlSeconds = Math.max(Number(options.redisTtlSeconds) || DEFAULT_REDIS_TTL_SECONDS, 30); const enabled = options.enabled !== false; const invalidateOnMutations = options.invalidateOnMutations !== false; const shouldHandleRequest = typeof options.shouldHandleRequest === 'function' ? options.shouldHandleRequest : () => true; const shouldCacheBody = typeof options.shouldCacheBody === 'function' ? options.shouldCacheBody : defaultShouldCacheBody; return async (req, res, next) => { if (!enabled || !shouldHandleRequest(req)) { recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl }); return next(); } if (req.method !== 'GET') { if (invalidateOnMutations) { res.on('finish', () => { if (res.statusCode >= 200 && res.statusCode < 300) { dataPageCache.deleteByPrefix(getNamespacePrefix(namespace)).catch((error) => { console.warn(`[PageDataCache] ${namespace} invalidation failed:`, error.message); }); recordPageCacheEvent(namespace, 'invalidate', { path: req.originalUrl }); } }); } return next(); } if (!verifyAuthHeaderIfPresent(req) || isCacheBypassed(req)) { recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl }); res.set('X-Page-Cache', 'bypass'); return next(); } const cacheKey = buildCacheKey(req, namespace, version); const cachedPayload = await redis.get(cacheKey); if (cachedPayload?.body !== undefined) { recordPageCacheEvent(namespace, 'redis', { path: req.originalUrl }); res.set('X-Page-Cache', 'redis'); return res.status(cachedPayload.statusCode || 200).json(cachedPayload.body); } recordPageCacheEvent(namespace, 'miss', { path: req.originalUrl }); res.set('X-Page-Cache', 'miss'); const originalJson = res.json.bind(res); res.json = (body) => { if (res.statusCode >= 200 && res.statusCode < 300 && shouldCacheBody(body, req)) { redis.setex(cacheKey, redisTtlSeconds, { statusCode: res.statusCode, body, cachedAt: new Date().toISOString(), }).then((stored) => { if (stored) { recordPageCacheEvent(namespace, 'store', { path: req.originalUrl }); } }).catch((error) => { console.warn(`[PageDataCache] ${namespace} store failed:`, error.message); }); } return originalJson(body); }; return next(); }; } module.exports = { createPageDataCache, getPageDataCacheStats, resetPageDataCacheStats, };