From 20bafcb61bee2a9a10a500908850c9a7d5e3c8c5 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 22 Jul 2021 11:15:17 +0200 Subject: Move apicache in peertube Allow us to upgrade to node 16 --- server/middlewares/cache/cache.ts | 32 ++++ server/middlewares/cache/index.ts | 1 + server/middlewares/cache/shared/api-cache.ts | 269 +++++++++++++++++++++++++++ server/middlewares/cache/shared/index.ts | 1 + 4 files changed, 303 insertions(+) create mode 100644 server/middlewares/cache/cache.ts create mode 100644 server/middlewares/cache/index.ts create mode 100644 server/middlewares/cache/shared/api-cache.ts create mode 100644 server/middlewares/cache/shared/index.ts (limited to 'server/middlewares/cache') diff --git a/server/middlewares/cache/cache.ts b/server/middlewares/cache/cache.ts new file mode 100644 index 000000000..48162a0ae --- /dev/null +++ b/server/middlewares/cache/cache.ts @@ -0,0 +1,32 @@ +import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' +import { Redis } from '../../lib/redis' +import { ApiCache, APICacheOptions } from './shared' + +// Ensure Redis is initialized +Redis.Instance.init() + +const defaultOptions: APICacheOptions = { + excludeStatus: [ + HttpStatusCode.FORBIDDEN_403, + HttpStatusCode.NOT_FOUND_404 + ] +} + +function cacheRoute (duration: string) { + const instance = new ApiCache(defaultOptions) + + return instance.buildMiddleware(duration) +} + +function cacheRouteFactory (options: APICacheOptions) { + const instance = new ApiCache({ ...defaultOptions, ...options }) + + return instance.buildMiddleware.bind(instance) +} + +// --------------------------------------------------------------------------- + +export { + cacheRoute, + cacheRouteFactory +} diff --git a/server/middlewares/cache/index.ts b/server/middlewares/cache/index.ts new file mode 100644 index 000000000..79b512828 --- /dev/null +++ b/server/middlewares/cache/index.ts @@ -0,0 +1 @@ +export * from './cache' diff --git a/server/middlewares/cache/shared/api-cache.ts b/server/middlewares/cache/shared/api-cache.ts new file mode 100644 index 000000000..f9f7b1b67 --- /dev/null +++ b/server/middlewares/cache/shared/api-cache.ts @@ -0,0 +1,269 @@ +// Thanks: https://github.com/kwhitley/apicache +// We duplicated the library because it is unmaintened and prevent us to upgrade to recent NodeJS versions + +import * as express from 'express' +import { OutgoingHttpHeaders } from 'http' +import { isTestInstance, parseDurationToMs } from '@server/helpers/core-utils' +import { logger } from '@server/helpers/logger' +import { Redis } from '@server/lib/redis' +import { HttpStatusCode } from '@shared/models' + +export interface APICacheOptions { + headerBlacklist?: string[] + excludeStatus?: HttpStatusCode[] +} + +interface CacheObject { + status: number + headers: OutgoingHttpHeaders + data: any + encoding: BufferEncoding + timestamp: number +} + +export class ApiCache { + + private readonly options: APICacheOptions + private readonly timers: { [ id: string ]: NodeJS.Timeout } = {} + + private index: { all: string[] } = { all: [] } + + constructor (options: APICacheOptions) { + this.options = { + headerBlacklist: [], + excludeStatus: [], + + ...options + } + } + + buildMiddleware (strDuration: string) { + const duration = parseDurationToMs(strDuration) + + return (req: express.Request, res: express.Response, next: express.NextFunction) => { + const key = Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl + const redis = Redis.Instance.getClient() + + if (!redis.connected) return this.makeResponseCacheable(res, next, key, duration) + + try { + redis.hgetall(key, (err, obj) => { + if (!err && obj && obj.response) { + return this.sendCachedResponse(req, res, JSON.parse(obj.response), duration) + } + + return this.makeResponseCacheable(res, next, key, duration) + }) + } catch (err) { + return this.makeResponseCacheable(res, next, key, duration) + } + } + } + + private shouldCacheResponse (response: express.Response) { + if (!response) return false + if (this.options.excludeStatus.includes(response.statusCode)) return false + + return true + } + + private addIndexEntries (key: string) { + this.index.all.unshift(key) + } + + private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) { + return Object.keys(headers) + .filter(key => !this.options.headerBlacklist.includes(key)) + .reduce((acc, header) => { + acc[header] = headers[header] + + return acc + }, {}) + } + + private createCacheObject (status: number, headers: OutgoingHttpHeaders, data: any, encoding: BufferEncoding) { + return { + status, + headers: this.filterBlacklistedHeaders(headers), + data, + encoding, + + // Seconds since epoch, used to properly decrement max-age headers in cached responses. + timestamp: new Date().getTime() / 1000 + } as CacheObject + } + + private cacheResponse (key: string, value: object, duration: number) { + const redis = Redis.Instance.getClient() + + if (redis.connected) { + try { + redis.hset(key, 'response', JSON.stringify(value)) + redis.hset(key, 'duration', duration + '') + redis.expire(key, duration / 1000) + } catch (err) { + logger.error('Cannot set cache in redis.', { err }) + } + } + + // add automatic cache clearing from duration, includes max limit on setTimeout + this.timers[key] = setTimeout(() => this.clear(key), Math.min(duration, 2147483647)) + } + + private accumulateContent (res: express.Response, content: any) { + if (!content) return + + if (typeof content === 'string') { + res.locals.apicache.content = (res.locals.apicache.content || '') + content + return + } + + if (Buffer.isBuffer(content)) { + let oldContent = res.locals.apicache.content + + if (typeof oldContent === 'string') { + oldContent = Buffer.from(oldContent) + } + + if (!oldContent) { + oldContent = Buffer.alloc(0) + } + + res.locals.apicache.content = Buffer.concat( + [ oldContent, content ], + oldContent.length + content.length + ) + + return + } + + res.locals.apicache.content = content + } + + private makeResponseCacheable (res: express.Response, next: express.NextFunction, key: string, duration: number) { + const self = this + + res.locals.apicache = { + write: res.write, + writeHead: res.writeHead, + end: res.end, + cacheable: true, + content: undefined, + headers: {} + } + + // Patch express + res.writeHead = function () { + if (self.shouldCacheResponse(res)) { + res.setHeader('cache-control', 'max-age=' + (duration / 1000).toFixed(0)) + } else { + res.setHeader('cache-control', 'no-cache, no-store, must-revalidate') + } + + res.locals.apicache.headers = Object.assign({}, res.getHeaders()) + return res.locals.apicache.writeHead.apply(this, arguments as any) + } + + res.write = function (chunk: any) { + self.accumulateContent(res, chunk) + return res.locals.apicache.write.apply(this, arguments as any) + } + + res.end = function (content: any, encoding: BufferEncoding) { + if (self.shouldCacheResponse(res)) { + self.accumulateContent(res, content) + + if (res.locals.apicache.cacheable && res.locals.apicache.content) { + self.addIndexEntries(key) + + const headers = res.locals.apicache.headers || res.getHeaders() + const cacheObject = self.createCacheObject( + res.statusCode, + headers, + res.locals.apicache.content, + encoding + ) + self.cacheResponse(key, cacheObject, duration) + } + } + + res.locals.apicache.end.apply(this, arguments as any) + } as any + + next() + } + + private sendCachedResponse (request: express.Request, response: express.Response, cacheObject: CacheObject, duration: number) { + const headers = response.getHeaders() + + if (isTestInstance()) { + Object.assign(headers, { + 'x-api-cache-cached': 'true' + }) + } + + Object.assign(headers, this.filterBlacklistedHeaders(cacheObject.headers || {}), { + // Set properly decremented max-age header + // This ensures that max-age is in sync with the cache expiration + 'cache-control': + 'max-age=' + + Math.max( + 0, + (duration / 1000 - (new Date().getTime() / 1000 - cacheObject.timestamp)) + ).toFixed(0) + }) + + // unstringify buffers + let data = cacheObject.data + if (data && data.type === 'Buffer') { + data = typeof data.data === 'number' + ? Buffer.alloc(data.data) + : Buffer.from(data.data) + } + + // Test Etag against If-None-Match for 304 + const cachedEtag = cacheObject.headers.etag + const 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) + } + + private clear (target: string) { + const redis = Redis.Instance.getClient() + + if (target) { + clearTimeout(this.timers[target]) + delete this.timers[target] + + try { + redis.del(target) + } catch (err) { + logger.error('Cannot delete %s in redis cache.', target, { err }) + } + + this.index.all = this.index.all.filter(key => key !== target) + } else { + for (const key of this.index.all) { + clearTimeout(this.timers[key]) + delete this.timers[key] + + try { + redis.del(key) + } catch (err) { + logger.error('Cannot delete %s in redis cache.', key, { err }) + } + } + + this.index.all = [] + } + + return this.index + } +} diff --git a/server/middlewares/cache/shared/index.ts b/server/middlewares/cache/shared/index.ts new file mode 100644 index 000000000..c707eaf7a --- /dev/null +++ b/server/middlewares/cache/shared/index.ts @@ -0,0 +1 @@ +export * from './api-cache' -- cgit v1.2.3