]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/middlewares/cache/shared/api-cache.ts
Add Podcast RSS feeds (#5487)
[github/Chocobozzz/PeerTube.git] / server / middlewares / cache / shared / api-cache.ts
CommitLineData
20bafcb6
C
1// Thanks: https://github.com/kwhitley/apicache
2// We duplicated the library because it is unmaintened and prevent us to upgrade to recent NodeJS versions
3
41fb13c3 4import express from 'express'
20bafcb6
C
5import { OutgoingHttpHeaders } from 'http'
6import { isTestInstance, parseDurationToMs } from '@server/helpers/core-utils'
7import { logger } from '@server/helpers/logger'
8import { Redis } from '@server/lib/redis'
e5d91a9b 9import { asyncMiddleware } from '@server/middlewares'
b2111066 10import { HttpStatusCode } from '@shared/models'
20bafcb6
C
11
12export interface APICacheOptions {
13 headerBlacklist?: string[]
14 excludeStatus?: HttpStatusCode[]
15}
16
17interface CacheObject {
18 status: number
19 headers: OutgoingHttpHeaders
20 data: any
21 encoding: BufferEncoding
22 timestamp: number
23}
24
25export class ApiCache {
26
27 private readonly options: APICacheOptions
28 private readonly timers: { [ id: string ]: NodeJS.Timeout } = {}
29
cb0eda56
AG
30 private readonly index = {
31 groups: [] as string[],
32 all: [] as string[]
33 }
34
35 // Cache keys per group
36 private groups: { [groupIndex: string]: string[] } = {}
20bafcb6
C
37
38 constructor (options: APICacheOptions) {
39 this.options = {
40 headerBlacklist: [],
41 excludeStatus: [],
42
43 ...options
44 }
45 }
46
47 buildMiddleware (strDuration: string) {
48 const duration = parseDurationToMs(strDuration)
49
e5d91a9b
C
50 return asyncMiddleware(
51 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
cb0eda56 52 const key = this.getCacheKey(req)
e5d91a9b 53 const redis = Redis.Instance.getClient()
20bafcb6 54
e5d91a9b 55 if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
20bafcb6 56
e5d91a9b 57 try {
564b9b55 58 const obj = await redis.hgetall(key)
e5d91a9b 59 if (obj?.response) {
20bafcb6
C
60 return this.sendCachedResponse(req, res, JSON.parse(obj.response), duration)
61 }
62
63 return this.makeResponseCacheable(res, next, key, duration)
e5d91a9b
C
64 } catch (err) {
65 return this.makeResponseCacheable(res, next, key, duration)
66 }
20bafcb6 67 }
e5d91a9b 68 )
20bafcb6
C
69 }
70
cb0eda56
AG
71 clearGroupSafe (group: string) {
72 const run = async () => {
73 const cacheKeys = this.groups[group]
74 if (!cacheKeys) return
75
76 for (const key of cacheKeys) {
77 try {
78 await this.clear(key)
79 } catch (err) {
80 logger.error('Cannot clear ' + key, { err })
81 }
82 }
83
84 delete this.groups[group]
85 }
86
87 void run()
88 }
89
90 private getCacheKey (req: express.Request) {
91 return Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
92 }
93
20bafcb6
C
94 private shouldCacheResponse (response: express.Response) {
95 if (!response) return false
96 if (this.options.excludeStatus.includes(response.statusCode)) return false
97
98 return true
99 }
100
cb0eda56 101 private addIndexEntries (key: string, res: express.Response) {
20bafcb6 102 this.index.all.unshift(key)
cb0eda56
AG
103
104 const groups = res.locals.apicacheGroups || []
105
106 for (const group of groups) {
107 if (!this.groups[group]) this.groups[group] = []
108
109 this.groups[group].push(key)
110 }
20bafcb6
C
111 }
112
113 private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
114 return Object.keys(headers)
115 .filter(key => !this.options.headerBlacklist.includes(key))
116 .reduce((acc, header) => {
117 acc[header] = headers[header]
118
119 return acc
120 }, {})
121 }
122
123 private createCacheObject (status: number, headers: OutgoingHttpHeaders, data: any, encoding: BufferEncoding) {
124 return {
125 status,
126 headers: this.filterBlacklistedHeaders(headers),
127 data,
128 encoding,
129
130 // Seconds since epoch, used to properly decrement max-age headers in cached responses.
131 timestamp: new Date().getTime() / 1000
132 } as CacheObject
133 }
134
e5d91a9b 135 private async cacheResponse (key: string, value: object, duration: number) {
20bafcb6
C
136 const redis = Redis.Instance.getClient()
137
e5d91a9b
C
138 if (Redis.Instance.isConnected()) {
139 await Promise.all([
564b9b55 140 redis.hset(key, 'response', JSON.stringify(value)),
141 redis.hset(key, 'duration', duration + ''),
20bafcb6 142 redis.expire(key, duration / 1000)
e5d91a9b 143 ])
20bafcb6
C
144 }
145
146 // add automatic cache clearing from duration, includes max limit on setTimeout
e5d91a9b
C
147 this.timers[key] = setTimeout(() => {
148 this.clear(key)
149 .catch(err => logger.error('Cannot clear Redis key %s.', key, { err }))
150 }, Math.min(duration, 2147483647))
20bafcb6
C
151 }
152
153 private accumulateContent (res: express.Response, content: any) {
154 if (!content) return
155
156 if (typeof content === 'string') {
157 res.locals.apicache.content = (res.locals.apicache.content || '') + content
158 return
159 }
160
161 if (Buffer.isBuffer(content)) {
162 let oldContent = res.locals.apicache.content
163
164 if (typeof oldContent === 'string') {
165 oldContent = Buffer.from(oldContent)
166 }
167
168 if (!oldContent) {
169 oldContent = Buffer.alloc(0)
170 }
171
172 res.locals.apicache.content = Buffer.concat(
173 [ oldContent, content ],
174 oldContent.length + content.length
175 )
176
177 return
178 }
179
180 res.locals.apicache.content = content
181 }
182
183 private makeResponseCacheable (res: express.Response, next: express.NextFunction, key: string, duration: number) {
184 const self = this
185
186 res.locals.apicache = {
187 write: res.write,
188 writeHead: res.writeHead,
189 end: res.end,
190 cacheable: true,
191 content: undefined,
b2111066 192 headers: undefined
20bafcb6
C
193 }
194
195 // Patch express
196 res.writeHead = function () {
197 if (self.shouldCacheResponse(res)) {
198 res.setHeader('cache-control', 'max-age=' + (duration / 1000).toFixed(0))
199 } else {
200 res.setHeader('cache-control', 'no-cache, no-store, must-revalidate')
201 }
202
203 res.locals.apicache.headers = Object.assign({}, res.getHeaders())
204 return res.locals.apicache.writeHead.apply(this, arguments as any)
205 }
206
207 res.write = function (chunk: any) {
208 self.accumulateContent(res, chunk)
209 return res.locals.apicache.write.apply(this, arguments as any)
210 }
211
212 res.end = function (content: any, encoding: BufferEncoding) {
213 if (self.shouldCacheResponse(res)) {
214 self.accumulateContent(res, content)
215
216 if (res.locals.apicache.cacheable && res.locals.apicache.content) {
cb0eda56 217 self.addIndexEntries(key, res)
20bafcb6
C
218
219 const headers = res.locals.apicache.headers || res.getHeaders()
220 const cacheObject = self.createCacheObject(
221 res.statusCode,
222 headers,
223 res.locals.apicache.content,
224 encoding
225 )
226 self.cacheResponse(key, cacheObject, duration)
e5d91a9b 227 .catch(err => logger.error('Cannot cache response', { err }))
20bafcb6
C
228 }
229 }
230
231 res.locals.apicache.end.apply(this, arguments as any)
232 } as any
233
234 next()
235 }
236
237 private sendCachedResponse (request: express.Request, response: express.Response, cacheObject: CacheObject, duration: number) {
238 const headers = response.getHeaders()
239
240 if (isTestInstance()) {
241 Object.assign(headers, {
242 'x-api-cache-cached': 'true'
243 })
244 }
245
246 Object.assign(headers, this.filterBlacklistedHeaders(cacheObject.headers || {}), {
247 // Set properly decremented max-age header
248 // This ensures that max-age is in sync with the cache expiration
249 'cache-control':
250 'max-age=' +
251 Math.max(
252 0,
253 (duration / 1000 - (new Date().getTime() / 1000 - cacheObject.timestamp))
254 ).toFixed(0)
255 })
256
257 // unstringify buffers
258 let data = cacheObject.data
259 if (data && data.type === 'Buffer') {
260 data = typeof data.data === 'number'
261 ? Buffer.alloc(data.data)
262 : Buffer.from(data.data)
263 }
264
265 // Test Etag against If-None-Match for 304
266 const cachedEtag = cacheObject.headers.etag
267 const requestEtag = request.headers['if-none-match']
268
269 if (requestEtag && cachedEtag === requestEtag) {
270 response.writeHead(304, headers)
271 return response.end()
272 }
273
274 response.writeHead(cacheObject.status || 200, headers)
275
276 return response.end(data, cacheObject.encoding)
277 }
278
e5d91a9b 279 private async clear (target: string) {
20bafcb6
C
280 const redis = Redis.Instance.getClient()
281
282 if (target) {
283 clearTimeout(this.timers[target])
284 delete this.timers[target]
285
286 try {
e5d91a9b 287 await redis.del(target)
20bafcb6
C
288 } catch (err) {
289 logger.error('Cannot delete %s in redis cache.', target, { err })
290 }
291
292 this.index.all = this.index.all.filter(key => key !== target)
293 } else {
294 for (const key of this.index.all) {
295 clearTimeout(this.timers[key])
296 delete this.timers[key]
297
298 try {
e5d91a9b 299 await redis.del(key)
20bafcb6
C
300 } catch (err) {
301 logger.error('Cannot delete %s in redis cache.', key, { err })
302 }
303 }
304
305 this.index.all = []
306 }
307
308 return this.index
309 }
310}