From 8c7ee947699532ae072645e43370461fb130da47 Mon Sep 17 00:00:00 2001 From: LouisLam Date: Wed, 22 Sep 2021 00:58:22 +0800 Subject: [PATCH] add modified apicache library with disabling client cache --- server/modules/apicache/apicache.js | 749 ++++++++++++++++++ server/modules/apicache/index.js | 14 + server/modules/apicache/memory-cache.js | 59 ++ server/routers/api-router.js | 17 +- .../status-page-socket-handler.js | 4 +- src/pages/StatusPage.vue | 2 +- 6 files changed, 831 insertions(+), 14 deletions(-) create mode 100644 server/modules/apicache/apicache.js create mode 100644 server/modules/apicache/index.js create mode 100644 server/modules/apicache/memory-cache.js diff --git a/server/modules/apicache/apicache.js b/server/modules/apicache/apicache.js new file mode 100644 index 00000000..22d1fed7 --- /dev/null +++ b/server/modules/apicache/apicache.js @@ -0,0 +1,749 @@ +let url = require("url"); +let MemoryCache = require("./memory-cache"); + +let t = { + ms: 1, + second: 1000, + minute: 60000, + hour: 3600000, + day: 3600000 * 24, + week: 3600000 * 24 * 7, + month: 3600000 * 24 * 30, +}; + +let instances = []; + +let matches = function (a) { + return function (b) { + return a === b; + }; +}; + +let doesntMatch = function (a) { + return function (b) { + return !matches(a)(b); + }; +}; + +let logDuration = function (d, prefix) { + let str = d > 1000 ? (d / 1000).toFixed(2) + "sec" : d + "ms"; + return "\x1b[33m- " + (prefix ? prefix + " " : "") + str + "\x1b[0m"; +}; + +function getSafeHeaders(res) { + return res.getHeaders ? res.getHeaders() : res._headers; +} + +function ApiCache() { + let memCache = new MemoryCache(); + + let globalOptions = { + debug: false, + defaultDuration: 3600000, + enabled: true, + appendKey: [], + jsonp: false, + redisClient: false, + headerBlacklist: [], + statusCodes: { + include: [], + exclude: [], + }, + events: { + expire: undefined, + }, + headers: { + // 'cache-control': 'no-cache' // example of header overwrite + }, + trackPerformance: false, + respectCacheControl: false, + }; + + let middlewareOptions = []; + let instance = this; + let index = null; + let timers = {}; + let performanceArray = []; // for tracking cache hit rate + + instances.push(this); + this.id = instances.length; + + function debug(a, b, c, d) { + let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) { + return arg !== undefined; + }); + let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1; + + return (globalOptions.debug || debugEnv) && console.log.apply(null, arr); + } + + function shouldCacheResponse(request, response, toggle) { + let opt = globalOptions; + let codes = opt.statusCodes; + + if (!response) { + return false; + } + + if (toggle && !toggle(request, response)) { + return false; + } + + if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) { + return false; + } + if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) { + return false; + } + + return true; + } + + function addIndexEntries(key, req) { + let groupName = req.apicacheGroup; + + if (groupName) { + debug("group detected \"" + groupName + "\""); + let group = (index.groups[groupName] = index.groups[groupName] || []); + group.unshift(key); + } + + index.all.unshift(key); + } + + function filterBlacklistedHeaders(headers) { + return Object.keys(headers) + .filter(function (key) { + return globalOptions.headerBlacklist.indexOf(key) === -1; + }) + .reduce(function (acc, header) { + acc[header] = headers[header]; + return acc; + }, {}); + } + + function createCacheObject(status, headers, data, encoding) { + return { + status: status, + headers: filterBlacklistedHeaders(headers), + data: data, + encoding: encoding, + timestamp: new Date().getTime() / 1000, // seconds since epoch. This is used to properly decrement max-age headers in cached responses. + }; + } + + function cacheResponse(key, value, duration) { + let redis = globalOptions.redisClient; + let expireCallback = globalOptions.events.expire; + + if (redis && redis.connected) { + try { + redis.hset(key, "response", JSON.stringify(value)); + redis.hset(key, "duration", duration); + redis.expire(key, duration / 1000, expireCallback || function () {}); + } catch (err) { + debug("[apicache] error in redis.hset()"); + } + } else { + memCache.add(key, value, duration, expireCallback); + } + + // add automatic cache clearing from duration, includes max limit on setTimeout + timers[key] = setTimeout(function () { + instance.clear(key, true); + }, Math.min(duration, 2147483647)); + } + + function accumulateContent(res, content) { + if (content) { + if (typeof content == "string") { + res._apicache.content = (res._apicache.content || "") + content; + } else if (Buffer.isBuffer(content)) { + let oldContent = res._apicache.content; + + if (typeof oldContent === "string") { + oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent); + } + + if (!oldContent) { + oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0); + } + + res._apicache.content = Buffer.concat( + [oldContent, content], + oldContent.length + content.length + ); + } else { + res._apicache.content = content; + } + } + } + + function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) { + // monkeypatch res.end to create cache object + res._apicache = { + write: res.write, + writeHead: res.writeHead, + end: res.end, + cacheable: true, + content: undefined, + }; + + // append header overwrites if applicable + Object.keys(globalOptions.headers).forEach(function (name) { + res.setHeader(name, globalOptions.headers[name]); + }); + + res.writeHead = function () { + // add cache control headers + if (!globalOptions.headers["cache-control"]) { + if (shouldCacheResponse(req, res, toggle)) { + res.setHeader("cache-control", "max-age=" + (duration / 1000).toFixed(0)); + } else { + res.setHeader("cache-control", "no-cache, no-store, must-revalidate"); + } + } + + res._apicache.headers = Object.assign({}, getSafeHeaders(res)); + return res._apicache.writeHead.apply(this, arguments); + }; + + // patch res.write + res.write = function (content) { + accumulateContent(res, content); + return res._apicache.write.apply(this, arguments); + }; + + // patch res.end + res.end = function (content, encoding) { + if (shouldCacheResponse(req, res, toggle)) { + accumulateContent(res, content); + + if (res._apicache.cacheable && res._apicache.content) { + addIndexEntries(key, req); + let headers = res._apicache.headers || getSafeHeaders(res); + let cacheObject = createCacheObject( + res.statusCode, + headers, + res._apicache.content, + encoding + ); + cacheResponse(key, cacheObject, duration); + + // display log entry + let elapsed = new Date() - req.apicacheTimer; + debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed)); + debug("_apicache.headers: ", res._apicache.headers); + debug("res.getHeaders(): ", getSafeHeaders(res)); + debug("cacheObject: ", cacheObject); + } + } + + return res._apicache.end.apply(this, arguments); + }; + + next(); + } + + function sendCachedResponse(request, response, cacheObject, toggle, next, duration) { + if (toggle && !toggle(request, response)) { + return next(); + } + + let headers = getSafeHeaders(response); + + // Modified by @louislam, removed Cache-control, since I don't need client side cache! + // Original Source: https://github.com/kwhitley/apicache/blob/0d5686cc21fad353c6dddee646288c2fca3e4f50/src/apicache.js#L254 + Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {})); + + // only embed apicache headers when not in production environment + if (process.env.NODE_ENV !== "production") { + Object.assign(headers, { + "apicache-store": globalOptions.redisClient ? "redis" : "memory", + "apicache-version": "1.6.2-modified", + }); + } + + // unstringify buffers + let data = cacheObject.data; + if (data && data.type === "Buffer") { + data = + typeof data.data === "number" ? new Buffer.alloc(data.data) : new Buffer.from(data.data); + } + + // test Etag against If-None-Match for 304 + let cachedEtag = cacheObject.headers.etag; + let requestEtag = request.headers["if-none-match"]; + + if (requestEtag && cachedEtag === requestEtag) { + response.writeHead(304, headers); + return response.end(); + } + + response.writeHead(cacheObject.status || 200, headers); + + return response.end(data, cacheObject.encoding); + } + + function syncOptions() { + for (let i in middlewareOptions) { + Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions); + } + } + + this.clear = function (target, isAutomatic) { + let group = index.groups[target]; + let redis = globalOptions.redisClient; + + if (group) { + debug("clearing group \"" + target + "\""); + + group.forEach(function (key) { + debug("clearing cached entry for \"" + key + "\""); + clearTimeout(timers[key]); + delete timers[key]; + if (!globalOptions.redisClient) { + memCache.delete(key); + } else { + try { + redis.del(key); + } catch (err) { + console.log("[apicache] error in redis.del(\"" + key + "\")"); + } + } + index.all = index.all.filter(doesntMatch(key)); + }); + + delete index.groups[target]; + } else if (target) { + debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\""); + clearTimeout(timers[target]); + delete timers[target]; + // clear actual cached entry + if (!redis) { + memCache.delete(target); + } else { + try { + redis.del(target); + } catch (err) { + console.log("[apicache] error in redis.del(\"" + target + "\")"); + } + } + + // remove from global index + index.all = index.all.filter(doesntMatch(target)); + + // remove target from each group that it may exist in + Object.keys(index.groups).forEach(function (groupName) { + index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target)); + + // delete group if now empty + if (!index.groups[groupName].length) { + delete index.groups[groupName]; + } + }); + } else { + debug("clearing entire index"); + + if (!redis) { + memCache.clear(); + } else { + // clear redis keys one by one from internal index to prevent clearing non-apicache entries + index.all.forEach(function (key) { + clearTimeout(timers[key]); + delete timers[key]; + try { + redis.del(key); + } catch (err) { + console.log("[apicache] error in redis.del(\"" + key + "\")"); + } + }); + } + this.resetIndex(); + } + + return this.getIndex(); + }; + + function parseDuration(duration, defaultDuration) { + if (typeof duration === "number") { + return duration; + } + + if (typeof duration === "string") { + let split = duration.match(/^([\d\.,]+)\s?(\w+)$/); + + if (split.length === 3) { + let len = parseFloat(split[1]); + let unit = split[2].replace(/s$/i, "").toLowerCase(); + if (unit === "m") { + unit = "ms"; + } + + return (len || 1) * (t[unit] || 0); + } + } + + return defaultDuration; + } + + this.getDuration = function (duration) { + return parseDuration(duration, globalOptions.defaultDuration); + }; + + /** + * Return cache performance statistics (hit rate). Suitable for putting into a route: + * + * app.get('/api/cache/performance', (req, res) => { + * res.json(apicache.getPerformance()) + * }) + * + */ + this.getPerformance = function () { + return performanceArray.map(function (p) { + return p.report(); + }); + }; + + this.getIndex = function (group) { + if (group) { + return index.groups[group]; + } else { + return index; + } + }; + + this.middleware = function cache(strDuration, middlewareToggle, localOptions) { + let duration = instance.getDuration(strDuration); + let opt = {}; + + middlewareOptions.push({ + options: opt, + }); + + let options = function (localOptions) { + if (localOptions) { + middlewareOptions.find(function (middleware) { + return middleware.options === opt; + }).localOptions = localOptions; + } + + syncOptions(); + + return opt; + }; + + options(localOptions); + + /** + * A Function for non tracking performance + */ + function NOOPCachePerformance() { + this.report = this.hit = this.miss = function () {}; // noop; + } + + /** + * A function for tracking and reporting hit rate. These statistics are returned by the getPerformance() call above. + */ + function CachePerformance() { + /** + * Tracks the hit rate for the last 100 requests. + * If there have been fewer than 100 requests, the hit rate just considers the requests that have happened. + */ + this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits + + /** + * Tracks the hit rate for the last 1000 requests. + * If there have been fewer than 1000 requests, the hit rate just considers the requests that have happened. + */ + this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits + + /** + * Tracks the hit rate for the last 10000 requests. + * If there have been fewer than 10000 requests, the hit rate just considers the requests that have happened. + */ + this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits + + /** + * Tracks the hit rate for the last 100000 requests. + * If there have been fewer than 100000 requests, the hit rate just considers the requests that have happened. + */ + this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits + + /** + * The number of calls that have passed through the middleware since the server started. + */ + this.callCount = 0; + + /** + * The total number of hits since the server started + */ + this.hitCount = 0; + + /** + * The key from the last cache hit. This is useful in identifying which route these statistics apply to. + */ + this.lastCacheHit = null; + + /** + * The key from the last cache miss. This is useful in identifying which route these statistics apply to. + */ + this.lastCacheMiss = null; + + /** + * Return performance statistics + */ + this.report = function () { + return { + lastCacheHit: this.lastCacheHit, + lastCacheMiss: this.lastCacheMiss, + callCount: this.callCount, + hitCount: this.hitCount, + missCount: this.callCount - this.hitCount, + hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount, + hitRateLast100: this.hitRate(this.hitsLast100), + hitRateLast1000: this.hitRate(this.hitsLast1000), + hitRateLast10000: this.hitRate(this.hitsLast10000), + hitRateLast100000: this.hitRate(this.hitsLast100000), + }; + }; + + /** + * Computes a cache hit rate from an array of hits and misses. + * @param {Uint8Array} array An array representing hits and misses. + * @returns a number between 0 and 1, or null if the array has no hits or misses + */ + this.hitRate = function (array) { + let hits = 0; + let misses = 0; + for (let i = 0; i < array.length; i++) { + let n8 = array[i]; + for (let j = 0; j < 4; j++) { + switch (n8 & 3) { + case 1: + hits++; + break; + case 2: + misses++; + break; + } + n8 >>= 2; + } + } + let total = hits + misses; + if (total == 0) { + return null; + } + return hits / total; + }; + + /** + * Record a hit or miss in the given array. It will be recorded at a position determined + * by the current value of the callCount variable. + * @param {Uint8Array} array An array representing hits and misses. + * @param {boolean} hit true for a hit, false for a miss + * Each element in the array is 8 bits, and encodes 4 hit/miss records. + * Each hit or miss is encoded as to bits as follows: + * 00 means no hit or miss has been recorded in these bits + * 01 encodes a hit + * 10 encodes a miss + */ + this.recordHitInArray = function (array, hit) { + let arrayIndex = ~~(this.callCount / 4) % array.length; + let bitOffset = (this.callCount % 4) * 2; // 2 bits per record, 4 records per uint8 array element + let clearMask = ~(3 << bitOffset); + let record = (hit ? 1 : 2) << bitOffset; + array[arrayIndex] = (array[arrayIndex] & clearMask) | record; + }; + + /** + * Records the hit or miss in the tracking arrays and increments the call count. + * @param {boolean} hit true records a hit, false records a miss + */ + this.recordHit = function (hit) { + this.recordHitInArray(this.hitsLast100, hit); + this.recordHitInArray(this.hitsLast1000, hit); + this.recordHitInArray(this.hitsLast10000, hit); + this.recordHitInArray(this.hitsLast100000, hit); + if (hit) { + this.hitCount++; + } + this.callCount++; + }; + + /** + * Records a hit event, setting lastCacheMiss to the given key + * @param {string} key The key that had the cache hit + */ + this.hit = function (key) { + this.recordHit(true); + this.lastCacheHit = key; + }; + + /** + * Records a miss event, setting lastCacheMiss to the given key + * @param {string} key The key that had the cache miss + */ + this.miss = function (key) { + this.recordHit(false); + this.lastCacheMiss = key; + }; + } + + let perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance(); + + performanceArray.push(perf); + + let cache = function (req, res, next) { + function bypass() { + debug("bypass detected, skipping cache."); + return next(); + } + + // initial bypass chances + if (!opt.enabled) { + return bypass(); + } + if ( + req.headers["x-apicache-bypass"] || + req.headers["x-apicache-force-fetch"] || + (opt.respectCacheControl && req.headers["cache-control"] == "no-cache") + ) { + return bypass(); + } + + // REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER + // if (typeof middlewareToggle === 'function') { + // if (!middlewareToggle(req, res)) return bypass() + // } else if (middlewareToggle !== undefined && !middlewareToggle) { + // return bypass() + // } + + // embed timer + req.apicacheTimer = new Date(); + + // In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url + let key = req.originalUrl || req.url; + + // Remove querystring from key if jsonp option is enabled + if (opt.jsonp) { + key = url.parse(key).pathname; + } + + // add appendKey (either custom function or response path) + if (typeof opt.appendKey === "function") { + key += "$$appendKey=" + opt.appendKey(req, res); + } else if (opt.appendKey.length > 0) { + let appendKey = req; + + for (let i = 0; i < opt.appendKey.length; i++) { + appendKey = appendKey[opt.appendKey[i]]; + } + key += "$$appendKey=" + appendKey; + } + + // attempt cache hit + let redis = opt.redisClient; + let cached = !redis ? memCache.getValue(key) : null; + + // send if cache hit from memory-cache + if (cached) { + let elapsed = new Date() - req.apicacheTimer; + debug("sending cached (memory-cache) version of", key, logDuration(elapsed)); + + perf.hit(key); + return sendCachedResponse(req, res, cached, middlewareToggle, next, duration); + } + + // send if cache hit from redis + if (redis && redis.connected) { + try { + redis.hgetall(key, function (err, obj) { + if (!err && obj && obj.response) { + let elapsed = new Date() - req.apicacheTimer; + debug("sending cached (redis) version of", key, logDuration(elapsed)); + + perf.hit(key); + return sendCachedResponse( + req, + res, + JSON.parse(obj.response), + middlewareToggle, + next, + duration + ); + } else { + perf.miss(key); + return makeResponseCacheable( + req, + res, + next, + key, + duration, + strDuration, + middlewareToggle + ); + } + }); + } catch (err) { + // bypass redis on error + perf.miss(key); + return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle); + } + } else { + perf.miss(key); + return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle); + } + }; + + cache.options = options; + + return cache; + }; + + this.options = function (options) { + if (options) { + Object.assign(globalOptions, options); + syncOptions(); + + if ("defaultDuration" in options) { + // Convert the default duration to a number in milliseconds (if needed) + globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000); + } + + if (globalOptions.trackPerformance) { + debug("WARNING: using trackPerformance flag can cause high memory usage!"); + } + + return this; + } else { + return globalOptions; + } + }; + + this.resetIndex = function () { + index = { + all: [], + groups: {}, + }; + }; + + this.newInstance = function (config) { + let instance = new ApiCache(); + + if (config) { + instance.options(config); + } + + return instance; + }; + + this.clone = function () { + return this.newInstance(this.options()); + }; + + // initialize index + this.resetIndex(); +} + +module.exports = new ApiCache(); diff --git a/server/modules/apicache/index.js b/server/modules/apicache/index.js new file mode 100644 index 00000000..b8bb9b35 --- /dev/null +++ b/server/modules/apicache/index.js @@ -0,0 +1,14 @@ +const apicache = require("./apicache"); + +apicache.options({ + headerBlacklist: [ + "cache-control" + ], + headers: { + // Disable client side cache, only server side cache. + // BUG! Not working for the second request + "cache-control": "no-cache", + }, +}); + +module.exports = apicache; diff --git a/server/modules/apicache/memory-cache.js b/server/modules/apicache/memory-cache.js new file mode 100644 index 00000000..ad831e2e --- /dev/null +++ b/server/modules/apicache/memory-cache.js @@ -0,0 +1,59 @@ +function MemoryCache() { + this.cache = {}; + this.size = 0; +} + +MemoryCache.prototype.add = function (key, value, time, timeoutCallback) { + let old = this.cache[key]; + let instance = this; + + let entry = { + value: value, + expire: time + Date.now(), + timeout: setTimeout(function () { + instance.delete(key); + return timeoutCallback && typeof timeoutCallback === "function" && timeoutCallback(value, key); + }, time) + }; + + this.cache[key] = entry; + this.size = Object.keys(this.cache).length; + + return entry; +}; + +MemoryCache.prototype.delete = function (key) { + let entry = this.cache[key]; + + if (entry) { + clearTimeout(entry.timeout); + } + + delete this.cache[key]; + + this.size = Object.keys(this.cache).length; + + return null; +}; + +MemoryCache.prototype.get = function (key) { + let entry = this.cache[key]; + + return entry; +}; + +MemoryCache.prototype.getValue = function (key) { + let entry = this.get(key); + + return entry && entry.value; +}; + +MemoryCache.prototype.clear = function () { + Object.keys(this.cache).forEach(function (key) { + this.delete(key); + }, this); + + return true; +}; + +module.exports = MemoryCache; diff --git a/server/routers/api-router.js b/server/routers/api-router.js index 8dd5189c..d9420ecd 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -2,8 +2,11 @@ let express = require("express"); const { allowDevAllOrigin, getSettings, setting } = require("../util-server"); const { R } = require("redbean-node"); const server = require("../server"); +const apicache = require("../modules/apicache"); let router = express.Router(); +let cache = apicache.middleware; + router.get("/api/entry-page", async (_, response) => { allowDevAllOrigin(response); response.json(server.entryPage); @@ -56,7 +59,7 @@ router.get("/api/status-page/incident", async (_, response) => { // Status Page - Monitor List // Can fetch only if published -router.get("/api/status-page/monitor-list", async (_request, response) => { +router.get("/api/status-page/monitor-list", cache("5 minutes"), async (_request, response) => { allowDevAllOrigin(response); try { @@ -77,7 +80,7 @@ router.get("/api/status-page/monitor-list", async (_request, response) => { // Status Page Polling Data // Can fetch only if published -router.get("/api/status-page/heartbeat", async (_request, response) => { +router.get("/api/status-page/heartbeat", cache("5 minutes"), async (_request, response) => { allowDevAllOrigin(response); try { await checkPublished(); @@ -113,16 +116,6 @@ router.get("/api/status-page/heartbeat", async (_request, response) => { } }); -router.post("/api/status-page/upload-logo", async (request, response) => { - allowDevAllOrigin(response); - - // TODO: Check Bearer token - - console.log(request); - - response.json({}); -}); - async function checkPublished() { if (! await isPublished()) { throw new Error("The status page is not published"); diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 16d17831..ad07c16b 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -4,7 +4,7 @@ const dayjs = require("dayjs"); const { debug } = require("../../src/util"); const ImageDataURI = require("../image-data-uri"); const Database = require("../database"); -const fs = require("fs"); +const apicache = require("../modules/apicache"); module.exports.statusPageSocketHandler = (socket) => { @@ -76,6 +76,8 @@ module.exports.statusPageSocketHandler = (socket) => { try { checkLogin(socket); + apicache.clear(); + const header = "data:image/png;base64,"; // Check logo format diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 206b4042..8045cb1a 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -409,7 +409,7 @@ export default { this.updateHeartbeatList(); feedInterval = setInterval(() => { this.updateHeartbeatList(); - }, 5 * 60 * 1000); + }, (300 + 10) * 1000); }, methods: {