More checks on remote downloads/views

This commit is contained in:
Chocobozzz
2026-07-24 14:37:04 +02:00
parent 5971c37a11
commit 6b513bfb74
8 changed files with 111 additions and 24 deletions
@@ -1,7 +1,8 @@
import { Activity, ActivityType } from '@peertube/peertube-models' import { Activity, ActivityType } from '@peertube/peertube-models'
import { VIEW_LIFETIME } from '@server/initializers/constants.js'
import validator from 'validator' import validator from 'validator'
import { isAbuseReasonValid } from '../abuses.js' import { isAbuseReasonValid } from '../abuses.js'
import { exists } from '../misc.js' import { exists, isDateValid } from '../misc.js'
import { sanitizeAndCheckActorObject } from './actor.js' import { sanitizeAndCheckActorObject } from './actor.js'
import { isCacheFileObjectValid } from './cache-file.js' import { isCacheFileObjectValid } from './cache-file.js'
import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc.js' import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc.js'
@@ -11,14 +12,24 @@ import { sanitizeAndCheckVideoCommentObject } from './video-comments.js'
import { sanitizeAndCheckVideoTorrentObject } from './videos.js' import { sanitizeAndCheckVideoTorrentObject } from './videos.js'
import { isWatchActionObjectValid } from './watch-action.js' import { isWatchActionObjectValid } from './watch-action.js'
const collectionTypes = new Set([ 'Collection', 'CollectionPage', 'OrderedCollection', 'OrderedCollectionPage' ])
export function isRootActivityValid (activity: any) { export function isRootActivityValid (activity: any) {
return isCollection(activity) || isActivity(activity) if (!exists(activity)) return false
if (collectionTypes.has(activity.type)) return isCollection(activity)
return isActivity(activity)
} }
function isCollection (activity: any) { function isCollection (activity: any) {
return (activity.type === 'Collection' || activity.type === 'OrderedCollection') && if (!validator.default.isInt(activity.totalItems + '', { min: 0 })) return false
validator.default.isInt(activity.totalItems, { min: 0 }) &&
Array.isArray(activity.items) if (activity.type === 'Collection' || activity.type === 'CollectionPage') {
return Array.isArray(activity.items)
}
return Array.isArray(activity.orderedItems)
} }
function isActivity (activity: any) { function isActivity (activity: any) {
@@ -77,9 +88,22 @@ export function isAnnounceActivityValid (activity: any) {
} }
export function isViewActivityValid (activity: any) { export function isViewActivityValid (activity: any) {
return isBaseActivityValid(activity, 'View') && if (!isBaseActivityValid(activity, 'View')) return false
isActivityPubUrlValid(activity.actor) && if (!isActivityPubUrlValid(activity.actor)) return false
isActivityPubUrlValid(activity.object) if (!isActivityPubUrlValid(activity.object)) return false
if (exists(activity.expires)) {
// isDateValid() expects a string
if (typeof activity.expires !== 'string' || !isDateValid(activity.expires)) return false
const expires = new Date(activity.expires).getTime()
if (isNaN(expires)) return false
// Add a 10x margin to the max expires to allow for some clock drift or custom view lifetime between servers
if (expires > new Date().getTime() + (VIEW_LIFETIME.VIEWER_COUNTER * 10)) return false
}
return true
} }
export function isCreateActivityValid (activity: any) { export function isCreateActivityValid (activity: any) {
+9
View File
@@ -565,10 +565,19 @@ export const VIEW_LIFETIME = {
} }
export let VIEWER_SYNC_REDIS = 30000 // Sync viewer into redis export let VIEWER_SYNC_REDIS = 30000 // Sync viewer into redis
export const MAX_REMOTE_VIEWERS_COUNTER = 1_000_000
export const STATS_LIFETIME = { export const STATS_LIFETIME = {
DOWNLOADS: 60000 * 60 // 1 hour DOWNLOADS: 60000 * 60 // 1 hour
} }
export const REMOTE_DOWNLOADS = {
DEDUPLICATION_LIFETIME: 60000 * 60 * 24, // 24 hours
RATE_LIMIT_LIFETIME: 60000 * 60, // 1 hour
// Max downloads of a specific video we accept from a specific instance in RATE_LIMIT_LIFETIME
MAX_PER_HOST_PER_VIDEO: 500
}
export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100 export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100
export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
@@ -111,7 +111,7 @@ async function processCreateWatchAction (watchAction: WatchActionObject) {
if (watchAction.actionStatus !== 'CompletedActionStatus') return if (watchAction.actionStatus !== 'CompletedActionStatus') return
const video = await VideoModel.loadByUrl(watchAction.object) const video = await VideoModel.loadByUrl(watchAction.object)
if (video.remote) return if (!video || video.remote) return
await sequelizeTypescript.transaction(async t => { await sequelizeTypescript.transaction(async t => {
return createOrUpdateLocalVideoViewer(watchAction, video, t) return createOrUpdateLocalVideoViewer(watchAction, video, t)
@@ -1,12 +1,17 @@
import { ActivityView } from '@peertube/peertube-models' import { ActivityDownload } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VideoStatsManager } from '@server/lib/stats/video-stats-manager.js' import { VideoStatsManager } from '@server/lib/stats/video-stats-manager.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { checkUrlsSameHost } from '../url.js'
import { getOrCreateAPVideo } from '../videos/index.js' import { getOrCreateAPVideo } from '../videos/index.js'
async function processDownloadActivity (options: APProcessorOptions<ActivityView>) { const lTags = loggerTagsFactory('ap', 'download')
const { activity } = options
return processCreateDownload(activity) async function processDownloadActivity (options: APProcessorOptions<ActivityDownload>) {
const { activity, byActor } = options
return processCreateDownload(activity, byActor)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -17,7 +22,7 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processCreateDownload (activity: ActivityView) { async function processCreateDownload (activity: ActivityDownload, byActor: MActorSignature) {
const videoObject = activity.object const videoObject = activity.object
const { video } = await getOrCreateAPVideo({ const { video } = await getOrCreateAPVideo({
@@ -26,5 +31,12 @@ async function processCreateDownload (activity: ActivityView) {
allowRefresh: false allowRefresh: false
}) })
await VideoStatsManager.Instance.processRemoteDownload({ video }) // An instance can tell us one of its users downloaded one of our videos
// But for a remote video, only its origin instance broadcasts download activities
if (!video.isLocal() && !checkUrlsSameHost(byActor.url, video.url)) {
logger.warn('Ignoring download activity %s of %s that does not come from the origin instance.', activity.id, video.url, lTags())
return
}
await VideoStatsManager.Instance.processRemoteDownload({ video, downloadId: activity.id, byActorUrl: byActor.url })
} }
@@ -1,8 +1,10 @@
import { ActivityView } from '@peertube/peertube-models' import { ActivityView } from '@peertube/peertube-models'
import { MAX_REMOTE_VIEWERS_COUNTER } from '@server/initializers/constants.js'
import { VideoStatsManager } from '@server/lib/stats/video-stats-manager.js' import { VideoStatsManager } from '@server/lib/stats/video-stats-manager.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js' import { MActorSignature, MVideo } from '../../../types/models/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js' import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { checkUrlsSameHost } from '../url.js'
import { getOrCreateAPVideo } from '../videos/index.js' import { getOrCreateAPVideo } from '../videos/index.js'
async function processViewActivity (options: APProcessorOptions<ActivityView>) { async function processViewActivity (options: APProcessorOptions<ActivityView>) {
@@ -35,7 +37,7 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
viewerExpires: activity.expires viewerExpires: activity.expires
? new Date(activity.expires) ? new Date(activity.expires)
: undefined, : undefined,
viewerResultCounter: getViewerResultCounter(activity) viewerResultCounter: getViewerResultCounter(activity, video, byActor)
}) })
if (video.isLocal()) { if (video.isLocal()) {
@@ -45,13 +47,16 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
} }
} }
function getViewerResultCounter (activity: ActivityView) { function getViewerResultCounter (activity: ActivityView, video: MVideo, byActor: MActorSignature) {
const result = activity.result const result = activity.result
if (!activity.expires || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined if (!activity.expires || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined
// Only the origin instance of the video can send us a summary of all its viewers
if (!checkUrlsSameHost(byActor.url, video.url)) return undefined
const counter = parseInt(result.userInteractionCount + '') const counter = parseInt(result.userInteractionCount + '')
if (isNaN(counter)) return undefined if (isNaN(counter)) return undefined
return counter return Math.min(Math.max(counter, 0), MAX_REMOTE_VIEWERS_COUNTER)
} }
+34 -3
View File
@@ -1,7 +1,7 @@
import { buildUUID } from '@peertube/peertube-node-utils' import { buildUUID } from '@peertube/peertube-node-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { VIEW_LIFETIME } from '@server/initializers/constants.js' import { REMOTE_DOWNLOADS, VIEW_LIFETIME } from '@server/initializers/constants.js'
import { sendDownload } from '@server/lib/activitypub/send/send-download.js' import { sendDownload } from '@server/lib/activitypub/send/send-download.js'
import { sendView } from '@server/lib/activitypub/send/send-view.js' import { sendView } from '@server/lib/activitypub/send/send-view.js'
import { getCachedVideoDuration } from '@server/lib/video.js' import { getCachedVideoDuration } from '@server/lib/video.js'
@@ -18,6 +18,17 @@ export class VideoStats {
ttl: VIEW_LIFETIME.VIEW ttl: VIEW_LIFETIME.VIEW
}) })
// Remote instances are trusted to report downloads of our videos, so guard against duplicated/flooded activities
private readonly remoteDownloadsCache = new LRUCache<string, boolean>({
max: 50_000,
ttl: REMOTE_DOWNLOADS.DEDUPLICATION_LIFETIME
})
private readonly remoteDownloadsPerHostCache = new LRUCache<string, number>({
max: 10_000,
ttl: REMOTE_DOWNLOADS.RATE_LIMIT_LIFETIME
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Views // Views
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -116,10 +127,30 @@ export class VideoStats {
async addRemoteDownload (options: { async addRemoteDownload (options: {
video: MVideoImmutable video: MVideoImmutable
downloadId: string
byActorUrl: string
}) { }) {
const { video } = options const { video, downloadId, byActorUrl } = options
logger.debug('Adding remote download to video %s.', video.uuid, { ...lTags(video.uuid) }) logger.debug('Adding remote download to video %s.', video.uuid, { downloadId, ...lTags(video.uuid) })
if (this.remoteDownloadsCache.has(downloadId)) {
logger.debug('Ignoring already processed remote download %s.', downloadId, lTags(video.uuid))
return false
}
this.remoteDownloadsCache.set(downloadId, true)
// We can't check a remote instance really downloaded the video, so at least limit how much it can inflate our counter
const rateLimitKey = new URL(byActorUrl).host.toLowerCase() + '-' + video.id
const hostDownloads = (this.remoteDownloadsPerHostCache.get(rateLimitKey) || 0) + 1
this.remoteDownloadsPerHostCache.set(rateLimitKey, hostDownloads, { noUpdateTTL: true })
if (hostDownloads > REMOTE_DOWNLOADS.MAX_PER_HOST_PER_VIDEO) {
logger.warn('Too many remote downloads of video %s sent by %s, ignoring.', video.uuid, byActorUrl, lTags(video.uuid))
return false
}
await this.addDownload(video) await this.addDownload(video)
@@ -149,6 +149,10 @@ export class VideoViewerCounters {
let watchers = this.viewersPerVideo.get(video.id) let watchers = this.viewersPerVideo.get(video.id)
if (!watchers || replaceCurrentViewers) { if (!watchers || replaceCurrentViewers) {
for (const watcher of watchers || []) {
this.idToViewer.delete(watcher.id)
}
watchers = [] watchers = []
this.viewersPerVideo.set(video.id, watchers) this.viewersPerVideo.set(video.id, watchers)
} }
+4 -2
View File
@@ -110,12 +110,14 @@ export class VideoStatsManager {
async processRemoteDownload (options: { async processRemoteDownload (options: {
video: MVideoImmutable video: MVideoImmutable
downloadId: string
byActorUrl: string
}) { }) {
const { video } = options const { video, downloadId, byActorUrl } = options
logger.debug('Processing remote download for %s.', video.url, lTags()) logger.debug('Processing remote download for %s.', video.url, lTags())
await this.videoStats.addRemoteDownload({ video }) await this.videoStats.addRemoteDownload({ video, downloadId, byActorUrl })
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------