]>
Commit | Line | Data |
---|---|---|
a91e9beb | 1 | import { createClient, RedisClientOptions, RedisModules } from 'redis' |
e5d91a9b | 2 | import { exists } from '@server/helpers/custom-validators/misc' |
f1569117 | 3 | import { sha256 } from '@shared/extra-utils' |
ecb4e35f C |
4 | import { logger } from '../helpers/logger' |
5 | import { generateRandomString } from '../helpers/utils' | |
e5d91a9b | 6 | import { CONFIG } from '../initializers/config' |
a4101923 | 7 | import { |
f1569117 | 8 | AP_CLEANER, |
a4101923 | 9 | CONTACT_FORM_LIFETIME, |
e5d91a9b C |
10 | RESUMABLE_UPLOAD_SESSION_LIFETIME, |
11 | TRACKER_RATE_LIMITS, | |
56f47830 | 12 | TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, |
a4101923 | 13 | USER_EMAIL_VERIFY_LIFETIME, |
45f1bd72 | 14 | USER_PASSWORD_CREATE_LIFETIME, |
e5d91a9b | 15 | USER_PASSWORD_RESET_LIFETIME, |
e4bf7856 | 16 | VIEW_LIFETIME, |
e5d91a9b | 17 | WEBSERVER |
74dc3bca | 18 | } from '../initializers/constants' |
4195cd2b | 19 | |
ecb4e35f C |
20 | class Redis { |
21 | ||
22 | private static instance: Redis | |
23 | private initialized = false | |
e5d91a9b | 24 | private connected = false |
a91e9beb | 25 | private client: ReturnType<typeof createClient> |
ecb4e35f C |
26 | private prefix: string |
27 | ||
a1587156 C |
28 | private constructor () { |
29 | } | |
ecb4e35f C |
30 | |
31 | init () { | |
32 | // Already initialized | |
33 | if (this.initialized === true) return | |
34 | this.initialized = true | |
35 | ||
47f6409b | 36 | this.client = createClient(Redis.getRedisClientOptions()) |
ab08ab4e | 37 | this.client.on('error', err => logger.error('Redis Client Error', { err })) |
ecb4e35f | 38 | |
8f5a1f36 C |
39 | logger.info('Connecting to redis...') |
40 | ||
e5d91a9b | 41 | this.client.connect() |
8f5a1f36 C |
42 | .then(() => { |
43 | logger.info('Connected to redis.') | |
44 | ||
45 | this.connected = true | |
46 | }).catch(err => { | |
e5d91a9b C |
47 | logger.error('Cannot connect to redis', { err }) |
48 | process.exit(-1) | |
49 | }) | |
50 | ||
6dd9de95 | 51 | this.prefix = 'redis-' + WEBSERVER.HOST + '-' |
ecb4e35f C |
52 | } |
53 | ||
47f6409b | 54 | static getRedisClientOptions () { |
8f5a1f36 C |
55 | let config: RedisClientOptions<RedisModules, {}> = { |
56 | socket: { | |
57 | connectTimeout: 20000 // Could be slow since node use sync call to compile PeerTube | |
58 | } | |
59 | } | |
60 | ||
61 | if (CONFIG.REDIS.AUTH) { | |
62 | config = { ...config, password: CONFIG.REDIS.AUTH } | |
63 | } | |
64 | ||
65 | if (CONFIG.REDIS.DB) { | |
66 | config = { ...config, database: CONFIG.REDIS.DB } | |
67 | } | |
68 | ||
69 | if (CONFIG.REDIS.HOSTNAME && CONFIG.REDIS.PORT) { | |
70 | config.socket = { ...config.socket, host: CONFIG.REDIS.HOSTNAME, port: CONFIG.REDIS.PORT } | |
71 | } else { | |
72 | config.socket = { ...config.socket, path: CONFIG.REDIS.SOCKET } | |
73 | } | |
74 | ||
75 | return config | |
19f7b248 RK |
76 | } |
77 | ||
47f6409b C |
78 | getClient () { |
79 | return this.client | |
80 | } | |
81 | ||
82 | getPrefix () { | |
83 | return this.prefix | |
84 | } | |
85 | ||
e5d91a9b C |
86 | isConnected () { |
87 | return this.connected | |
88 | } | |
89 | ||
a1587156 | 90 | /* ************ Forgot password ************ */ |
6e46de09 | 91 | |
ecb4e35f C |
92 | async setResetPasswordVerificationString (userId: number) { |
93 | const generatedString = await generateRandomString(32) | |
94 | ||
95 | await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME) | |
96 | ||
45f1bd72 JL |
97 | return generatedString |
98 | } | |
99 | ||
100 | async setCreatePasswordVerificationString (userId: number) { | |
101 | const generatedString = await generateRandomString(32) | |
102 | ||
103 | await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_CREATE_LIFETIME) | |
104 | ||
ecb4e35f C |
105 | return generatedString |
106 | } | |
107 | ||
e9c5f123 C |
108 | async removePasswordVerificationString (userId: number) { |
109 | return this.removeValue(this.generateResetPasswordKey(userId)) | |
110 | } | |
111 | ||
56f47830 | 112 | async getResetPasswordVerificationString (userId: number) { |
ecb4e35f C |
113 | return this.getValue(this.generateResetPasswordKey(userId)) |
114 | } | |
115 | ||
56f47830 C |
116 | /* ************ Two factor auth request ************ */ |
117 | ||
118 | async setTwoFactorRequest (userId: number, otpSecret: string) { | |
119 | const requestToken = await generateRandomString(32) | |
120 | ||
121 | await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME) | |
122 | ||
123 | return requestToken | |
124 | } | |
125 | ||
126 | async getTwoFactorRequestToken (userId: number, requestToken: string) { | |
127 | return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken)) | |
128 | } | |
129 | ||
a1587156 | 130 | /* ************ Email verification ************ */ |
6e46de09 | 131 | |
d9eaee39 JM |
132 | async setVerifyEmailVerificationString (userId: number) { |
133 | const generatedString = await generateRandomString(32) | |
134 | ||
135 | await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME) | |
136 | ||
137 | return generatedString | |
138 | } | |
139 | ||
140 | async getVerifyEmailLink (userId: number) { | |
141 | return this.getValue(this.generateVerifyEmailKey(userId)) | |
142 | } | |
143 | ||
a1587156 | 144 | /* ************ Contact form per IP ************ */ |
a4101923 C |
145 | |
146 | async setContactFormIp (ip: string) { | |
147 | return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME) | |
148 | } | |
149 | ||
0f6acda1 | 150 | async doesContactFormIpExist (ip: string) { |
a4101923 C |
151 | return this.exists(this.generateContactFormKey(ip)) |
152 | } | |
153 | ||
a1587156 | 154 | /* ************ Views per IP ************ */ |
6e46de09 | 155 | |
51353d9a C |
156 | setIPVideoView (ip: string, videoUUID: string) { |
157 | return this.setValue(this.generateIPViewKey(ip, videoUUID), '1', VIEW_LIFETIME.VIEW) | |
158 | } | |
e4bf7856 | 159 | |
0f6acda1 | 160 | async doesVideoIPViewExist (ip: string, videoUUID: string) { |
51353d9a C |
161 | return this.exists(this.generateIPViewKey(ip, videoUUID)) |
162 | } | |
163 | ||
db48de85 C |
164 | /* ************ Tracker IP block ************ */ |
165 | ||
166 | setTrackerBlockIP (ip: string) { | |
167 | return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME) | |
168 | } | |
169 | ||
170 | async doesTrackerBlockIPExist (ip: string) { | |
171 | return this.exists(this.generateTrackerBlockIPKey(ip)) | |
172 | } | |
173 | ||
51353d9a | 174 | /* ************ Video views stats ************ */ |
6e46de09 | 175 | |
51353d9a C |
176 | addVideoViewStats (videoId: number) { |
177 | const { videoKey, setKey } = this.generateVideoViewStatsKeys({ videoId }) | |
6b616860 C |
178 | |
179 | return Promise.all([ | |
51353d9a C |
180 | this.addToSet(setKey, videoId.toString()), |
181 | this.increment(videoKey) | |
6b616860 C |
182 | ]) |
183 | } | |
184 | ||
51353d9a C |
185 | async getVideoViewsStats (videoId: number, hour: number) { |
186 | const { videoKey } = this.generateVideoViewStatsKeys({ videoId, hour }) | |
6b616860 | 187 | |
51353d9a | 188 | const valueString = await this.getValue(videoKey) |
6040f87d C |
189 | const valueInt = parseInt(valueString, 10) |
190 | ||
191 | if (isNaN(valueInt)) { | |
51353d9a | 192 | logger.error('Cannot get videos views stats of video %d in hour %d: views number is NaN (%s).', videoId, hour, valueString) |
6040f87d C |
193 | return undefined |
194 | } | |
195 | ||
196 | return valueInt | |
6b616860 C |
197 | } |
198 | ||
51353d9a C |
199 | async listVideosViewedForStats (hour: number) { |
200 | const { setKey } = this.generateVideoViewStatsKeys({ hour }) | |
6b616860 | 201 | |
51353d9a | 202 | const stringIds = await this.getSet(setKey) |
6b616860 C |
203 | return stringIds.map(s => parseInt(s, 10)) |
204 | } | |
205 | ||
51353d9a C |
206 | deleteVideoViewsStats (videoId: number, hour: number) { |
207 | const { setKey, videoKey } = this.generateVideoViewStatsKeys({ videoId, hour }) | |
208 | ||
209 | return Promise.all([ | |
210 | this.deleteFromSet(setKey, videoId.toString()), | |
211 | this.deleteKey(videoKey) | |
212 | ]) | |
213 | } | |
214 | ||
215 | /* ************ Local video views buffer ************ */ | |
216 | ||
217 | addLocalVideoView (videoId: number) { | |
218 | const { videoKey, setKey } = this.generateLocalVideoViewsKeys(videoId) | |
6b616860 C |
219 | |
220 | return Promise.all([ | |
51353d9a C |
221 | this.addToSet(setKey, videoId.toString()), |
222 | this.increment(videoKey) | |
223 | ]) | |
224 | } | |
225 | ||
226 | async getLocalVideoViews (videoId: number) { | |
227 | const { videoKey } = this.generateLocalVideoViewsKeys(videoId) | |
228 | ||
229 | const valueString = await this.getValue(videoKey) | |
230 | const valueInt = parseInt(valueString, 10) | |
231 | ||
232 | if (isNaN(valueInt)) { | |
233 | logger.error('Cannot get videos views of video %d: views number is NaN (%s).', videoId, valueString) | |
234 | return undefined | |
235 | } | |
236 | ||
237 | return valueInt | |
238 | } | |
239 | ||
240 | async listLocalVideosViewed () { | |
241 | const { setKey } = this.generateLocalVideoViewsKeys() | |
242 | ||
243 | const stringIds = await this.getSet(setKey) | |
244 | return stringIds.map(s => parseInt(s, 10)) | |
245 | } | |
246 | ||
247 | deleteLocalVideoViews (videoId: number) { | |
248 | const { setKey, videoKey } = this.generateLocalVideoViewsKeys(videoId) | |
249 | ||
250 | return Promise.all([ | |
251 | this.deleteFromSet(setKey, videoId.toString()), | |
252 | this.deleteKey(videoKey) | |
6b616860 C |
253 | ]) |
254 | } | |
255 | ||
b2111066 C |
256 | /* ************ Video viewers stats ************ */ |
257 | ||
258 | getLocalVideoViewer (options: { | |
259 | key?: string | |
260 | // Or | |
261 | ip?: string | |
262 | videoId?: number | |
263 | }) { | |
264 | if (options.key) return this.getObject(options.key) | |
265 | ||
266 | const { viewerKey } = this.generateLocalVideoViewerKeys(options.ip, options.videoId) | |
267 | ||
268 | return this.getObject(viewerKey) | |
269 | } | |
270 | ||
271 | setLocalVideoViewer (ip: string, videoId: number, object: any) { | |
272 | const { setKey, viewerKey } = this.generateLocalVideoViewerKeys(ip, videoId) | |
273 | ||
274 | return Promise.all([ | |
275 | this.addToSet(setKey, viewerKey), | |
276 | this.setObject(viewerKey, object) | |
277 | ]) | |
278 | } | |
279 | ||
280 | listLocalVideoViewerKeys () { | |
281 | const { setKey } = this.generateLocalVideoViewerKeys() | |
282 | ||
283 | return this.getSet(setKey) | |
284 | } | |
285 | ||
286 | deleteLocalVideoViewersKeys (key: string) { | |
287 | const { setKey } = this.generateLocalVideoViewerKeys() | |
288 | ||
289 | return Promise.all([ | |
290 | this.deleteFromSet(setKey, key), | |
291 | this.deleteKey(key) | |
292 | ]) | |
293 | } | |
294 | ||
276250f0 RK |
295 | /* ************ Resumable uploads final responses ************ */ |
296 | ||
297 | setUploadSession (uploadId: string, response?: { video: { id: number, shortUUID: string, uuid: string } }) { | |
298 | return this.setValue( | |
299 | 'resumable-upload-' + uploadId, | |
300 | response | |
301 | ? JSON.stringify(response) | |
302 | : '', | |
303 | RESUMABLE_UPLOAD_SESSION_LIFETIME | |
304 | ) | |
305 | } | |
306 | ||
307 | doesUploadSessionExist (uploadId: string) { | |
308 | return this.exists('resumable-upload-' + uploadId) | |
309 | } | |
310 | ||
311 | async getUploadSession (uploadId: string) { | |
312 | const value = await this.getValue('resumable-upload-' + uploadId) | |
313 | ||
314 | return value | |
315 | ? JSON.parse(value) | |
316 | : '' | |
317 | } | |
318 | ||
020d3d3d C |
319 | deleteUploadSession (uploadId: string) { |
320 | return this.deleteKey('resumable-upload-' + uploadId) | |
321 | } | |
322 | ||
7a4fd56c | 323 | /* ************ AP resource unavailability ************ */ |
f1569117 C |
324 | |
325 | async addAPUnavailability (url: string) { | |
326 | const key = this.generateAPUnavailabilityKey(url) | |
327 | ||
328 | const value = await this.increment(key) | |
329 | await this.setExpiration(key, AP_CLEANER.PERIOD * 2) | |
330 | ||
331 | return value | |
332 | } | |
333 | ||
a1587156 | 334 | /* ************ Keys generation ************ */ |
6e46de09 | 335 | |
b2111066 C |
336 | private generateLocalVideoViewsKeys (videoId: number): { setKey: string, videoKey: string } |
337 | private generateLocalVideoViewsKeys (): { setKey: string } | |
338 | private generateLocalVideoViewsKeys (videoId?: number) { | |
51353d9a | 339 | return { setKey: `local-video-views-buffer`, videoKey: `local-video-views-buffer-${videoId}` } |
6b616860 C |
340 | } |
341 | ||
b2111066 C |
342 | private generateLocalVideoViewerKeys (ip: string, videoId: number): { setKey: string, viewerKey: string } |
343 | private generateLocalVideoViewerKeys (): { setKey: string } | |
344 | private generateLocalVideoViewerKeys (ip?: string, videoId?: number) { | |
345 | return { setKey: `local-video-viewer-stats-keys`, viewerKey: `local-video-viewer-stats-${ip}-${videoId}` } | |
346 | } | |
347 | ||
51353d9a C |
348 | private generateVideoViewStatsKeys (options: { videoId?: number, hour?: number }) { |
349 | const hour = exists(options.hour) | |
350 | ? options.hour | |
351 | : new Date().getHours() | |
6b616860 | 352 | |
51353d9a | 353 | return { setKey: `videos-view-h${hour}`, videoKey: `video-view-${options.videoId}-h${hour}` } |
6b616860 C |
354 | } |
355 | ||
6e46de09 | 356 | private generateResetPasswordKey (userId: number) { |
b40f0575 C |
357 | return 'reset-password-' + userId |
358 | } | |
359 | ||
56f47830 C |
360 | private generateTwoFactorRequestKey (userId: number, token: string) { |
361 | return 'two-factor-request-' + userId + '-' + token | |
362 | } | |
363 | ||
6e46de09 | 364 | private generateVerifyEmailKey (userId: number) { |
d9eaee39 JM |
365 | return 'verify-email-' + userId |
366 | } | |
367 | ||
51353d9a | 368 | private generateIPViewKey (ip: string, videoUUID: string) { |
a4101923 C |
369 | return `views-${videoUUID}-${ip}` |
370 | } | |
371 | ||
db48de85 C |
372 | private generateTrackerBlockIPKey (ip: string) { |
373 | return `tracker-block-ip-${ip}` | |
374 | } | |
375 | ||
a4101923 C |
376 | private generateContactFormKey (ip: string) { |
377 | return 'contact-form-' + ip | |
b40f0575 C |
378 | } |
379 | ||
f1569117 C |
380 | private generateAPUnavailabilityKey (url: string) { |
381 | return 'ap-unavailability-' + sha256(url) | |
382 | } | |
383 | ||
a1587156 | 384 | /* ************ Redis helpers ************ */ |
b40f0575 | 385 | |
ecb4e35f | 386 | private getValue (key: string) { |
e5d91a9b | 387 | return this.client.get(this.prefix + key) |
ecb4e35f C |
388 | } |
389 | ||
6b616860 | 390 | private getSet (key: string) { |
e5d91a9b | 391 | return this.client.sMembers(this.prefix + key) |
6b616860 C |
392 | } |
393 | ||
394 | private addToSet (key: string, value: string) { | |
e5d91a9b | 395 | return this.client.sAdd(this.prefix + key, value) |
6b616860 C |
396 | } |
397 | ||
398 | private deleteFromSet (key: string, value: string) { | |
e5d91a9b | 399 | return this.client.sRem(this.prefix + key, value) |
6b616860 C |
400 | } |
401 | ||
402 | private deleteKey (key: string) { | |
e5d91a9b | 403 | return this.client.del(this.prefix + key) |
6e46de09 C |
404 | } |
405 | ||
b2111066 C |
406 | private async getObject (key: string) { |
407 | const value = await this.getValue(key) | |
408 | if (!value) return null | |
409 | ||
410 | return JSON.parse(value) | |
411 | } | |
412 | ||
56f47830 C |
413 | private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) { |
414 | return this.setValue(key, JSON.stringify(value), expirationMilliseconds) | |
b2111066 C |
415 | } |
416 | ||
417 | private async setValue (key: string, value: string, expirationMilliseconds?: number) { | |
418 | const options = expirationMilliseconds | |
419 | ? { PX: expirationMilliseconds } | |
420 | : {} | |
421 | ||
422 | const result = await this.client.set(this.prefix + key, value, options) | |
ecb4e35f | 423 | |
e5d91a9b | 424 | if (result !== 'OK') throw new Error('Redis set result is not OK.') |
ecb4e35f C |
425 | } |
426 | ||
e9c5f123 | 427 | private removeValue (key: string) { |
e5d91a9b | 428 | return this.client.del(this.prefix + key) |
4195cd2b C |
429 | } |
430 | ||
6b616860 | 431 | private increment (key: string) { |
e5d91a9b | 432 | return this.client.incr(this.prefix + key) |
6b616860 C |
433 | } |
434 | ||
c0d2eac3 C |
435 | private async exists (key: string) { |
436 | const result = await this.client.exists(this.prefix + key) | |
437 | ||
438 | return result !== 0 | |
b5c0e955 C |
439 | } |
440 | ||
f1569117 C |
441 | private setExpiration (key: string, ms: number) { |
442 | return this.client.expire(this.prefix + key, ms / 1000) | |
443 | } | |
444 | ||
ecb4e35f C |
445 | static get Instance () { |
446 | return this.instance || (this.instance = new this()) | |
447 | } | |
448 | } | |
449 | ||
450 | // --------------------------------------------------------------------------- | |
451 | ||
452 | export { | |
453 | Redis | |
454 | } |