5cb3e6a0b8
Breaking: YAML config `ip_view_expiration` is renamed `view_expiration` Breaking: Views are taken into account after 10 seconds instead of 30 seconds (can be changed in YAML config) Purpose of this commit is to get closer to other video platforms where some platforms count views on play (mux, vimeo) or others use a very low delay (instagram, tiktok) We also want to improve the viewer identification, where we no longer use the IP but the `sessionId` generated by the web browser. Multiple viewers behind a NAT can now be able to be identified as independent viewers (this method is also used by vimeo or mux)
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import { Redis as IoRedis, RedisOptions } from 'ioredis'
|
|
import { exists } from '@server/helpers/custom-validators/misc.js'
|
|
import { sha256 } from '@peertube/peertube-node-utils'
|
|
import { logger } from '../helpers/logger.js'
|
|
import { generateRandomString } from '../helpers/utils.js'
|
|
import { CONFIG } from '../initializers/config.js'
|
|
import {
|
|
AP_CLEANER,
|
|
CONTACT_FORM_LIFETIME,
|
|
EMAIL_VERIFY_LIFETIME,
|
|
RESUMABLE_UPLOAD_SESSION_LIFETIME,
|
|
TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
|
|
USER_PASSWORD_CREATE_LIFETIME,
|
|
USER_PASSWORD_RESET_LIFETIME,
|
|
VIEW_LIFETIME,
|
|
WEBSERVER
|
|
} from '../initializers/constants.js'
|
|
|
|
class Redis {
|
|
|
|
private static instance: Redis
|
|
private initialized = false
|
|
private connected = false
|
|
private client: IoRedis
|
|
private prefix: string
|
|
|
|
private constructor () {
|
|
}
|
|
|
|
init () {
|
|
// Already initialized
|
|
if (this.initialized === true) return
|
|
this.initialized = true
|
|
|
|
const redisMode = CONFIG.REDIS.SENTINEL.ENABLED ? 'sentinel' : 'standalone'
|
|
logger.info('Connecting to redis ' + redisMode + '...')
|
|
|
|
this.client = new IoRedis(Redis.getRedisClientOptions('', { enableAutoPipelining: true }))
|
|
this.client.on('error', err => logger.error('Redis failed to connect', { err }))
|
|
this.client.on('connect', () => {
|
|
logger.info('Connected to redis.')
|
|
|
|
this.connected = true
|
|
})
|
|
this.client.on('reconnecting', (ms) => {
|
|
logger.error(`Reconnecting to redis in ${ms}.`)
|
|
})
|
|
this.client.on('close', () => {
|
|
logger.error('Connection to redis has closed.')
|
|
this.connected = false
|
|
})
|
|
|
|
this.client.on('end', () => {
|
|
logger.error('Connection to redis has closed and no more reconnects will be done.')
|
|
})
|
|
|
|
this.prefix = 'redis-' + WEBSERVER.HOST + '-'
|
|
}
|
|
|
|
static getRedisClientOptions (name?: string, options: RedisOptions = {}): RedisOptions {
|
|
const connectionName = [ 'PeerTube', name ].join('')
|
|
const connectTimeout = 20000 // Could be slow since node use sync call to compile PeerTube
|
|
|
|
if (CONFIG.REDIS.SENTINEL.ENABLED) {
|
|
return {
|
|
connectionName,
|
|
connectTimeout,
|
|
enableTLSForSentinelMode: CONFIG.REDIS.SENTINEL.ENABLE_TLS,
|
|
sentinelPassword: CONFIG.REDIS.AUTH,
|
|
sentinels: CONFIG.REDIS.SENTINEL.SENTINELS,
|
|
name: CONFIG.REDIS.SENTINEL.MASTER_NAME,
|
|
...options
|
|
}
|
|
}
|
|
|
|
return {
|
|
connectionName,
|
|
connectTimeout,
|
|
password: CONFIG.REDIS.AUTH,
|
|
db: CONFIG.REDIS.DB,
|
|
host: CONFIG.REDIS.HOSTNAME,
|
|
port: CONFIG.REDIS.PORT,
|
|
path: CONFIG.REDIS.SOCKET,
|
|
showFriendlyErrorStack: true,
|
|
...options
|
|
}
|
|
}
|
|
|
|
getClient () {
|
|
return this.client
|
|
}
|
|
|
|
getPrefix () {
|
|
return this.prefix
|
|
}
|
|
|
|
isConnected () {
|
|
return this.connected
|
|
}
|
|
|
|
/* ************ Forgot password ************ */
|
|
|
|
async setResetPasswordVerificationString (userId: number) {
|
|
const generatedString = await generateRandomString(32)
|
|
|
|
await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME)
|
|
|
|
return generatedString
|
|
}
|
|
|
|
async setCreatePasswordVerificationString (userId: number) {
|
|
const generatedString = await generateRandomString(32)
|
|
|
|
await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_CREATE_LIFETIME)
|
|
|
|
return generatedString
|
|
}
|
|
|
|
async removePasswordVerificationString (userId: number) {
|
|
return this.removeValue(this.generateResetPasswordKey(userId))
|
|
}
|
|
|
|
async getResetPasswordVerificationString (userId: number) {
|
|
return this.getValue(this.generateResetPasswordKey(userId))
|
|
}
|
|
|
|
/* ************ Two factor auth request ************ */
|
|
|
|
async setTwoFactorRequest (userId: number, otpSecret: string) {
|
|
const requestToken = await generateRandomString(32)
|
|
|
|
await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME)
|
|
|
|
return requestToken
|
|
}
|
|
|
|
async getTwoFactorRequestToken (userId: number, requestToken: string) {
|
|
return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken))
|
|
}
|
|
|
|
/* ************ Email verification ************ */
|
|
|
|
async setUserVerifyEmailVerificationString (userId: number) {
|
|
const generatedString = await generateRandomString(32)
|
|
|
|
await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
|
|
|
|
return generatedString
|
|
}
|
|
|
|
async getUserVerifyEmailLink (userId: number) {
|
|
return this.getValue(this.generateUserVerifyEmailKey(userId))
|
|
}
|
|
|
|
async setRegistrationVerifyEmailVerificationString (registrationId: number) {
|
|
const generatedString = await generateRandomString(32)
|
|
|
|
await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME)
|
|
|
|
return generatedString
|
|
}
|
|
|
|
async getRegistrationVerifyEmailLink (registrationId: number) {
|
|
return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId))
|
|
}
|
|
|
|
/* ************ Contact form per IP ************ */
|
|
|
|
async setContactFormIp (ip: string) {
|
|
return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME)
|
|
}
|
|
|
|
async doesContactFormIpExist (ip: string) {
|
|
return this.exists(this.generateContactFormKey(ip))
|
|
}
|
|
|
|
/* ************ Views per IP ************ */
|
|
|
|
setSessionIdVideoView (ip: string, videoUUID: string) {
|
|
return this.setValue(this.generateSessionIdViewKey(ip, videoUUID), '1', VIEW_LIFETIME.VIEW)
|
|
}
|
|
|
|
async doesVideoSessionIdViewExist (sessionId: string, videoUUID: string) {
|
|
return this.exists(this.generateSessionIdViewKey(sessionId, videoUUID))
|
|
}
|
|
|
|
/* ************ Video views stats ************ */
|
|
|
|
addVideoViewStats (videoId: number) {
|
|
const { videoKey, setKey } = this.generateVideoViewStatsKeys({ videoId })
|
|
|
|
return Promise.all([
|
|
this.addToSet(setKey, videoId.toString()),
|
|
this.increment(videoKey)
|
|
])
|
|
}
|
|
|
|
async getVideoViewsStats (videoId: number, hour: number) {
|
|
const { videoKey } = this.generateVideoViewStatsKeys({ videoId, hour })
|
|
|
|
const valueString = await this.getValue(videoKey)
|
|
const valueInt = parseInt(valueString, 10)
|
|
|
|
if (isNaN(valueInt)) {
|
|
logger.error('Cannot get videos views stats of video %d in hour %d: views number is NaN (%s).', videoId, hour, valueString)
|
|
return undefined
|
|
}
|
|
|
|
return valueInt
|
|
}
|
|
|
|
async listVideosViewedForStats (hour: number) {
|
|
const { setKey } = this.generateVideoViewStatsKeys({ hour })
|
|
|
|
const stringIds = await this.getSet(setKey)
|
|
return stringIds.map(s => parseInt(s, 10))
|
|
}
|
|
|
|
deleteVideoViewsStats (videoId: number, hour: number) {
|
|
const { setKey, videoKey } = this.generateVideoViewStatsKeys({ videoId, hour })
|
|
|
|
return Promise.all([
|
|
this.deleteFromSet(setKey, videoId.toString()),
|
|
this.deleteKey(videoKey)
|
|
])
|
|
}
|
|
|
|
/* ************ Local video views buffer ************ */
|
|
|
|
addLocalVideoView (videoId: number) {
|
|
const { videoKey, setKey } = this.generateLocalVideoViewsKeys(videoId)
|
|
|
|
return Promise.all([
|
|
this.addToSet(setKey, videoId.toString()),
|
|
this.increment(videoKey)
|
|
])
|
|
}
|
|
|
|
async getLocalVideoViews (videoId: number) {
|
|
const { videoKey } = this.generateLocalVideoViewsKeys(videoId)
|
|
|
|
const valueString = await this.getValue(videoKey)
|
|
const valueInt = parseInt(valueString, 10)
|
|
|
|
if (isNaN(valueInt)) {
|
|
logger.error('Cannot get videos views of video %d: views number is NaN (%s).', videoId, valueString)
|
|
return undefined
|
|
}
|
|
|
|
return valueInt
|
|
}
|
|
|
|
async listLocalVideosViewed () {
|
|
const { setKey } = this.generateLocalVideoViewsKeys()
|
|
|
|
const stringIds = await this.getSet(setKey)
|
|
return stringIds.map(s => parseInt(s, 10))
|
|
}
|
|
|
|
deleteLocalVideoViews (videoId: number) {
|
|
const { setKey, videoKey } = this.generateLocalVideoViewsKeys(videoId)
|
|
|
|
return Promise.all([
|
|
this.deleteFromSet(setKey, videoId.toString()),
|
|
this.deleteKey(videoKey)
|
|
])
|
|
}
|
|
|
|
/* ************ Video viewers stats ************ */
|
|
|
|
getLocalVideoViewer (options: {
|
|
key?: string
|
|
// Or
|
|
ip?: string
|
|
videoId?: number
|
|
}) {
|
|
if (options.key) return this.getObject(options.key)
|
|
|
|
const { viewerKey } = this.generateLocalVideoViewerKeys(options.ip, options.videoId)
|
|
|
|
return this.getObject(viewerKey)
|
|
}
|
|
|
|
setLocalVideoViewer (sessionId: string, videoId: number, object: any) {
|
|
const { setKey, viewerKey } = this.generateLocalVideoViewerKeys(sessionId, videoId)
|
|
|
|
return Promise.all([
|
|
this.addToSet(setKey, viewerKey),
|
|
this.setObject(viewerKey, object)
|
|
])
|
|
}
|
|
|
|
listLocalVideoViewerKeys () {
|
|
const { setKey } = this.generateLocalVideoViewerKeys()
|
|
|
|
return this.getSet(setKey)
|
|
}
|
|
|
|
deleteLocalVideoViewersKeys (key: string) {
|
|
const { setKey } = this.generateLocalVideoViewerKeys()
|
|
|
|
return Promise.all([
|
|
this.deleteFromSet(setKey, key),
|
|
this.deleteKey(key)
|
|
])
|
|
}
|
|
|
|
/* ************ Resumable uploads final responses ************ */
|
|
|
|
setUploadSession (uploadId: string) {
|
|
return this.setValue('resumable-upload-' + uploadId, '', RESUMABLE_UPLOAD_SESSION_LIFETIME)
|
|
}
|
|
|
|
doesUploadSessionExist (uploadId: string) {
|
|
return this.exists('resumable-upload-' + uploadId)
|
|
}
|
|
|
|
deleteUploadSession (uploadId: string) {
|
|
return this.deleteKey('resumable-upload-' + uploadId)
|
|
}
|
|
|
|
/* ************ AP resource unavailability ************ */
|
|
|
|
async addAPUnavailability (url: string) {
|
|
const key = this.generateAPUnavailabilityKey(url)
|
|
|
|
const value = await this.increment(key)
|
|
await this.setExpiration(key, AP_CLEANER.PERIOD * 2)
|
|
|
|
return value
|
|
}
|
|
|
|
/* ************ Keys generation ************ */
|
|
|
|
private generateLocalVideoViewsKeys (videoId: number): { setKey: string, videoKey: string }
|
|
private generateLocalVideoViewsKeys (): { setKey: string }
|
|
private generateLocalVideoViewsKeys (videoId?: number) {
|
|
return { setKey: `local-video-views-buffer`, videoKey: `local-video-views-buffer-${videoId}` }
|
|
}
|
|
|
|
generateLocalVideoViewerKeys (sessionId: string, videoId: number): { setKey: string, viewerKey: string }
|
|
generateLocalVideoViewerKeys (): { setKey: string }
|
|
generateLocalVideoViewerKeys (sessionId?: string, videoId?: number) {
|
|
return {
|
|
setKey: `local-video-viewer-stats-keys`,
|
|
|
|
viewerKey: sessionId && videoId
|
|
? `local-video-viewer-stats-${sessionId}-${videoId}`
|
|
: undefined
|
|
}
|
|
}
|
|
|
|
private generateVideoViewStatsKeys (options: { videoId?: number, hour?: number }) {
|
|
const hour = exists(options.hour)
|
|
? options.hour
|
|
: new Date().getHours()
|
|
|
|
return { setKey: `videos-view-h${hour}`, videoKey: `video-view-${options.videoId}-h${hour}` }
|
|
}
|
|
|
|
private generateResetPasswordKey (userId: number) {
|
|
return 'reset-password-' + userId
|
|
}
|
|
|
|
private generateTwoFactorRequestKey (userId: number, token: string) {
|
|
return 'two-factor-request-' + userId + '-' + token
|
|
}
|
|
|
|
private generateUserVerifyEmailKey (userId: number) {
|
|
return 'verify-email-user-' + userId
|
|
}
|
|
|
|
private generateRegistrationVerifyEmailKey (registrationId: number) {
|
|
return 'verify-email-registration-' + registrationId
|
|
}
|
|
|
|
generateSessionIdViewKey (sessionId: string, videoUUID: string) {
|
|
return `views-${videoUUID}-${sessionId}`
|
|
}
|
|
|
|
private generateContactFormKey (ip: string) {
|
|
return 'contact-form-' + sha256(CONFIG.SECRETS.PEERTUBE + '-' + ip)
|
|
}
|
|
|
|
private generateAPUnavailabilityKey (url: string) {
|
|
return 'ap-unavailability-' + sha256(url)
|
|
}
|
|
|
|
/* ************ Redis helpers ************ */
|
|
|
|
private getValue (key: string) {
|
|
return this.client.get(this.prefix + key)
|
|
}
|
|
|
|
private getSet (key: string) {
|
|
return this.client.smembers(this.prefix + key)
|
|
}
|
|
|
|
private addToSet (key: string, value: string) {
|
|
return this.client.sadd(this.prefix + key, value)
|
|
}
|
|
|
|
private deleteFromSet (key: string, value: string) {
|
|
return this.client.srem(this.prefix + key, value)
|
|
}
|
|
|
|
private deleteKey (key: string) {
|
|
return this.client.del(this.prefix + key)
|
|
}
|
|
|
|
private async getObject (key: string) {
|
|
const value = await this.getValue(key)
|
|
if (!value) return null
|
|
|
|
return JSON.parse(value)
|
|
}
|
|
|
|
private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
|
|
return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
|
|
}
|
|
|
|
private async setValue (key: string, value: string, expirationMilliseconds?: number) {
|
|
const result = expirationMilliseconds !== undefined
|
|
? await this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds)
|
|
: await this.client.set(this.prefix + key, value)
|
|
|
|
if (result !== 'OK') throw new Error('Redis set result is not OK.')
|
|
}
|
|
|
|
private removeValue (key: string) {
|
|
return this.client.del(this.prefix + key)
|
|
}
|
|
|
|
private increment (key: string) {
|
|
return this.client.incr(this.prefix + key)
|
|
}
|
|
|
|
private async exists (key: string) {
|
|
const result = await this.client.exists(this.prefix + key)
|
|
|
|
return result !== 0
|
|
}
|
|
|
|
private setExpiration (key: string, ms: number) {
|
|
return this.client.expire(this.prefix + key, ms / 1000)
|
|
}
|
|
|
|
static get Instance () {
|
|
return this.instance || (this.instance = new this())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export {
|
|
Redis
|
|
}
|