More robust AP handlers

This commit is contained in:
Chocobozzz
2026-07-24 15:26:33 +02:00
parent 6b513bfb74
commit 63a0b21d5e
10 changed files with 122 additions and 28 deletions
+4
View File
@@ -578,6 +578,10 @@ export const REMOTE_DOWNLOADS = {
MAX_PER_HOST_PER_VIDEO: 500
}
export const REMOTE_VIEWS = {
DEDUPLICATION_LIFETIME: 60000 * 60 * 24 // 24 hours
}
export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100
export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
+1 -1
View File
@@ -30,7 +30,7 @@ export async function crawlCollectionPage<T> (argUrl: string, handler: HandlerFu
if (typeof nextLink === 'string') {
// Don't crawl ourselves
const remoteHost = new URL(nextLink).host
if (remoteHost === WEBSERVER.HOST) continue
if (remoteHost === WEBSERVER.HOST) break
url = nextLink
@@ -1,12 +1,14 @@
import { ActivityAnnounce } from '@peertube/peertube-models'
import { getAPId } from '@server/lib/activitypub/activity.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { Notifier } from '../../notifier/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { checkUrlsSameHost } from '../url.js'
import { maybeGetOrCreateAPVideo } from '../videos/index.js'
async function processAnnounceActivity (options: APProcessorOptions<ActivityAnnounce>) {
@@ -31,6 +33,12 @@ export {
async function processVideoShare (actorAnnouncer: MActorSignature, activity: ActivityAnnounce, notify: boolean) {
const objectUri = getAPId(activity.object)
// The share is identified by the announce URL, so don't let an actor use (and so squat) the announce URL of another host
if (checkUrlsSameHost(activity.id, actorAnnouncer.url) !== true) {
logger.warn('Ignoring announce %s that has not the same host than actor %s.', activity.id, actorAnnouncer.url)
return
}
const { video, created: videoCreated } = await maybeGetOrCreateAPVideo({ videoObject: objectUri })
if (!video) return
@@ -27,6 +27,7 @@ import { createOrUpdateLocalVideoViewer } from '../local-video-viewer.js'
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
import { sendReplyApproval } from '../send/send-reply-approval.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { checkUrlsSameHost, getLocalApproveReplyActivityPubUrl } from '../url.js'
import { resolveThread } from '../video-comments.js'
import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
@@ -50,7 +51,10 @@ async function processCreateActivity (options: APProcessorOptions<ActivityCreate
}
if (activityType === 'WatchAction') {
return retryTransactionWrapper(processCreateWatchAction, activityObject)
// Watch actions are only sent to the inbox of the video origin, so we never have to process a fetched one
if (options.fromFetch) return
return retryTransactionWrapper(processCreateWatchAction, activityObject, byActor)
}
if (activityType === 'CacheFile') {
@@ -107,9 +111,14 @@ async function processCreateCacheFile (
}
}
async function processCreateWatchAction (watchAction: WatchActionObject) {
async function processCreateWatchAction (watchAction: WatchActionObject, byActor: MActorSignature) {
if (watchAction.actionStatus !== 'CompletedActionStatus') return
if (checkUrlsSameHost(watchAction.id, byActor.url) !== true) {
logger.warn('Ignoring watch action %s that has not the same host than actor %s.', watchAction.id, byActor.url)
return
}
const video = await VideoModel.loadByUrl(watchAction.object)
if (!video || video.remote) return
@@ -170,7 +179,7 @@ async function processCreateVideoComment (
}
// New comment or re-sent after an approval -> forward comment
if (comment.heldForReview === false && (created || commentObject.replyApproval)) {
if (comment.heldForReview === false && (created || await consumeReplyApproval(video, comment, commentObject))) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
@@ -181,6 +190,31 @@ async function processCreateVideoComment (
if (created) Notifier.Instance.notifyOnNewComment(comment)
}
// The origin instance re-sends us the comment when we approved the reply
// Ensure it's the approval we sent and that we didn't already process it
async function consumeReplyApproval (
video: MVideoAccountLightBlacklistAllFiles,
comment: MCommentOwnerVideo,
commentObject: VideoCommentObject
) {
if (!commentObject.replyApproval) return false
const expectedApproval = getLocalApproveReplyActivityPubUrl(video, comment)
if (commentObject.replyApproval !== expectedApproval) {
logger.warn('Do not forward comment %s that has an unknown reply approval %s.', comment.url, commentObject.replyApproval)
return false
}
// We already forwarded this comment after this approval
if (comment.replyApproval === expectedApproval) return false
comment.replyApproval = expectedApproval
await comment.save()
return true
}
async function processCreatePlaylist (
activity: ActivityCreate<PlaylistObject | string>,
playlistObject: PlaylistObject,
@@ -12,8 +12,12 @@ export function processReplyApprovalFactory (type: Extract<ActivityType, 'Approv
const { activity, byActor } = options
const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(activity.object)
if (!comment || comment.isDeleted()) {
throw new Error(`Cannot process reply approval on comment ${comment.url} that doesn't exist`)
if (!comment) {
throw new Error(`Cannot process reply approval on comment ${activity.object} that doesn't exist`)
}
if (comment.isDeleted()) {
throw new Error(`Cannot process reply approval on deleted comment ${comment.url}`)
}
if (comment.isLocal() !== true) {
@@ -173,6 +173,11 @@ function processUndoAnnounce (byActor: MActorSignature, announceActivity: Activi
function processUndoFollow (follower: MActorSignature, followActivity: ActivityFollow) {
return sequelizeTypescript.transaction(async t => {
const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
if (!following) {
logger.warn('Unknown actor %s to undo the follow of %s.', followActivity.object, follower.url)
return
}
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
if (!actorFollow) {
@@ -1,4 +1,5 @@
import { ActivityView } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { MAX_REMOTE_VIEWERS_COUNTER } from '@server/initializers/constants.js'
import { VideoStatsManager } from '@server/lib/stats/video-stats-manager.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
@@ -7,6 +8,8 @@ import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { checkUrlsSameHost } from '../url.js'
import { getOrCreateAPVideo } from '../videos/index.js'
const lTags = loggerTagsFactory('ap', 'view')
async function processViewActivity (options: APProcessorOptions<ActivityView>) {
const { activity, byActor } = options
@@ -24,13 +27,19 @@ export {
async function processCreateView (activity: ActivityView, byActor: MActorSignature) {
const videoObject = activity.object
// The view URL is built from the actor URL, so don't accept a view of another host
if (checkUrlsSameHost(activity.id, byActor.url) !== true) {
logger.warn('Ignoring view %s that has not the same host than actor %s.', activity.id, byActor.url, lTags())
return
}
const { video } = await getOrCreateAPVideo({
videoObject,
fetchType: 'with-blacklist',
allowRefresh: false
})
await VideoStatsManager.Instance.processRemoteView({
const isNew = await VideoStatsManager.Instance.processRemoteView({
video,
viewerId: activity.id,
@@ -40,7 +49,8 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
viewerResultCounter: getViewerResultCounter(activity, video, byActor)
})
if (video.isLocal()) {
// Only forward a view we didn't already know, so a replayed activity can't be amplified to all our followers
if (isNew && video.isLocal()) {
// Forward the view but don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video, parallelizable: true })
+15 -1
View File
@@ -53,12 +53,22 @@ export async function processActivities (
const actorsCache: { [ url: string ]: MActorSignature } = {}
for (const activity of activities) {
if (!activity || typeof activity !== 'object') {
logger.warn('Cannot process invalid activity.', { activity })
continue
}
if (!signatureActor && [ 'Create', 'Announce', 'Like' ].includes(activity.type) === false) {
logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
continue
}
let byActor: MActorSignature
// Activities fetched from a remote instance are not validated, so a malformed one must not prevent processing the others
try {
const actorUrl = getAPId(activity.actor)
if (!actorUrl) throw new Error('Cannot find the actor URL of the activity')
// When we fetch remote data, we don't have signature
if (signatureActor && actorUrl !== signatureActor.url) {
@@ -71,8 +81,12 @@ export async function processActivities (
continue
}
const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl)
byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl)
actorsCache[actorUrl] = byActor
} catch (err) {
logger.warn('Cannot get the actor of activity %s, skipping.', activity.id, { err })
continue
}
const activityProcessor = processActivity[activity.type]
if (activityProcessor === undefined) {
+19 -3
View File
@@ -1,7 +1,7 @@
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { REMOTE_DOWNLOADS, VIEW_LIFETIME } from '@server/initializers/constants.js'
import { REMOTE_DOWNLOADS, REMOTE_VIEWS, VIEW_LIFETIME } from '@server/initializers/constants.js'
import { sendDownload } from '@server/lib/activitypub/send/send-download.js'
import { sendView } from '@server/lib/activitypub/send/send-view.js'
import { getCachedVideoDuration } from '@server/lib/video.js'
@@ -18,6 +18,11 @@ export class VideoStats {
ttl: VIEW_LIFETIME.VIEW
})
private readonly remoteViewsCache = new LRUCache<string, boolean>({
max: 50_000,
ttl: REMOTE_VIEWS.DEDUPLICATION_LIFETIME
})
// 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,
@@ -58,10 +63,21 @@ export class VideoStats {
async addRemoteView (options: {
video: MVideo
viewerId: string | null
}) {
const { video } = options
const { video, viewerId } = options
logger.debug('Adding remote view to video %s.', video.uuid, { ...lTags(video.uuid) })
logger.debug('Adding remote view to video %s.', video.uuid, { viewerId, ...lTags(video.uuid) })
if (viewerId) {
if (this.remoteViewsCache.has(viewerId)) {
logger.debug('Ignoring already processed remote view %s.', viewerId, lTags(video.uuid))
return false
}
this.remoteViewsCache.set(viewerId, true)
}
await this.addView(video)
+4 -5
View File
@@ -71,6 +71,7 @@ export class VideoStatsManager {
return { successView, successViewer }
}
// Returns false if we already know this viewer/view, so the caller can avoid processing it again
async processRemoteView (options: {
video: MVideo
viewerId: string | null
@@ -84,16 +85,14 @@ export class VideoStatsManager {
// Viewer
if (viewerExpires) {
if (video.remote === false) {
this.videoViewerCounters.addRemoteViewerOnLocalVideo({ video, viewerId, viewerExpires })
return
return this.videoViewerCounters.addRemoteViewerOnLocalVideo({ video, viewerId, viewerExpires })
}
this.videoViewerCounters.addRemoteViewerOnRemoteVideo({ video, viewerId, viewerExpires, viewerResultCounter })
return
return this.videoViewerCounters.addRemoteViewerOnRemoteVideo({ video, viewerId, viewerExpires, viewerResultCounter })
}
// Just a view
await this.videoStats.addRemoteView({ video })
return this.videoStats.addRemoteView({ video, viewerId })
}
// ---------------------------------------------------------------------------