Init commit
This commit is contained in:
74
server/core/lib/activitypub/activity.ts
Normal file
74
server/core/lib/activitypub/activity.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { doJSONRequest, PeerTubeRequestOptions } from '@server/helpers/requests.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { ActivityObject, ActivityPubActor, ActivityType, APObjectId } from '@peertube/peertube-models'
|
||||
import { buildSignedRequestOptions } from './send/index.js'
|
||||
|
||||
export function getAPId (object: string | { id: string }) {
|
||||
if (typeof object === 'string') return object
|
||||
|
||||
return object.id
|
||||
}
|
||||
|
||||
export function getActivityStreamDuration (duration: number) {
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
|
||||
return 'PT' + duration + 'S'
|
||||
}
|
||||
|
||||
export function getDurationFromActivityStream (duration: string) {
|
||||
return parseInt(duration.replace(/[^\d]+/, ''))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildAvailableActivities (): ActivityType[] {
|
||||
return [
|
||||
'Create',
|
||||
'Update',
|
||||
'Delete',
|
||||
'Follow',
|
||||
'Accept',
|
||||
'Announce',
|
||||
'Undo',
|
||||
'Like',
|
||||
'Reject',
|
||||
'View',
|
||||
'Dislike',
|
||||
'Flag'
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchAP <T> (url: string, moreOptions: PeerTubeRequestOptions = {}) {
|
||||
const options = {
|
||||
activityPub: true,
|
||||
|
||||
httpSignature: CONFIG.FEDERATION.SIGN_FEDERATED_FETCHES
|
||||
? await buildSignedRequestOptions({ hasPayload: false })
|
||||
: undefined,
|
||||
|
||||
...moreOptions
|
||||
}
|
||||
|
||||
return doJSONRequest<T>(url, options)
|
||||
}
|
||||
|
||||
export async function fetchAPObjectIfNeeded <T extends (ActivityObject | ActivityPubActor)> (object: APObjectId) {
|
||||
if (typeof object === 'string') {
|
||||
const { body } = await fetchAP<Exclude<T, string>>(object)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
return object as Exclude<T, string>
|
||||
}
|
||||
|
||||
export async function findLatestAPRedirection (url: string, iteration = 1) {
|
||||
if (iteration > 10) throw new Error('Too much iterations to find final URL ' + url)
|
||||
|
||||
const { headers } = await fetchAP(url, { followRedirect: false })
|
||||
|
||||
if (headers.location) return findLatestAPRedirection(headers.location, iteration + 1)
|
||||
|
||||
return url
|
||||
}
|
||||
8
server/core/lib/activitypub/actors/check-actor.ts
Normal file
8
server/core/lib/activitypub/actors/check-actor.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { MActorHostOnly } from '@server/types/models/index.js'
|
||||
|
||||
export function haveActorsSameRemoteHost (base: MActorHostOnly, other: MActorHostOnly) {
|
||||
if (!base.serverId || !other.serverId) return false
|
||||
if (base.serverId !== other.serverId) return false
|
||||
|
||||
return true
|
||||
}
|
||||
140
server/core/lib/activitypub/actors/get.ts
Normal file
140
server/core/lib/activitypub/actors/get.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import { ActivityPubActor, APObjectId } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders/index.js'
|
||||
import {
|
||||
MActor,
|
||||
MActorAccountChannelId,
|
||||
MActorAccountChannelIdActor,
|
||||
MActorAccountId,
|
||||
MActorFullActor
|
||||
} from '@server/types/models/index.js'
|
||||
import { fetchAPObjectIfNeeded, getAPId } from '../activity.js'
|
||||
import { checkUrlsSameHost } from '../url.js'
|
||||
import { refreshActorIfNeeded } from './refresh.js'
|
||||
import { APActorCreator, fetchRemoteActor } from './shared/index.js'
|
||||
|
||||
// FIXME: use an object for params
|
||||
function getOrCreateAPActor (
|
||||
activityActor: string | ActivityPubActor,
|
||||
fetchType: 'all',
|
||||
recurseIfNeeded?: boolean,
|
||||
updateCollections?: boolean
|
||||
): Promise<MActorFullActor>
|
||||
|
||||
function getOrCreateAPActor (
|
||||
activityActor: string | ActivityPubActor,
|
||||
fetchType?: 'association-ids',
|
||||
recurseIfNeeded?: boolean,
|
||||
updateCollections?: boolean
|
||||
): Promise<MActorAccountChannelId>
|
||||
|
||||
async function getOrCreateAPActor (
|
||||
activityActor: string | ActivityPubActor,
|
||||
fetchType: ActorLoadByUrlType = 'association-ids',
|
||||
recurseIfNeeded = true,
|
||||
updateCollections = false
|
||||
): Promise<MActorFullActor | MActorAccountChannelId> {
|
||||
const actorUrl = getAPId(activityActor)
|
||||
let actor = await loadActorByUrl(actorUrl, fetchType)
|
||||
|
||||
let created = false
|
||||
let accountPlaylistsUrl: string
|
||||
|
||||
// We don't have this actor in our database, fetch it on remote
|
||||
if (!actor) {
|
||||
const { actorObject } = await fetchRemoteActor(actorUrl)
|
||||
if (actorObject === undefined) throw new Error(`Cannot fetch remote actor ${actorUrl}`)
|
||||
|
||||
// actorUrl is just an alias/redirection, so process object id instead
|
||||
if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
|
||||
|
||||
// Create the attributed to actor
|
||||
// In PeerTube a video channel is owned by an account
|
||||
let ownerActor: MActorFullActor
|
||||
if (recurseIfNeeded === true && actorObject.type === 'Group') {
|
||||
ownerActor = await getOrCreateAPOwner(actorObject, actorUrl)
|
||||
}
|
||||
|
||||
const creator = new APActorCreator(actorObject, ownerActor)
|
||||
actor = await retryTransactionWrapper(creator.create.bind(creator))
|
||||
created = true
|
||||
accountPlaylistsUrl = actorObject.playlists
|
||||
}
|
||||
|
||||
if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor
|
||||
if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
|
||||
|
||||
const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType })
|
||||
if (!actorRefreshed) throw new Error(`Actor ${actor.url} does not exist anymore.`)
|
||||
|
||||
await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections)
|
||||
await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
|
||||
|
||||
return actorRefreshed
|
||||
}
|
||||
|
||||
async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorId: string) {
|
||||
const accountAttributedTo = await findOwner(actorId, actorObject.attributedTo, 'Person')
|
||||
if (!accountAttributedTo) {
|
||||
throw new Error(`Cannot find account attributed to video channel ${actorId}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Don't recurse another time
|
||||
const recurseIfNeeded = false
|
||||
return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
|
||||
} catch (err) {
|
||||
logger.error(`Cannot get or create account attributed to video channel ${actorId}`)
|
||||
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') {
|
||||
for (const actorToCheck of arrayify(attributedTo)) {
|
||||
const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck))
|
||||
|
||||
if (!actorObject) {
|
||||
logger.warn(`Unknown attributed to actor ${actorToCheck} for owner ${rootUrl}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) {
|
||||
logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (actorObject.type === type) return actorObject
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
findOwner,
|
||||
getOrCreateAPActor,
|
||||
getOrCreateAPOwner
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, refreshed: boolean, updateCollections: boolean) {
|
||||
if ((created === true || refreshed === true) && updateCollections === true) {
|
||||
const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
}
|
||||
|
||||
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
|
||||
// We created a new account: fetch the playlists
|
||||
if (created === true && actor.Account && accountPlaylistsUrl) {
|
||||
const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists', accountId: actor.Account.id }
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
}
|
||||
112
server/core/lib/activitypub/actors/image.ts
Normal file
112
server/core/lib/activitypub/actors/image.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||
import { MActorImage, MActorImages } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
|
||||
type ImageInfo = {
|
||||
name: string
|
||||
fileUrl: string
|
||||
height: number
|
||||
width: number
|
||||
onDisk?: boolean
|
||||
}
|
||||
|
||||
async function updateActorImages (actor: MActorImages, type: ActorImageType_Type, imagesInfo: ImageInfo[], t: Transaction) {
|
||||
const getAvatarsOrBanners = () => {
|
||||
const result = type === ActorImageType.AVATAR
|
||||
? actor.Avatars
|
||||
: actor.Banners
|
||||
|
||||
return result || []
|
||||
}
|
||||
|
||||
if (imagesInfo.length === 0) {
|
||||
await deleteActorImages(actor, type, t)
|
||||
}
|
||||
|
||||
// Cleanup old images that did not have a width
|
||||
for (const oldImageModel of getAvatarsOrBanners()) {
|
||||
if (oldImageModel.width) continue
|
||||
|
||||
await safeDeleteActorImage(actor, oldImageModel, type, t)
|
||||
}
|
||||
|
||||
for (const imageInfo of imagesInfo) {
|
||||
const oldImageModel = getAvatarsOrBanners().find(i => imageInfo.width && i.width === imageInfo.width)
|
||||
|
||||
if (oldImageModel) {
|
||||
// Don't update the avatar if the file URL did not change
|
||||
if (imageInfo.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
await safeDeleteActorImage(actor, oldImageModel, type, t)
|
||||
}
|
||||
|
||||
const imageModel = await ActorImageModel.create({
|
||||
filename: imageInfo.name,
|
||||
onDisk: imageInfo.onDisk ?? false,
|
||||
fileUrl: imageInfo.fileUrl,
|
||||
height: imageInfo.height,
|
||||
width: imageInfo.width,
|
||||
type,
|
||||
actorId: actor.id
|
||||
}, { transaction: t })
|
||||
|
||||
addActorImage(actor, type, imageModel)
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
async function deleteActorImages (actor: MActorImages, type: ActorImageType_Type, t: Transaction) {
|
||||
try {
|
||||
const association = buildAssociationName(type)
|
||||
|
||||
for (const image of actor[association]) {
|
||||
await image.destroy({ transaction: t })
|
||||
}
|
||||
|
||||
actor[association] = []
|
||||
} catch (err) {
|
||||
logger.error('Cannot remove old image of actor %s.', actor.url, { err })
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType_Type, t: Transaction) {
|
||||
try {
|
||||
await toDelete.destroy({ transaction: t })
|
||||
|
||||
const association = buildAssociationName(type)
|
||||
actor[association] = actor[association].filter(image => image.id !== toDelete.id)
|
||||
} catch (err) {
|
||||
logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
type ImageInfo,
|
||||
|
||||
updateActorImages,
|
||||
deleteActorImages
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function addActorImage (actor: MActorImages, type: ActorImageType_Type, imageModel: MActorImage) {
|
||||
const association = buildAssociationName(type)
|
||||
if (!actor[association]) actor[association] = []
|
||||
|
||||
actor[association].push(imageModel)
|
||||
}
|
||||
|
||||
function buildAssociationName (type: ActorImageType_Type) {
|
||||
return type === ActorImageType.AVATAR
|
||||
? 'Avatars'
|
||||
: 'Banners'
|
||||
}
|
||||
7
server/core/lib/activitypub/actors/index.ts
Normal file
7
server/core/lib/activitypub/actors/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './check-actor.js'
|
||||
export * from './get.js'
|
||||
export * from './image.js'
|
||||
export * from './keys.js'
|
||||
export * from './refresh.js'
|
||||
export * from './updater.js'
|
||||
export * from './webfinger.js'
|
||||
16
server/core/lib/activitypub/actors/keys.ts
Normal file
16
server/core/lib/activitypub/actors/keys.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createPrivateAndPublicKeys } from '@server/helpers/peertube-crypto.js'
|
||||
import { MActor } from '@server/types/models/index.js'
|
||||
|
||||
// Set account keys, this could be long so process after the account creation and do not block the client
|
||||
async function generateAndSaveActorKeys <T extends MActor> (actor: T) {
|
||||
const { publicKey, privateKey } = await createPrivateAndPublicKeys()
|
||||
|
||||
actor.publicKey = publicKey
|
||||
actor.privateKey = privateKey
|
||||
|
||||
return actor.save()
|
||||
}
|
||||
|
||||
export {
|
||||
generateAndSaveActorKeys
|
||||
}
|
||||
83
server/core/lib/activitypub/actors/refresh.ts
Normal file
83
server/core/lib/activitypub/actors/refresh.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CachePromiseFactory } from '@server/helpers/promise-cache.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { ActorLoadByUrlType } from '@server/lib/model-loaders/index.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { MActorAccountChannelId, MActorFull } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { fetchRemoteActor } from './shared/index.js'
|
||||
import { APActorUpdater } from './updater.js'
|
||||
import { getUrlFromWebfinger } from './webfinger.js'
|
||||
|
||||
type RefreshResult<T> = Promise<{ actor: T | MActorFull, refreshed: boolean }>
|
||||
|
||||
type RefreshOptions<T> = {
|
||||
actor: T
|
||||
fetchedType: ActorLoadByUrlType
|
||||
}
|
||||
|
||||
const promiseCache = new CachePromiseFactory(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url)
|
||||
|
||||
function refreshActorIfNeeded<T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult<T> {
|
||||
const actorArg = options.actor
|
||||
if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false })
|
||||
|
||||
return promiseCache.run(options)
|
||||
}
|
||||
|
||||
export {
|
||||
refreshActorIfNeeded
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function doRefresh<T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult<MActorFull> {
|
||||
const { actor: actorArg, fetchedType } = options
|
||||
|
||||
// We need more attributes
|
||||
const actor = fetchedType === 'all'
|
||||
? actorArg as MActorFull
|
||||
: await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'actor', 'refresh', actor.url)
|
||||
|
||||
logger.info('Refreshing actor %s.', actor.url, lTags())
|
||||
|
||||
try {
|
||||
const actorUrl = await getActorUrl(actor)
|
||||
const { actorObject } = await fetchRemoteActor(actorUrl)
|
||||
|
||||
if (actorObject === undefined) {
|
||||
logger.info('Cannot fetch remote actor %s in refresh actor.', actorUrl)
|
||||
return { actor, refreshed: false }
|
||||
}
|
||||
|
||||
const updater = new APActorUpdater(actorObject, actor)
|
||||
await updater.update()
|
||||
|
||||
return { refreshed: true, actor }
|
||||
} catch (err) {
|
||||
const statusCode = (err as PeerTubeRequestError).statusCode
|
||||
|
||||
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.info('Deleting actor %s because there is a 404/410 in refresh actor.', actor.url, lTags())
|
||||
|
||||
actor.Account
|
||||
? await actor.Account.destroy()
|
||||
: await actor.VideoChannel.destroy()
|
||||
|
||||
return { actor: undefined, refreshed: false }
|
||||
}
|
||||
|
||||
logger.info('Cannot refresh actor %s.', actor.url, { err, ...lTags() })
|
||||
return { actor, refreshed: false }
|
||||
}
|
||||
}
|
||||
|
||||
function getActorUrl (actor: MActorFull) {
|
||||
return getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
|
||||
.catch(err => {
|
||||
logger.info('Cannot get actor URL from webfinger, keeping the old one.', { err })
|
||||
return actor.url
|
||||
})
|
||||
}
|
||||
140
server/core/lib/activitypub/actors/shared/creator.ts
Normal file
140
server/core/lib/activitypub/actors/shared/creator.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { ServerModel } from '@server/models/server/server.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
import { MAccount, MActor, MActorFull, MActorFullActor, MActorImages, MChannel, MServer } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { upsertAPPlayerSettings } from '../../player-settings.js'
|
||||
import { updateActorImages } from '../image.js'
|
||||
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js'
|
||||
import { fetchActorFollowsCount } from './url-to-object.js'
|
||||
|
||||
export class APActorCreator {
|
||||
private readonly lTags: LoggerTagsFn
|
||||
|
||||
constructor (
|
||||
private readonly actorObject: ActivityPubActor,
|
||||
private readonly ownerActor?: MActorFullActor
|
||||
) {
|
||||
this.lTags = loggerTagsFactory('ap', 'actor', 'create', this.actorObject.id)
|
||||
}
|
||||
|
||||
async create (): Promise<MActorFull> {
|
||||
logger.debug('Creating remote actor from object', { actorObject: this.actorObject, ...this.lTags() })
|
||||
|
||||
const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject)
|
||||
|
||||
const actor = await sequelizeTypescript.transaction(async t => {
|
||||
const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount)) as MActorFull
|
||||
|
||||
const server = await this.setServer(actorInstance, t)
|
||||
|
||||
const existingActor = await ActorModel.loadByUniqueKeys({
|
||||
preferredUsername: actorInstance.preferredUsername,
|
||||
url: actorInstance.url,
|
||||
serverId: actorInstance.serverId,
|
||||
transaction: t
|
||||
})
|
||||
|
||||
if (existingActor) {
|
||||
logger.debug(`Actor ${existingActor.id} already exists, updating existing one`, this.lTags())
|
||||
|
||||
// Re-init unique keys
|
||||
existingActor.preferredUsername = actorInstance.preferredUsername
|
||||
existingActor.url = actorInstance.url
|
||||
existingActor.serverId = actorInstance.serverId
|
||||
|
||||
await existingActor.save({ transaction: t })
|
||||
|
||||
return ActorModel.loadFull(existingActor.id, t)
|
||||
}
|
||||
|
||||
if (isAccountActor(this.actorObject.type)) {
|
||||
const account = await this.saveAccount(actorInstance, t)
|
||||
await actorInstance.save({ transaction: t })
|
||||
|
||||
actorInstance.Account = Object.assign(account, { Actor: actorInstance })
|
||||
} else if (isChannelActor(this.actorObject.type)) {
|
||||
const channel = await this.saveVideoChannel(actorInstance, t)
|
||||
await actorInstance.save({ transaction: t })
|
||||
|
||||
actorInstance.VideoChannel = Object.assign(channel, { Account: this.ownerActor.Account })
|
||||
}
|
||||
|
||||
await this.setImageIfNeeded(actorInstance, ActorImageType.AVATAR, t)
|
||||
await this.setImageIfNeeded(actorInstance, ActorImageType.BANNER, t)
|
||||
|
||||
actorInstance.Server = server
|
||||
|
||||
return actorInstance
|
||||
})
|
||||
|
||||
if (isChannelActor(actor.type) && typeof this.actorObject.playerSettings === 'string') {
|
||||
const channel = Object.assign(actor.VideoChannel, { Actor: actor })
|
||||
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject: this.actorObject.playerSettings,
|
||||
video: undefined,
|
||||
channel,
|
||||
contextUrl: actor.url
|
||||
})
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
private async setServer (actor: MActor, t: Transaction) {
|
||||
const actorHost = new URL(actor.url).host
|
||||
|
||||
const serverOptions = {
|
||||
where: {
|
||||
host: actorHost
|
||||
},
|
||||
defaults: {
|
||||
host: actorHost
|
||||
},
|
||||
transaction: t
|
||||
}
|
||||
const [ server ] = await ServerModel.findOrCreate(serverOptions)
|
||||
|
||||
// Save our new account in database
|
||||
actor.serverId = server.id
|
||||
|
||||
return server as MServer
|
||||
}
|
||||
|
||||
private async setImageIfNeeded (actor: MActor, type: ActorImageType_Type, t: Transaction) {
|
||||
const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
|
||||
if (imagesInfo.length === 0) return
|
||||
|
||||
return updateActorImages(actor as MActorImages, type, imagesInfo, t)
|
||||
}
|
||||
|
||||
private async saveAccount (actor: MActor, t: Transaction) {
|
||||
const accountCreated = await AccountModel.create({
|
||||
name: getActorDisplayNameFromObject(this.actorObject),
|
||||
description: this.actorObject.summary
|
||||
}, { transaction: t })
|
||||
|
||||
actor.accountId = accountCreated.id
|
||||
|
||||
return accountCreated as MAccount
|
||||
}
|
||||
|
||||
private async saveVideoChannel (actor: MActor, t: Transaction) {
|
||||
const videoChannelCreated = await VideoChannelModel.create({
|
||||
name: getActorDisplayNameFromObject(this.actorObject),
|
||||
description: this.actorObject.summary,
|
||||
support: this.actorObject.support,
|
||||
accountId: this.ownerActor.Account.id
|
||||
}, { transaction: t })
|
||||
|
||||
actor.videoChannelId = videoChannelCreated.id
|
||||
|
||||
return videoChannelCreated as MChannel
|
||||
}
|
||||
}
|
||||
3
server/core/lib/activitypub/actors/shared/index.ts
Normal file
3
server/core/lib/activitypub/actors/shared/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './creator.js'
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './url-to-object.js'
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ActivityIconObject, ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
import { MIMETYPES } from '@server/initializers/constants.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils'
|
||||
|
||||
function getActorAttributesFromObject (
|
||||
actorObject: ActivityPubActor,
|
||||
followersCount: number,
|
||||
followingCount: number
|
||||
): FilteredModelAttributes<ActorModel> {
|
||||
return {
|
||||
type: actorObject.type,
|
||||
preferredUsername: actorObject.preferredUsername,
|
||||
url: actorObject.id,
|
||||
publicKey: actorObject.publicKey.publicKeyPem,
|
||||
privateKey: null,
|
||||
followersCount,
|
||||
followingCount,
|
||||
inboxUrl: actorObject.inbox,
|
||||
outboxUrl: actorObject.outbox,
|
||||
followersUrl: actorObject.followers,
|
||||
followingUrl: actorObject.following,
|
||||
|
||||
sharedInboxUrl: actorObject.endpoints?.sharedInbox
|
||||
? actorObject.endpoints.sharedInbox
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType_Type) {
|
||||
const iconsOrImages = type === ActorImageType.AVATAR
|
||||
? actorObject.icon
|
||||
: actorObject.image
|
||||
|
||||
return normalizeIconOrImage(iconsOrImages)
|
||||
.map(iconOrImage => {
|
||||
const mimetypes = MIMETYPES.IMAGE
|
||||
|
||||
if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined
|
||||
|
||||
let extension: string
|
||||
|
||||
if (iconOrImage.mediaType) {
|
||||
extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType]
|
||||
} else {
|
||||
const tmp = getLowercaseExtension(iconOrImage.url)
|
||||
|
||||
if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
|
||||
}
|
||||
|
||||
if (!extension) return undefined
|
||||
|
||||
return {
|
||||
name: buildUUID() + extension,
|
||||
fileUrl: iconOrImage.url,
|
||||
height: iconOrImage.height,
|
||||
width: iconOrImage.width,
|
||||
type
|
||||
}
|
||||
})
|
||||
.filter(i => !!i)
|
||||
}
|
||||
|
||||
function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
|
||||
return actorObject.name || actorObject.preferredUsername
|
||||
}
|
||||
|
||||
export {
|
||||
getActorAttributesFromObject,
|
||||
getImagesInfoFromObject,
|
||||
getActorDisplayNameFromObject
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] {
|
||||
if (Array.isArray(icon)) return icon
|
||||
if (icon) return [ icon ]
|
||||
|
||||
return []
|
||||
}
|
||||
68
server/core/lib/activitypub/actors/shared/url-to-object.ts
Normal file
68
server/core/lib/activitypub/actors/shared/url-to-object.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ActivityPubActor, ActivityPubOrderedCollection } from '@peertube/peertube-models'
|
||||
import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor.js'
|
||||
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
export async function fetchRemoteActor (
|
||||
actorUrl: string,
|
||||
canRefetchPublicKeyOwner = true
|
||||
): Promise<{ statusCode: number, actorObject: ActivityPubActor }> {
|
||||
logger.info('Fetching remote actor %s.', actorUrl)
|
||||
|
||||
const { body, statusCode } = await fetchAP<ActivityPubActor>(actorUrl)
|
||||
|
||||
if (sanitizeAndCheckActorObject(body) === false) {
|
||||
logger.debug('Remote actor JSON is not valid.', { actorJSON: body })
|
||||
|
||||
// Retry with the public key owner
|
||||
if (canRefetchPublicKeyOwner && hasPublicKeyOwner(actorUrl, body)) {
|
||||
logger.debug('Retrying with public key owner ' + body.publicKey.owner)
|
||||
|
||||
return fetchRemoteActor(body.publicKey.owner, false)
|
||||
}
|
||||
|
||||
return { actorObject: undefined, statusCode }
|
||||
}
|
||||
|
||||
if (checkUrlsSameHost(body.id, actorUrl) !== true) {
|
||||
logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, body.id)
|
||||
return { actorObject: undefined, statusCode }
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
|
||||
actorObject: body
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchActorFollowsCount (actorObject: ActivityPubActor) {
|
||||
let followersCount = 0
|
||||
let followingCount = 0
|
||||
|
||||
if (actorObject.followers) followersCount = await fetchActorTotalItems(actorObject.followers)
|
||||
if (actorObject.following) followingCount = await fetchActorTotalItems(actorObject.following)
|
||||
|
||||
return { followersCount, followingCount }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchActorTotalItems (url: string) {
|
||||
try {
|
||||
const { body } = await fetchAP<ActivityPubOrderedCollection<unknown>>(url)
|
||||
|
||||
return body.totalItems || 0
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote actor count %s.', url, { err })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function hasPublicKeyOwner (actorUrl: string, actor: ActivityPubActor) {
|
||||
return isUrlValid(actor?.publicKey?.owner) && checkUrlsSameHost(actorUrl, actor.publicKey.owner)
|
||||
}
|
||||
113
server/core/lib/activitypub/actors/updater.ts
Normal file
113
server/core/lib/activitypub/actors/updater.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ActivityPubActor, ActorImageType } from '@peertube/peertube-models'
|
||||
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js'
|
||||
import { upsertAPPlayerSettings } from '../player-settings.js'
|
||||
import { getOrCreateAPOwner } from './get.js'
|
||||
import { updateActorImages } from './image.js'
|
||||
import { fetchActorFollowsCount } from './shared/index.js'
|
||||
import { getImagesInfoFromObject } from './shared/object-to-model-attributes.js'
|
||||
|
||||
export class APActorUpdater {
|
||||
private readonly accountOrChannel: MAccount | MChannel
|
||||
|
||||
constructor (
|
||||
private readonly actorObject: ActivityPubActor,
|
||||
private readonly actor: MActorFull
|
||||
) {
|
||||
if (this.actorObject.type === 'Group') this.accountOrChannel = this.actor.VideoChannel
|
||||
else this.accountOrChannel = this.actor.Account
|
||||
}
|
||||
|
||||
async update () {
|
||||
const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
|
||||
const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
|
||||
|
||||
try {
|
||||
await this.updateActorInstance(this.actor, this.actorObject)
|
||||
|
||||
this.accountOrChannel.name = this.actorObject.name || this.actorObject.preferredUsername
|
||||
this.accountOrChannel.description = this.actorObject.summary
|
||||
|
||||
const accountOrChannel = this.accountOrChannel
|
||||
|
||||
if (accountOrChannel instanceof VideoChannelModel) {
|
||||
const channel = accountOrChannel as MChannel
|
||||
|
||||
const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.id)
|
||||
|
||||
channel.accountId = owner.Account.id
|
||||
channel.support = this.actorObject.support
|
||||
|
||||
if (typeof this.actorObject.playerSettings === 'string') {
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject: this.actorObject.playerSettings,
|
||||
video: undefined,
|
||||
channel: Object.assign(channel, { Account: owner.Account, Actor: this.actor }),
|
||||
contextUrl: this.actor.url
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await runInReadCommittedTransaction(async t => {
|
||||
await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
|
||||
await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
|
||||
})
|
||||
|
||||
await runInReadCommittedTransaction(async t => {
|
||||
await this.accountOrChannel.save({ transaction: t })
|
||||
|
||||
if (accountOrChannel instanceof VideoChannelModel) {
|
||||
this.actor.videoChannelId = accountOrChannel.id
|
||||
this.actor.accountId = null
|
||||
} else if (accountOrChannel instanceof AccountModel) {
|
||||
this.actor.accountId = accountOrChannel.id
|
||||
this.actor.videoChannelId = null
|
||||
}
|
||||
|
||||
await this.actor.save({ transaction: t })
|
||||
})
|
||||
|
||||
// Update the following line to template string
|
||||
logger.info(`Remote account ${this.actorObject.id} updated`)
|
||||
} catch (err) {
|
||||
if (this.actor !== undefined) {
|
||||
await resetSequelizeInstance(this.actor)
|
||||
}
|
||||
|
||||
if (this.accountOrChannel !== undefined) {
|
||||
await resetSequelizeInstance(this.accountOrChannel)
|
||||
}
|
||||
|
||||
// This is just a debug because we will retry the insert
|
||||
logger.debug('Cannot update the remote account.', { err })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async updateActorInstance (actorInstance: MActor, actorObject: ActivityPubActor) {
|
||||
const { followersCount, followingCount } = await fetchActorFollowsCount(actorObject)
|
||||
|
||||
actorInstance.type = actorObject.type
|
||||
actorInstance.preferredUsername = actorObject.preferredUsername
|
||||
actorInstance.url = actorObject.id
|
||||
actorInstance.publicKey = actorObject.publicKey.publicKeyPem
|
||||
actorInstance.followersCount = followersCount
|
||||
actorInstance.followingCount = followingCount
|
||||
actorInstance.inboxUrl = actorObject.inbox
|
||||
actorInstance.outboxUrl = actorObject.outbox
|
||||
actorInstance.followersUrl = actorObject.followers
|
||||
actorInstance.followingUrl = actorObject.following
|
||||
|
||||
if (actorObject.published) actorInstance.remoteCreatedAt = new Date(actorObject.published)
|
||||
|
||||
if (actorObject.endpoints?.sharedInbox) {
|
||||
actorInstance.sharedInboxUrl = actorObject.endpoints.sharedInbox
|
||||
}
|
||||
|
||||
// Force actor update
|
||||
actorInstance.changed('updatedAt', true)
|
||||
}
|
||||
}
|
||||
46
server/core/lib/activitypub/actors/webfinger.ts
Normal file
46
server/core/lib/activitypub/actors/webfinger.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { isProdInstance } from '@peertube/peertube-node-utils'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { REQUEST_TIMEOUTS, WEBSERVER } from '@server/initializers/constants.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { MActorFull } from '@server/types/models/index.js'
|
||||
import WebFinger from 'webfinger.js'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const webfinger = new WebFinger({
|
||||
tls_only: isProdInstance(),
|
||||
uri_fallback: false,
|
||||
request_timeout: REQUEST_TIMEOUTS.DEFAULT,
|
||||
allow_private_addresses: CONFIG.FEDERATION.PREVENT_SSRF === false
|
||||
})
|
||||
|
||||
export async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
|
||||
// Handle strings like @toto@example.com
|
||||
const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg
|
||||
|
||||
const [ name, host ] = uri.split('@')
|
||||
let actor: MActorFull
|
||||
|
||||
if (!host || host === WEBSERVER.HOST) {
|
||||
actor = await ActorModel.loadLocalByName(name)
|
||||
} else {
|
||||
actor = await ActorModel.loadByNameAndHost(name, host)
|
||||
}
|
||||
|
||||
if (actor) return actor.url
|
||||
|
||||
return getUrlFromWebfinger(uri)
|
||||
}
|
||||
|
||||
export async function getUrlFromWebfinger (uri: string) {
|
||||
const { object } = await webfinger.lookup(uri)
|
||||
|
||||
if (Array.isArray(object.links) === false) throw new Error('WebFinger links is not an array.')
|
||||
|
||||
const selfLink = object.links.find(l => l.rel === 'self')
|
||||
if (selfLink === undefined || isActivityPubUrlValid(selfLink.href as string) === false) {
|
||||
throw new Error('Cannot find self link or href is not a valid URL.')
|
||||
}
|
||||
|
||||
return selfLink.href as string
|
||||
}
|
||||
107
server/core/lib/activitypub/audience.ts
Normal file
107
server/core/lib/activitypub/audience.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ActivityAudience, VideoPlaylistPrivacy, VideoPlaylistPrivacyType, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
|
||||
import {
|
||||
MAccountAudience,
|
||||
MActorFollowersUrl,
|
||||
MActorUrl,
|
||||
MChannelAudience,
|
||||
MCommentOwner,
|
||||
MVideoAccountLight
|
||||
} from '../../types/models/index.js'
|
||||
|
||||
export function getPublicAudience (actorSender: MActorFollowersUrl) {
|
||||
return _buildPublicAudience({ cc: [ actorSender.followersUrl ] })
|
||||
}
|
||||
|
||||
export function getDirectAudience (targetActor: MActorUrl): ActivityAudience {
|
||||
return { to: [ targetActor.url ], cc: [] }
|
||||
}
|
||||
|
||||
export function getVideoAudience (options: {
|
||||
account: MAccountAudience
|
||||
channel: MChannelAudience
|
||||
privacy: VideoPrivacyType
|
||||
skipPrivacyCheck?: boolean // default false
|
||||
}) {
|
||||
const { account, channel, privacy, skipPrivacyCheck = false } = options
|
||||
|
||||
const followerUrls = [ account.Actor.followersUrl ]
|
||||
|
||||
if (privacy === VideoPrivacy.PUBLIC) {
|
||||
return _buildPublicAudience({
|
||||
to: [ channel.Actor.url ], // fep-1b12
|
||||
cc: followerUrls
|
||||
})
|
||||
}
|
||||
|
||||
if (privacy === VideoPrivacy.UNLISTED) {
|
||||
return _buildUnlistedAudience()
|
||||
}
|
||||
|
||||
if (skipPrivacyCheck) {
|
||||
return _buildPrivateAudience()
|
||||
}
|
||||
|
||||
throw new Error(`Cannot get audience of non public/unlisted video privacy type (${privacy})`)
|
||||
}
|
||||
|
||||
export function getCommentAudience (options: {
|
||||
comment: MCommentOwner
|
||||
video: MVideoAccountLight
|
||||
threadParentComments: MCommentOwner[]
|
||||
}): ActivityAudience {
|
||||
const { comment, video, threadParentComments } = options
|
||||
|
||||
const audience: ActivityAudience = {
|
||||
to: [ getAPPublicValue() ],
|
||||
|
||||
cc: [
|
||||
comment.Account.Actor.followersUrl, // Followers of the commenter
|
||||
video.VideoChannel.Account.Actor.url, // Owner of the video
|
||||
video.VideoChannel.Actor.url // fep-1b12
|
||||
]
|
||||
}
|
||||
|
||||
// Send to actors we reply to
|
||||
for (const parentComment of threadParentComments) {
|
||||
if (parentComment.isDeleted()) continue
|
||||
|
||||
audience.cc.push(parentComment.Account.Actor.url)
|
||||
}
|
||||
|
||||
return audience
|
||||
}
|
||||
|
||||
export function getPlaylistAudience (actorSender: MActorFollowersUrl, privacy: VideoPlaylistPrivacyType) {
|
||||
const followerUrls = [ actorSender.followersUrl ]
|
||||
|
||||
if (privacy === VideoPlaylistPrivacy.PUBLIC) return _buildPublicAudience({ cc: followerUrls })
|
||||
else if (privacy === VideoPlaylistPrivacy.UNLISTED) return _buildUnlistedAudience()
|
||||
|
||||
throw new Error(`Cannot get audience of non public/unlisted playlist privacy type (${privacy})`)
|
||||
}
|
||||
|
||||
export function audiencify<T> (object: T, audience: ActivityAudience) {
|
||||
return { ...audience, ...object }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _buildPublicAudience (options: {
|
||||
to?: string[]
|
||||
cc?: string[]
|
||||
}) {
|
||||
const { to = [], cc = [] } = options
|
||||
|
||||
return { to: [ getAPPublicValue(), ...to ], cc }
|
||||
}
|
||||
|
||||
function _buildUnlistedAudience () {
|
||||
return { to: [], cc: [ getAPPublicValue() ] }
|
||||
}
|
||||
|
||||
function _buildPrivateAudience () {
|
||||
return { to: [], cc: [] }
|
||||
}
|
||||
72
server/core/lib/activitypub/cache-file.ts
Normal file
72
server/core/lib/activitypub/cache-file.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js'
|
||||
|
||||
export async function createOrUpdateCacheFile (
|
||||
cacheFileObject: CacheFileObject,
|
||||
video: MVideoWithAllFiles,
|
||||
byActor: MActorId,
|
||||
t: Transaction
|
||||
) {
|
||||
const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
|
||||
|
||||
if (redundancyModel) {
|
||||
return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
|
||||
}
|
||||
|
||||
return createCacheFile(cacheFileObject, video, byActor, t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
|
||||
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
|
||||
if (!attributes) return
|
||||
|
||||
return VideoRedundancyModel.create(attributes, { transaction: t })
|
||||
}
|
||||
|
||||
function updateCacheFile (
|
||||
cacheFileObject: CacheFileObject,
|
||||
redundancyModel: MVideoRedundancy,
|
||||
video: MVideoWithAllFiles,
|
||||
byActor: MActorId,
|
||||
t: Transaction
|
||||
) {
|
||||
if (redundancyModel.actorId !== byActor.id) {
|
||||
throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.')
|
||||
}
|
||||
|
||||
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
|
||||
if (!attributes) return
|
||||
|
||||
redundancyModel.expiresOn = attributes.expiresOn
|
||||
redundancyModel.fileUrl = attributes.fileUrl
|
||||
|
||||
return redundancyModel.save({ transaction: t })
|
||||
}
|
||||
|
||||
function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
|
||||
if (cacheFileObject.url.mediaType !== 'application/x-mpegURL') {
|
||||
logger.debug('Do not create remote cache file of non application/x-mpegURL media type', { cacheFileObject })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = cacheFileObject.url
|
||||
|
||||
const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
|
||||
if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
|
||||
|
||||
return {
|
||||
expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
|
||||
url: cacheFileObject.id,
|
||||
fileUrl: url.href,
|
||||
strategy: null,
|
||||
videoStreamingPlaylistId: playlist.id,
|
||||
actorId: byActor.id
|
||||
}
|
||||
}
|
||||
66
server/core/lib/activitypub/collection.ts
Normal file
66
server/core/lib/activitypub/collection.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import Bluebird from 'bluebird'
|
||||
import validator from 'validator'
|
||||
import { pageToStartAndCount } from '@server/helpers/core-utils.js'
|
||||
import { ACTIVITY_PUB } from '@server/initializers/constants.js'
|
||||
import { ResultList } from '@peertube/peertube-models'
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
|
||||
type ActivityPubCollectionPaginationHandler = (start: number, count: number) => Bluebird<ResultList<any>> | Promise<ResultList<any>>
|
||||
|
||||
export async function activityPubCollectionPagination (
|
||||
baseUrl: string,
|
||||
handler: ActivityPubCollectionPaginationHandler,
|
||||
page?: any,
|
||||
size = ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE
|
||||
) {
|
||||
if (!page || !validator.default.isInt(page)) {
|
||||
// We just display the first page URL, we only need the total items
|
||||
const result = await handler(0, 1)
|
||||
|
||||
return {
|
||||
id: baseUrl,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: result.total,
|
||||
first: result.data.length === 0
|
||||
? undefined
|
||||
: baseUrl + '?page=1'
|
||||
}
|
||||
}
|
||||
|
||||
const { start, count } = pageToStartAndCount(page, size)
|
||||
const result = await handler(start, count)
|
||||
|
||||
let next: string | undefined
|
||||
let prev: string | undefined
|
||||
|
||||
// Assert page is a number
|
||||
page = forceNumber(page)
|
||||
|
||||
// There are more results
|
||||
if (result.total > page * size) {
|
||||
next = baseUrl + '?page=' + (page + 1)
|
||||
}
|
||||
|
||||
if (page > 1) {
|
||||
prev = baseUrl + '?page=' + (page - 1)
|
||||
}
|
||||
|
||||
return {
|
||||
id: baseUrl + '?page=' + page,
|
||||
type: 'OrderedCollectionPage',
|
||||
prev,
|
||||
next,
|
||||
partOf: baseUrl,
|
||||
orderedItems: result.data,
|
||||
totalItems: result.total
|
||||
}
|
||||
}
|
||||
|
||||
export function activityPubCollection <T> (baseUrl: string, items: T[]) {
|
||||
return {
|
||||
id: baseUrl,
|
||||
type: 'OrderedCollection' as 'OrderedCollection',
|
||||
totalItems: items.length,
|
||||
orderedItems: items
|
||||
}
|
||||
}
|
||||
10
server/core/lib/activitypub/context.ts
Normal file
10
server/core/lib/activitypub/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Hooks } from '../plugins/hooks.js'
|
||||
|
||||
export function getContextFilter <T> () {
|
||||
return (contextData: T) => {
|
||||
return Hooks.wrapObject(
|
||||
contextData,
|
||||
'filter:activity-pub.activity.context.build.result'
|
||||
)
|
||||
}
|
||||
}
|
||||
55
server/core/lib/activitypub/crawl.ts
Normal file
55
server/core/lib/activitypub/crawl.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Bluebird from 'bluebird'
|
||||
import { URL } from 'url'
|
||||
import { ActivityPubOrderedCollection } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants.js'
|
||||
import { fetchAP } from './activity.js'
|
||||
|
||||
type HandlerFunction<T> = (items: T[]) => Promise<any> | Bluebird<any>
|
||||
type CleanerFunction = (startedDate: Date) => Promise<any>
|
||||
|
||||
export async function crawlCollectionPage<T> (argUrl: string, handler: HandlerFunction<T>, cleaner?: CleanerFunction) {
|
||||
let url = argUrl
|
||||
|
||||
logger.info('Crawling ActivityPub data on %s.', url)
|
||||
|
||||
const startDate = new Date()
|
||||
|
||||
const response = await fetchAP<ActivityPubOrderedCollection<T>>(url)
|
||||
const firstBody = response.body
|
||||
|
||||
const limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT
|
||||
let i = 0
|
||||
let nextLink = firstBody.first
|
||||
while (nextLink && i < limit) {
|
||||
i++
|
||||
|
||||
let body: any
|
||||
|
||||
if (typeof nextLink === 'string') {
|
||||
// Don't crawl ourselves
|
||||
const remoteHost = new URL(nextLink).host
|
||||
if (remoteHost === WEBSERVER.HOST) continue
|
||||
|
||||
url = nextLink
|
||||
|
||||
const res = await fetchAP<ActivityPubOrderedCollection<T>>(url)
|
||||
body = res.body
|
||||
} else {
|
||||
// nextLink is already the object we want
|
||||
body = nextLink
|
||||
}
|
||||
|
||||
nextLink = body.next
|
||||
|
||||
if (Array.isArray(body.orderedItems)) {
|
||||
const items = body.orderedItems
|
||||
logger.info('Processing %i ActivityPub items for %s.', items.length, url)
|
||||
|
||||
await handler(items)
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaner) await retryTransactionWrapper(cleaner, startDate)
|
||||
}
|
||||
51
server/core/lib/activitypub/follow.ts
Normal file
51
server/core/lib/activitypub/follow.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { SERVER_ACTOR_NAME } from '../../initializers/constants.js'
|
||||
import { ServerModel } from '../../models/server/server.js'
|
||||
import { MActorFollowActors } from '../../types/models/index.js'
|
||||
import { JobQueue } from '../job-queue/index.js'
|
||||
|
||||
async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transaction?: Transaction) {
|
||||
if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return
|
||||
|
||||
const follower = actorFollow.ActorFollower
|
||||
|
||||
if (follower.type === 'Application' && follower.preferredUsername === SERVER_ACTOR_NAME) {
|
||||
logger.info('Auto follow back %s.', follower.url)
|
||||
|
||||
const me = await getServerActor()
|
||||
|
||||
const server = await ServerModel.load(follower.serverId, transaction)
|
||||
const host = server.host
|
||||
|
||||
const payload = {
|
||||
host,
|
||||
name: SERVER_ACTOR_NAME,
|
||||
followerActorId: me.id,
|
||||
isAutoFollow: true
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
|
||||
}
|
||||
}
|
||||
|
||||
// If we only have an host, use a default account handle
|
||||
function getRemoteNameAndHost (handleOrHost: string) {
|
||||
let name = SERVER_ACTOR_NAME
|
||||
let host = handleOrHost
|
||||
|
||||
const splitted = handleOrHost.split('@')
|
||||
if (splitted.length === 2) {
|
||||
name = splitted[0]
|
||||
host = splitted[1]
|
||||
}
|
||||
|
||||
return { name, host }
|
||||
}
|
||||
|
||||
export {
|
||||
autoFollowBackIfNeeded,
|
||||
getRemoteNameAndHost
|
||||
}
|
||||
41
server/core/lib/activitypub/inbox-manager.ts
Normal file
41
server/core/lib/activitypub/inbox-manager.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import PQueue from 'p-queue'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants.js'
|
||||
import { MActorDefault, MActorSignature } from '@server/types/models/index.js'
|
||||
import { Activity } from '@peertube/peertube-models'
|
||||
import { StatsManager } from '../stat-manager.js'
|
||||
import { processActivities } from './process/index.js'
|
||||
|
||||
export class InboxManager {
|
||||
|
||||
private static instance: InboxManager
|
||||
private readonly inboxQueue: PQueue
|
||||
|
||||
private constructor () {
|
||||
this.inboxQueue = new PQueue({ concurrency: 1 })
|
||||
|
||||
setInterval(() => {
|
||||
StatsManager.Instance.updateInboxWaiting(this.getActivityPubMessagesWaiting())
|
||||
}, SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS)
|
||||
}
|
||||
|
||||
addInboxMessage (param: {
|
||||
activities: Activity[]
|
||||
signatureActor?: MActorSignature
|
||||
inboxActor?: MActorDefault
|
||||
}) {
|
||||
this.inboxQueue.add(() => {
|
||||
const options = { signatureActor: param.signatureActor, inboxActor: param.inboxActor }
|
||||
|
||||
return processActivities(param.activities, options)
|
||||
}).catch(err => logger.error('Error with inbox queue.', { err }))
|
||||
}
|
||||
|
||||
getActivityPubMessagesWaiting () {
|
||||
return this.inboxQueue.size + this.inboxQueue.pending
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
43
server/core/lib/activitypub/local-video-viewer.ts
Normal file
43
server/core/lib/activitypub/local-video-viewer.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
||||
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { WatchActionObject } from '@peertube/peertube-models'
|
||||
import { getDurationFromActivityStream } from './activity.js'
|
||||
|
||||
async function createOrUpdateLocalVideoViewer (watchAction: WatchActionObject, video: MVideo, t: Transaction) {
|
||||
const stats = await LocalVideoViewerModel.loadByUrl(watchAction.id)
|
||||
if (stats) await stats.destroy({ transaction: t })
|
||||
|
||||
const localVideoViewer = await LocalVideoViewerModel.create({
|
||||
url: watchAction.id,
|
||||
uuid: watchAction.uuid,
|
||||
|
||||
watchTime: getDurationFromActivityStream(watchAction.duration),
|
||||
|
||||
startDate: new Date(watchAction.startTime),
|
||||
endDate: new Date(watchAction.endTime),
|
||||
|
||||
country: watchAction.location?.addressCountry || null,
|
||||
subdivisionName: watchAction.location?.addressRegion || null,
|
||||
|
||||
videoId: video.id
|
||||
}, { transaction: t })
|
||||
|
||||
await LocalVideoViewerWatchSectionModel.bulkCreateSections({
|
||||
localVideoViewerId: localVideoViewer.id,
|
||||
|
||||
watchSections: watchAction.watchSections.map(s => ({
|
||||
start: s.startTimestamp,
|
||||
end: s.endTimestamp
|
||||
})),
|
||||
|
||||
transaction: t
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createOrUpdateLocalVideoViewer
|
||||
}
|
||||
24
server/core/lib/activitypub/outbox.ts
Normal file
24
server/core/lib/activitypub/outbox.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { JobQueue } from '../job-queue/index.js'
|
||||
|
||||
async function addFetchOutboxJob (actor: Pick<ActorModel, 'id' | 'outboxUrl'>) {
|
||||
// Don't fetch ourselves
|
||||
const serverActor = await getServerActor()
|
||||
if (serverActor.id === actor.id) {
|
||||
logger.error('Cannot fetch our own outbox!')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = {
|
||||
uri: actor.outboxUrl,
|
||||
type: 'activity' as 'activity'
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
|
||||
export {
|
||||
addFetchOutboxJob
|
||||
}
|
||||
44
server/core/lib/activitypub/player-settings.ts
Normal file
44
server/core/lib/activitypub/player-settings.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { PlayerSettingsObject } from '@peertube/peertube-models'
|
||||
import { sanitizeAndCheckPlayerSettingsObject } from '@server/helpers/custom-validators/activitypub/player-settings.js'
|
||||
import { MChannelDefault, MVideoIdUrl } from '../../types/models/index.js'
|
||||
import { upsertPlayerSettings } from '../player-settings.js'
|
||||
import { fetchAPObjectIfNeeded } from './activity.js'
|
||||
import { checkUrlsSameHost } from './url.js'
|
||||
|
||||
export async function upsertAPPlayerSettings (options: {
|
||||
video: MVideoIdUrl
|
||||
channel: MChannelDefault
|
||||
settingsObject: PlayerSettingsObject | string
|
||||
contextUrl: string
|
||||
}) {
|
||||
const { video, channel, contextUrl } = options
|
||||
|
||||
if (!video && !channel) throw new Error('Video or channel must be specified')
|
||||
|
||||
const settingsObject = await fetchAPObjectIfNeeded<PlayerSettingsObject>(options.settingsObject)
|
||||
|
||||
if (!sanitizeAndCheckPlayerSettingsObject(settingsObject, video ? 'video' : 'channel')) {
|
||||
throw new Error(`Player settings ${settingsObject.id} object is not valid`)
|
||||
}
|
||||
|
||||
if (!checkUrlsSameHost(settingsObject.id, contextUrl)) {
|
||||
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
|
||||
}
|
||||
|
||||
const objectUrl = video?.url || channel?.Actor.url
|
||||
if (!checkUrlsSameHost(settingsObject.id, objectUrl)) {
|
||||
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
|
||||
}
|
||||
|
||||
await upsertPlayerSettings({ user: null, settings: getPlayerSettingsAttributesFromObject(settingsObject), channel, video })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPlayerSettingsAttributesFromObject (settingsObject: PlayerSettingsObject) {
|
||||
return {
|
||||
theme: settingsObject.theme
|
||||
}
|
||||
}
|
||||
189
server/core/lib/activitypub/playlists/create-update.ts
Normal file
189
server/core/lib/activitypub/playlists/create-update.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { HttpStatusCode, PlaylistObject } from '@peertube/peertube-models'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail.js'
|
||||
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import { MAccountHost, MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js'
|
||||
import Bluebird from 'bluebird'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/index.js'
|
||||
import { crawlCollectionPage } from '../crawl.js'
|
||||
import { checkUrlsSameHost } from '../url.js'
|
||||
import { getOrCreateAPVideo } from '../videos/index.js'
|
||||
import {
|
||||
fetchRemotePlaylistElement,
|
||||
fetchRemoteVideoPlaylist,
|
||||
playlistElementObjectToDBAttributes,
|
||||
playlistObjectToDBAttributes
|
||||
} from './shared/index.js'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist')
|
||||
|
||||
export async function createAccountPlaylists (playlistUrls: string[], account: MAccountHost) {
|
||||
logger.info(
|
||||
`Creating or updating ${playlistUrls.length} playlists for account ${account.Actor.preferredUsername}`,
|
||||
lTags()
|
||||
)
|
||||
|
||||
await Bluebird.map(playlistUrls, async playlistUrl => {
|
||||
if (!checkUrlsSameHost(playlistUrl, account.Actor.url)) {
|
||||
logger.warn(`Playlist ${playlistUrl} is not on the same host as owner account ${account.Actor.url}`, lTags(playlistUrl))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
|
||||
if (exists === true) return
|
||||
|
||||
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
|
||||
|
||||
if (playlistObject === undefined) {
|
||||
throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
|
||||
}
|
||||
|
||||
return createOrUpdateVideoPlaylist({ playlistObject, contextUrl: playlistUrl })
|
||||
} catch (err) {
|
||||
logger.warn(`Cannot create or update playlist ${playlistUrl}`, { err, ...lTags(playlistUrl) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
export async function createOrUpdateVideoPlaylist (options: {
|
||||
playlistObject: PlaylistObject
|
||||
// Which is the context where we retrieved the playlist
|
||||
// Can be the actor that signed the activity URL or the playlist URL we fetched
|
||||
contextUrl: string
|
||||
to?: string[]
|
||||
}) {
|
||||
const { playlistObject, contextUrl, to } = options
|
||||
|
||||
if (!checkUrlsSameHost(playlistObject.id, contextUrl)) {
|
||||
throw new Error(`Playlist ${playlistObject.id} is not on the same host as context URL ${contextUrl}`)
|
||||
}
|
||||
|
||||
logger.debug(`Creating or updating playlist ${playlistObject.id}`, lTags(playlistObject.id))
|
||||
|
||||
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
|
||||
|
||||
const channel = await getRemotePlaylistChannel(playlistObject)
|
||||
playlistAttributes.videoChannelId = channel.id
|
||||
playlistAttributes.ownerAccountId = channel.accountId
|
||||
|
||||
const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
|
||||
|
||||
const playlistElementUrls = await fetchElementUrls(playlistObject)
|
||||
|
||||
// Refetch playlist from DB since elements fetching could be long in time
|
||||
const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null)
|
||||
|
||||
await updatePlaylistThumbnail(playlistObject, playlist)
|
||||
|
||||
const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
|
||||
playlist.setVideosLength(elementsLength)
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getRemotePlaylistChannel (playlistObject: PlaylistObject) {
|
||||
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
|
||||
throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
|
||||
}
|
||||
|
||||
const channelUrl = getAPId(playlistObject.attributedTo[0])
|
||||
if (!checkUrlsSameHost(channelUrl, playlistObject.id)) {
|
||||
throw new Error(`Playlist ${playlistObject.id} and "attributedTo" channel ${channelUrl} are not on the same host`)
|
||||
}
|
||||
|
||||
const actor = await getOrCreateAPActor(channelUrl, 'all')
|
||||
|
||||
if (!actor.VideoChannel) {
|
||||
throw new Error(`Playlist ${playlistObject.id} "attributedTo" is not a video channel.`)
|
||||
}
|
||||
|
||||
return actor.VideoChannel
|
||||
}
|
||||
|
||||
async function fetchElementUrls (playlistObject: PlaylistObject) {
|
||||
let accItems: string[] = []
|
||||
await crawlCollectionPage<string>(playlistObject.id, items => {
|
||||
accItems = accItems.concat(items)
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
return accItems.filter(i => isActivityPubUrlValid(i))
|
||||
}
|
||||
|
||||
async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
|
||||
if (playlistObject.icon) {
|
||||
let thumbnailModel: MThumbnail
|
||||
|
||||
try {
|
||||
thumbnailModel = await updateRemotePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
|
||||
await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
|
||||
|
||||
if (thumbnailModel) await thumbnailModel.removeThumbnail()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Playlist does not have an icon, destroy existing one
|
||||
if (playlist.hasThumbnail()) {
|
||||
await playlist.Thumbnail.destroy()
|
||||
playlist.Thumbnail = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
|
||||
const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist)
|
||||
|
||||
await retryTransactionWrapper(() =>
|
||||
sequelizeTypescript.transaction(async t => {
|
||||
await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
|
||||
|
||||
for (const element of elementsToCreate) {
|
||||
await VideoPlaylistElementModel.create(element, { transaction: t })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
|
||||
|
||||
return elementsToCreate.length
|
||||
}
|
||||
|
||||
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
|
||||
const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
|
||||
|
||||
await Bluebird.map(elementUrls, async elementUrl => {
|
||||
try {
|
||||
const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
|
||||
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video-and-blacklist' })
|
||||
|
||||
elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
|
||||
} catch (err) {
|
||||
const logLevel = (err as PeerTubeRequestError).statusCode === HttpStatusCode.UNAUTHORIZED_401
|
||||
? 'debug'
|
||||
: 'warn'
|
||||
|
||||
logger.log(logLevel, `Cannot add playlist element ${elementUrl}`, { err, ...lTags(playlist.uuid, playlist.url) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
|
||||
return elementsToCreate
|
||||
}
|
||||
26
server/core/lib/activitypub/playlists/get.ts
Normal file
26
server/core/lib/activitypub/playlists/get.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { MVideoPlaylistFullSummary } from '@server/types/models/index.js'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { createOrUpdateVideoPlaylist } from './create-update.js'
|
||||
import { scheduleRefreshIfNeeded } from './refresh.js'
|
||||
import { fetchRemoteVideoPlaylist } from './shared/index.js'
|
||||
|
||||
export async function getOrCreateAPVideoPlaylist (playlistUrl: string): Promise<MVideoPlaylistFullSummary> {
|
||||
const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
|
||||
|
||||
if (playlistFromDatabase) {
|
||||
scheduleRefreshIfNeeded(playlistFromDatabase)
|
||||
|
||||
return playlistFromDatabase
|
||||
}
|
||||
|
||||
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
|
||||
if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
|
||||
|
||||
// playlistUrl is just an alias/redirection, so process object id instead
|
||||
if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(getAPId(playlistObject))
|
||||
|
||||
const playlistCreated = await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: playlistUrl })
|
||||
|
||||
return playlistCreated
|
||||
}
|
||||
3
server/core/lib/activitypub/playlists/index.ts
Normal file
3
server/core/lib/activitypub/playlists/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './get.js'
|
||||
export * from './create-update.js'
|
||||
export * from './refresh.js'
|
||||
55
server/core/lib/activitypub/playlists/refresh.ts
Normal file
55
server/core/lib/activitypub/playlists/refresh.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { MVideoPlaylist, MVideoPlaylistOwnerDefault } from '@server/types/models/index.js'
|
||||
import { createOrUpdateVideoPlaylist } from './create-update.js'
|
||||
import { fetchRemoteVideoPlaylist } from './shared/index.js'
|
||||
|
||||
function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
|
||||
if (!playlist.isOutdated()) return
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
|
||||
}
|
||||
|
||||
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwnerDefault): Promise<MVideoPlaylistOwnerDefault> {
|
||||
if (!videoPlaylist.isOutdated()) return videoPlaylist
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url)
|
||||
|
||||
logger.info('Refreshing playlist %s.', videoPlaylist.url, lTags())
|
||||
|
||||
try {
|
||||
const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
|
||||
|
||||
if (playlistObject === undefined) {
|
||||
logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags())
|
||||
|
||||
await videoPlaylist.setAsRefreshed()
|
||||
return videoPlaylist
|
||||
}
|
||||
|
||||
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: videoPlaylist.url })
|
||||
|
||||
return videoPlaylist
|
||||
} catch (err) {
|
||||
const statusCode = (err as PeerTubeRequestError).statusCode
|
||||
|
||||
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.info('Cannot refresh not existing playlist (404/410 error code) %s. Deleting it.', videoPlaylist.url, lTags())
|
||||
|
||||
await videoPlaylist.destroy()
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() })
|
||||
|
||||
await videoPlaylist.setAsRefreshed()
|
||||
return videoPlaylist
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
refreshVideoPlaylistIfNeeded,
|
||||
scheduleRefreshIfNeeded
|
||||
}
|
||||
2
server/core/lib/activitypub/playlists/shared/index.ts
Normal file
2
server/core/lib/activitypub/playlists/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './url-to-object.js'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
|
||||
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { MVideoId, MVideoPlaylistId } from '@server/types/models/index.js'
|
||||
|
||||
export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
|
||||
const privacy = hasAPPublic(to)
|
||||
? VideoPlaylistPrivacy.PUBLIC
|
||||
: VideoPlaylistPrivacy.UNLISTED
|
||||
|
||||
return {
|
||||
name: playlistObject.name,
|
||||
description: playlistObject.content,
|
||||
privacy,
|
||||
url: playlistObject.id,
|
||||
uuid: playlistObject.uuid,
|
||||
videoChannelPosition: playlistObject.videoChannelPosition,
|
||||
videoChannelId: null,
|
||||
ownerAccountId: null,
|
||||
createdAt: new Date(playlistObject.published),
|
||||
updatedAt: new Date(playlistObject.updated)
|
||||
} as AttributesOnly<VideoPlaylistModel>
|
||||
}
|
||||
|
||||
export function playlistElementObjectToDBAttributes (
|
||||
elementObject: PlaylistElementObject,
|
||||
videoPlaylist: MVideoPlaylistId,
|
||||
video: MVideoId
|
||||
) {
|
||||
return {
|
||||
position: elementObject.position,
|
||||
url: elementObject.id,
|
||||
startTimestamp: elementObject.startTimestamp || null,
|
||||
stopTimestamp: elementObject.stopTimestamp || null,
|
||||
videoPlaylistId: videoPlaylist.id,
|
||||
videoId: video.id
|
||||
} as AttributesOnly<VideoPlaylistElementModel>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist.js'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PlaylistElementObject, PlaylistObject } from '@peertube/peertube-models'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl)
|
||||
|
||||
logger.info('Fetching remote playlist %s.', playlistUrl, lTags())
|
||||
|
||||
const { body, statusCode } = await fetchAP<any>(playlistUrl)
|
||||
|
||||
if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
|
||||
logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() })
|
||||
return { statusCode, playlistObject: undefined }
|
||||
}
|
||||
|
||||
if (!isArray(body.to)) {
|
||||
logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() })
|
||||
return { statusCode, playlistObject: undefined }
|
||||
}
|
||||
|
||||
return { statusCode, playlistObject: body }
|
||||
}
|
||||
|
||||
async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> {
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl)
|
||||
|
||||
logger.debug('Fetching remote playlist element %s.', elementUrl, lTags())
|
||||
|
||||
const { body, statusCode } = await fetchAP<PlaylistElementObject>(elementUrl)
|
||||
|
||||
if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`)
|
||||
|
||||
if (checkUrlsSameHost(body.id, elementUrl) !== true) {
|
||||
throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
|
||||
}
|
||||
|
||||
return { statusCode, elementObject: body }
|
||||
}
|
||||
|
||||
export {
|
||||
fetchRemoteVideoPlaylist,
|
||||
fetchRemotePlaylistElement
|
||||
}
|
||||
1
server/core/lib/activitypub/process/index.ts
Normal file
1
server/core/lib/activitypub/process/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './process.js'
|
||||
32
server/core/lib/activitypub/process/process-accept.ts
Normal file
32
server/core/lib/activitypub/process/process-accept.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ActivityAccept } from '@peertube/peertube-models'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorDefault, MActorSignature } from '../../../types/models/index.js'
|
||||
import { addFetchOutboxJob } from '../outbox.js'
|
||||
|
||||
async function processAcceptActivity (options: APProcessorOptions<ActivityAccept>) {
|
||||
const { byActor: targetActor, inboxActor } = options
|
||||
if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
|
||||
|
||||
return processAccept(inboxActor, targetActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processAcceptActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processAccept (actor: MActorDefault, targetActor: MActorSignature) {
|
||||
const follow = await ActorFollowModel.loadByActorAndTarget(actor.id, targetActor.id)
|
||||
if (!follow) throw new Error('Cannot find associated follow.')
|
||||
|
||||
if (follow.state !== 'accepted') {
|
||||
follow.state = 'accepted'
|
||||
await follow.save()
|
||||
|
||||
await addFetchOutboxJob(targetActor)
|
||||
}
|
||||
}
|
||||
65
server/core/lib/activitypub/process/process-announce.ts
Normal file
65
server/core/lib/activitypub/process/process-announce.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ActivityAnnounce } from '@peertube/peertube-models'
|
||||
import { getAPId } from '@server/lib/activitypub/activity.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.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 { maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processAnnounceActivity (options: APProcessorOptions<ActivityAnnounce>) {
|
||||
const { activity, byActor: actorAnnouncer } = options
|
||||
// Only notify if it is not from a fetcher job
|
||||
const notify = options.fromFetch !== true
|
||||
|
||||
// Announces by accounts are not supported
|
||||
if (actorAnnouncer.type !== 'Application' && actorAnnouncer.type !== 'Group') return
|
||||
|
||||
return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity, notify)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processAnnounceActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processVideoShare (actorAnnouncer: MActorSignature, activity: ActivityAnnounce, notify: boolean) {
|
||||
const objectUri = getAPId(activity.object)
|
||||
|
||||
const { video, created: videoCreated } = await maybeGetOrCreateAPVideo({ videoObject: objectUri })
|
||||
if (!video) return
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
// Add share entry
|
||||
|
||||
const share = {
|
||||
actorId: actorAnnouncer.id,
|
||||
videoId: video.id,
|
||||
url: activity.id
|
||||
}
|
||||
|
||||
const [ , created ] = await VideoShareModel.findOrCreate({
|
||||
where: {
|
||||
url: activity.id
|
||||
},
|
||||
defaults: share,
|
||||
transaction: t
|
||||
})
|
||||
|
||||
if (video.isLocal() && created === true) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ actorAnnouncer ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: t, followersException: exceptions, video })
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (videoCreated && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
|
||||
}
|
||||
193
server/core/lib/activitypub/process/process-create.ts
Normal file
193
server/core/lib/activitypub/process/process-create.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
AbuseObject,
|
||||
ActivityCreate,
|
||||
ActivityCreateObject,
|
||||
ActivityObject,
|
||||
CacheFileObject,
|
||||
PlaylistObject,
|
||||
VideoCommentObject,
|
||||
VideoObject,
|
||||
WatchActionObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
|
||||
import { isRedundancyAccepted } from '@server/lib/redundancy.js'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models/index.js'
|
||||
import { Notifier } from '../../notifier/index.js'
|
||||
import { fetchAPObjectIfNeeded } from '../activity.js'
|
||||
import { createOrUpdateCacheFile } from '../cache-file.js'
|
||||
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 { resolveThread } from '../video-comments.js'
|
||||
import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
// Only notify if it is not from a fetcher job
|
||||
const notify = options.fromFetch !== true
|
||||
const activityObject = await fetchAPObjectIfNeeded<Exclude<ActivityObject, AbuseObject>>(activity.object)
|
||||
const activityType = activityObject.type
|
||||
|
||||
if (activityType === 'Video') {
|
||||
return processCreateVideo(activityObject, notify)
|
||||
}
|
||||
|
||||
if (activityType === 'Note') {
|
||||
// Comments will be fetched from videos
|
||||
if (options.fromFetch) return
|
||||
|
||||
return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, options.fromFetch)
|
||||
}
|
||||
|
||||
if (activityType === 'WatchAction') {
|
||||
return retryTransactionWrapper(processCreateWatchAction, activityObject)
|
||||
}
|
||||
|
||||
if (activityType === 'CacheFile') {
|
||||
return retryTransactionWrapper(processCreateCacheFile, activity, activityObject, byActor)
|
||||
}
|
||||
|
||||
if (activityType === 'Playlist') {
|
||||
return retryTransactionWrapper(processCreatePlaylist, activity, activityObject, byActor)
|
||||
}
|
||||
|
||||
logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processCreateActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processCreateVideo (videoToCreateData: VideoObject, notify: boolean) {
|
||||
const syncParam = { rates: false, shares: false, comments: false, refreshVideo: false }
|
||||
const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam })
|
||||
|
||||
if (created && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
async function processCreateCacheFile (
|
||||
activity: ActivityCreate<CacheFileObject | string>,
|
||||
cacheFile: CacheFileObject,
|
||||
byActor: MActorSignature
|
||||
) {
|
||||
if (await isRedundancyAccepted(activity, byActor) !== true) return
|
||||
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
|
||||
|
||||
if (video.isLocal() && !canVideoBeFederated(video)) {
|
||||
logger.warn(`Do not process create cache file ${cacheFile.object} on a video that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
return createOrUpdateCacheFile(cacheFile, video, byActor, t)
|
||||
})
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
async function processCreateWatchAction (watchAction: WatchActionObject) {
|
||||
if (watchAction.actionStatus !== 'CompletedActionStatus') return
|
||||
|
||||
const video = await VideoModel.loadByUrl(watchAction.object)
|
||||
if (video.remote) return
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
return createOrUpdateLocalVideoViewer(watchAction, video, t)
|
||||
})
|
||||
}
|
||||
|
||||
async function processCreateVideoComment (
|
||||
activity: ActivityCreate<VideoCommentObject | string>,
|
||||
commentObject: VideoCommentObject,
|
||||
byActor: MActorSignature,
|
||||
fromFetch: false
|
||||
) {
|
||||
if (CONFIG.VIDEO_COMMENTS.ACCEPT_REMOTE_COMMENTS !== true) return
|
||||
|
||||
if (fromFetch) throw new Error('Processing create video comment from fetch is not supported')
|
||||
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url)
|
||||
|
||||
let video: MVideoAccountLightBlacklistAllFiles
|
||||
let created: boolean
|
||||
let comment: MCommentOwnerVideo
|
||||
|
||||
try {
|
||||
const resolveThreadResult = await resolveThread({ url: commentObject.id, isVideo: false })
|
||||
if (!resolveThreadResult) return // Comment not accepted
|
||||
|
||||
video = resolveThreadResult.video
|
||||
created = resolveThreadResult.commentCreated
|
||||
comment = resolveThreadResult.comment
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
'Cannot process video comment because we could not resolve thread %s. Maybe it was not a video thread, so skip it.',
|
||||
commentObject.inReplyTo,
|
||||
{ err }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to not forward unwanted comments on our videos
|
||||
if (video.isLocal()) {
|
||||
if (!canVideoBeFederated(video)) {
|
||||
logger.info('Skip comment forward on non federated video' + video.url)
|
||||
return
|
||||
}
|
||||
|
||||
if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) {
|
||||
logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url)
|
||||
return
|
||||
}
|
||||
|
||||
// New non-moderated comment -> auto approve reply
|
||||
if (comment.heldForReview === false && created) {
|
||||
const reply = await VideoCommentModel.loadById(comment.inReplyToCommentId)
|
||||
sendReplyApproval(Object.assign(comment, { InReplyToVideoComment: reply }), 'ApproveReply')
|
||||
}
|
||||
|
||||
// New comment or re-sent after an approval -> forward comment
|
||||
if (comment.heldForReview === false && (created || commentObject.replyApproval)) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
if (created) Notifier.Instance.notifyOnNewComment(comment)
|
||||
}
|
||||
|
||||
async function processCreatePlaylist (
|
||||
activity: ActivityCreate<PlaylistObject | string>,
|
||||
playlistObject: PlaylistObject,
|
||||
byActor: MActorSignature
|
||||
) {
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
|
||||
|
||||
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: arrayify(activity.to) })
|
||||
}
|
||||
155
server/core/lib/activitypub/process/process-delete.ts
Normal file
155
server/core/lib/activitypub/process/process-delete.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ActivityDelete } from '@peertube/peertube-models'
|
||||
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import {
|
||||
MAccountActor,
|
||||
MActor,
|
||||
MActorFull,
|
||||
MActorSignature,
|
||||
MChannelAccountActor,
|
||||
MChannelActor,
|
||||
MCommentOwnerVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
|
||||
async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id
|
||||
|
||||
if (activity.actor === objectUrl) {
|
||||
// We need more attributes (all the account and channel)
|
||||
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
|
||||
if (!byActorFull) return
|
||||
|
||||
if (isAccountActor(byActorFull.type)) {
|
||||
if (!byActorFull.Account) throw new Error(`Actor ${byActorFull.url} is a person but we cannot find the account in database.`)
|
||||
|
||||
const accountToDelete = byActorFull.Account as MAccountActor
|
||||
accountToDelete.Actor = byActorFull
|
||||
|
||||
return retryTransactionWrapper(processDeleteAccount, accountToDelete)
|
||||
} else if (isChannelActor(byActorFull.type)) {
|
||||
if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
|
||||
|
||||
const channelToDelete = byActorFull.VideoChannel as MChannelAccountActor & { Actor: MActorFull }
|
||||
channelToDelete.Actor = byActorFull
|
||||
return retryTransactionWrapper(processDeleteVideoChannel, channelToDelete)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(objectUrl)
|
||||
if (videoCommentInstance) {
|
||||
return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const videoInstance = await VideoModel.loadByUrlAndPopulateAccountAndFiles(objectUrl)
|
||||
if (videoInstance) {
|
||||
if (videoInstance.isLocal()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`)
|
||||
|
||||
return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const videoPlaylist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(objectUrl)
|
||||
if (videoPlaylist) {
|
||||
if (videoPlaylist.isLocal()) throw new Error(`Remote instance cannot delete owned playlist ${videoPlaylist.url}.`)
|
||||
|
||||
return retryTransactionWrapper(processDeleteVideoPlaylist, byActor, videoPlaylist)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processDeleteActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processDeleteVideo (actor: MActor, videoToDelete: VideoModel) {
|
||||
logger.debug('Removing remote video "%s".', videoToDelete.uuid)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
if (videoToDelete.VideoChannel.Account.Actor.id !== actor.id) {
|
||||
throw new Error('Account ' + actor.url + ' does not own video channel ' + videoToDelete.VideoChannel.Actor.url)
|
||||
}
|
||||
|
||||
await videoToDelete.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote video with uuid %s removed.', videoToDelete.uuid)
|
||||
}
|
||||
|
||||
async function processDeleteVideoPlaylist (actor: MActor, playlistToDelete: VideoPlaylistModel) {
|
||||
logger.debug('Removing remote video playlist "%s".', playlistToDelete.uuid)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
if (playlistToDelete.OwnerAccount.Actor.id !== actor.id) {
|
||||
throw new Error('Account ' + actor.url + ' does not own video playlist ' + playlistToDelete.url)
|
||||
}
|
||||
|
||||
await playlistToDelete.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote video playlist with uuid %s removed.', playlistToDelete.uuid)
|
||||
}
|
||||
|
||||
async function processDeleteAccount (accountToRemove: MAccountActor) {
|
||||
logger.debug('Removing remote account "%s".', accountToRemove.Actor.url)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await accountToRemove.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote account %s removed.', accountToRemove.Actor.url)
|
||||
}
|
||||
|
||||
async function processDeleteVideoChannel (videoChannelToRemove: MChannelActor) {
|
||||
logger.debug('Removing remote video channel "%s".', videoChannelToRemove.Actor.url)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await videoChannelToRemove.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote video channel %s removed.', videoChannelToRemove.Actor.url)
|
||||
}
|
||||
|
||||
function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) {
|
||||
// Already deleted
|
||||
if (videoComment.isDeleted()) return Promise.resolve()
|
||||
|
||||
logger.debug('Removing remote video comment "%s".', videoComment.url)
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
if (byActor.Account.id !== videoComment.Account.id && byActor.Account.id !== videoComment.Video.VideoChannel.accountId) {
|
||||
throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
|
||||
}
|
||||
|
||||
videoComment.markAsDeleted()
|
||||
|
||||
await videoComment.save({ transaction: t })
|
||||
|
||||
if (videoComment.Video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity({ activity, transaction: t, followersException: exceptions, video: videoComment.Video })
|
||||
}
|
||||
|
||||
logger.info('Remote video comment %s removed.', videoComment.url)
|
||||
})
|
||||
}
|
||||
62
server/core/lib/activitypub/process/process-dislike.ts
Normal file
62
server/core/lib/activitypub/process/process-dislike.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ActivityDislike } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) {
|
||||
const { activity, byActor } = options
|
||||
return retryTransactionWrapper(processDislike, activity, byActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processDislikeActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processDislike (activity: ActivityDislike, byActor: MActorSignature) {
|
||||
const videoUrl = activity.object
|
||||
const byAccount = byActor.Account
|
||||
|
||||
if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
if (!canVideoBeFederated(onlyVideo)) {
|
||||
logger.warn(`Do not process dislike on video ${videoUrl} that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
|
||||
const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
|
||||
if (existingRate?.type === 'dislike') return
|
||||
|
||||
await video.increment('dislikes', { transaction: t })
|
||||
video.dislikes++
|
||||
|
||||
if (existingRate?.type === 'like') {
|
||||
await video.decrement('likes', { transaction: t })
|
||||
video.likes--
|
||||
}
|
||||
|
||||
const rate = existingRate || new AccountVideoRateModel()
|
||||
rate.type = 'dislike'
|
||||
rate.videoId = video.id
|
||||
rate.accountId = byAccount.id
|
||||
rate.url = activity.id
|
||||
|
||||
await rate.save({ transaction: t })
|
||||
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
103
server/core/lib/activitypub/process/process-flag.ts
Normal file
103
server/core/lib/activitypub/process/process-flag.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { abusePredefinedReasonsMap } from '@peertube/peertube-core-utils'
|
||||
import { AbuseState, ActivityFlag } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { getAPId } from '../../../lib/activitypub/activity.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models/index.js'
|
||||
|
||||
async function processFlagActivity (options: APProcessorOptions<ActivityFlag>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
return retryTransactionWrapper(processCreateAbuse, activity, byActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processFlagActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature) {
|
||||
const account = byActor.Account
|
||||
if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url)
|
||||
|
||||
const reporterAccount = await AccountModel.load(account.id)
|
||||
|
||||
const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ]
|
||||
|
||||
const tags = Array.isArray(flag.tag) ? flag.tag : []
|
||||
const predefinedReasons = tags.map(tag => abusePredefinedReasonsMap[tag.name])
|
||||
.filter(v => !isNaN(v))
|
||||
|
||||
const startAt = flag.startAt
|
||||
const endAt = flag.endAt
|
||||
|
||||
for (const object of objects) {
|
||||
try {
|
||||
const uri = getAPId(object)
|
||||
|
||||
logger.debug('Reporting remote abuse for object %s.', uri)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccountAndFiles(uri, t)
|
||||
let videoComment: MCommentOwnerVideo
|
||||
let flaggedAccount: MAccountDefault
|
||||
|
||||
if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(uri, t)
|
||||
if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri, t)
|
||||
|
||||
if (!video && !videoComment && !flaggedAccount) {
|
||||
logger.warn('Cannot flag unknown entity %s.', object)
|
||||
return
|
||||
}
|
||||
|
||||
const baseAbuse = {
|
||||
reporterAccountId: reporterAccount.id,
|
||||
reason: flag.content,
|
||||
state: AbuseState.PENDING,
|
||||
predefinedReasons
|
||||
}
|
||||
|
||||
if (video) {
|
||||
return createVideoAbuse({
|
||||
baseAbuse,
|
||||
startAt,
|
||||
endAt,
|
||||
reporterAccount,
|
||||
transaction: t,
|
||||
videoInstance: video,
|
||||
skipNotification: false
|
||||
})
|
||||
}
|
||||
|
||||
if (videoComment) {
|
||||
return createVideoCommentAbuse({
|
||||
baseAbuse,
|
||||
reporterAccount,
|
||||
transaction: t,
|
||||
commentInstance: videoComment,
|
||||
skipNotification: false
|
||||
})
|
||||
}
|
||||
|
||||
return await createAccountAbuse({
|
||||
baseAbuse,
|
||||
reporterAccount,
|
||||
transaction: t,
|
||||
accountInstance: flaggedAccount,
|
||||
skipNotification: false
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
logger.debug('Cannot process report of %s', getAPId(object), { err })
|
||||
}
|
||||
}
|
||||
}
|
||||
164
server/core/lib/activitypub/process/process-follow.ts
Normal file
164
server/core/lib/activitypub/process/process-follow.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityFollow } from '@peertube/peertube-models'
|
||||
import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { CONFIG } from '../../../initializers/config.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { getAPId } from '../../../lib/activitypub/activity.js'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorFollow, MActorFull, MActorId, MActorSignature } from '../../../types/models/index.js'
|
||||
import { Notifier } from '../../notifier/index.js'
|
||||
import { autoFollowBackIfNeeded } from '../follow.js'
|
||||
import { sendAccept, sendReject } from '../send/index.js'
|
||||
|
||||
async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
const activityId = activity.id
|
||||
const objectId = getAPId(activity.object)
|
||||
|
||||
return retryTransactionWrapper(processFollow, byActor, activityId, objectId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processFollowActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processFollow (byActor: MActorSignature, activityId: string, targetActorURL: string) {
|
||||
const { actorFollow, created, targetActor } = await sequelizeTypescript.transaction(async t => {
|
||||
const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
|
||||
|
||||
if (!targetActor) throw new Error('Unknown actor')
|
||||
if (targetActor.isLocal() === false) throw new Error('This is not a local actor.')
|
||||
|
||||
if (await rejectIfInstanceFollowDisabled(byActor, activityId, targetActor)) return { actorFollow: undefined }
|
||||
if (await rejectIfMuted(byActor, activityId, targetActor)) return { actorFollow: undefined }
|
||||
|
||||
const [ actorFollow, created ] = await ActorFollowModel.findOrCreateCustom({
|
||||
byActor,
|
||||
targetActor,
|
||||
activityId,
|
||||
state: await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
|
||||
? 'pending'
|
||||
: 'accepted',
|
||||
transaction: t
|
||||
})
|
||||
|
||||
if (rejectIfAlreadyRejected(actorFollow, byActor, activityId, targetActor)) return { actorFollow: undefined }
|
||||
|
||||
await acceptIfNeeded(actorFollow, targetActor, t)
|
||||
|
||||
await fixFollowURLIfNeeded(actorFollow, activityId, t)
|
||||
|
||||
actorFollow.ActorFollower = byActor
|
||||
actorFollow.ActorFollowing = targetActor
|
||||
|
||||
// Target sends to actor he accepted the follow request
|
||||
if (actorFollow.state === 'accepted') {
|
||||
sendAccept(actorFollow)
|
||||
|
||||
await autoFollowBackIfNeeded(actorFollow, t)
|
||||
}
|
||||
|
||||
return { actorFollow, created, targetActor }
|
||||
})
|
||||
|
||||
// Rejected
|
||||
if (!actorFollow) return
|
||||
|
||||
if (created) {
|
||||
const follower = await ActorModel.loadFull(byActor.id)
|
||||
const actorFollowFull = Object.assign(actorFollow, { ActorFollowing: targetActor, ActorFollower: follower })
|
||||
|
||||
if (await isFollowingInstance(targetActor)) {
|
||||
Notifier.Instance.notifyOfNewInstanceFollow(actorFollowFull)
|
||||
} else {
|
||||
Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Actor %s is followed by actor %s.', targetActorURL, byActor.url)
|
||||
}
|
||||
|
||||
async function rejectIfInstanceFollowDisabled (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
|
||||
if (await isFollowingInstance(targetActor)) {
|
||||
if (CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
|
||||
logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
} else if (CONFIG.FOLLOWERS.CHANNELS.ENABLED === false) {
|
||||
logger.info('Rejecting %s because channel followers are disabled.', targetActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function rejectIfMuted (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
|
||||
const followerAccount = await AccountModel.load(byActor.Account.id)
|
||||
const followingAccountId = targetActor.Account
|
||||
|
||||
if (followerAccount && await isBlockedByServerOrAccount(followerAccount, followingAccountId)) {
|
||||
logger.info('Rejecting %s because follower is muted.', byActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function rejectIfAlreadyRejected (actorFollow: MActorFollow, byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
|
||||
// Already rejected
|
||||
if (actorFollow.state === 'rejected') {
|
||||
logger.info('Rejecting %s because follow is already rejected.', byActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function acceptIfNeeded (actorFollow: MActorFollow, targetActor: MActorFull, transaction: Transaction) {
|
||||
// Set the follow as accepted if the remote actor follows a channel or account
|
||||
// Or if the instance automatically accepts followers
|
||||
if (actorFollow.state === 'accepted') return
|
||||
if (!await isFollowingInstance(targetActor)) return
|
||||
if (CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL === true && await isFollowingInstance(targetActor)) return
|
||||
|
||||
actorFollow.state = 'accepted'
|
||||
|
||||
await actorFollow.save({ transaction })
|
||||
}
|
||||
|
||||
async function fixFollowURLIfNeeded (actorFollow: MActorFollow, activityId: string, transaction: Transaction) {
|
||||
// Before PeerTube V3 we did not save the follow ID. Try to fix these old follows
|
||||
if (!actorFollow.url) {
|
||||
actorFollow.url = activityId
|
||||
await actorFollow.save({ transaction })
|
||||
}
|
||||
}
|
||||
|
||||
async function isFollowingInstance (targetActor: MActorId) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
return targetActor.id === serverActor.id
|
||||
}
|
||||
64
server/core/lib/activitypub/process/process-like.ts
Normal file
64
server/core/lib/activitypub/process/process-like.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ActivityLike } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { getAPId } from '../../../lib/activitypub/activity.js'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processLikeActivity (options: APProcessorOptions<ActivityLike>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
return retryTransactionWrapper(processLikeVideo, byActor, activity)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processLikeActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processLikeVideo (byActor: MActorSignature, activity: ActivityLike) {
|
||||
const videoUrl = getAPId(activity.object)
|
||||
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
if (!canVideoBeFederated(onlyVideo)) {
|
||||
logger.warn(`Do not process like on video ${videoUrl} that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
|
||||
const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
|
||||
if (existingRate?.type === 'like') return
|
||||
|
||||
if (existingRate?.type === 'dislike') {
|
||||
await video.decrement('dislikes', { transaction: t })
|
||||
video.dislikes--
|
||||
}
|
||||
|
||||
await video.increment('likes', { transaction: t })
|
||||
video.likes++
|
||||
|
||||
const rate = existingRate || new AccountVideoRateModel()
|
||||
rate.type = 'like'
|
||||
rate.videoId = video.id
|
||||
rate.accountId = byAccount.id
|
||||
rate.url = activity.id
|
||||
|
||||
await rate.save({ transaction: t })
|
||||
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
33
server/core/lib/activitypub/process/process-reject.ts
Normal file
33
server/core/lib/activitypub/process/process-reject.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ActivityReject } from '@peertube/peertube-models'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActor } from '../../../types/models/index.js'
|
||||
|
||||
async function processRejectActivity (options: APProcessorOptions<ActivityReject>) {
|
||||
const { byActor: targetActor, inboxActor } = options
|
||||
if (inboxActor === undefined) throw new Error('Need to reject on explicit inbox.')
|
||||
|
||||
return processReject(inboxActor, targetActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processRejectActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processReject (follower: MActor, targetActor: MActor) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, targetActor.id, t)
|
||||
|
||||
if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${targetActor.id}.`)
|
||||
|
||||
actorFollow.state = 'rejected'
|
||||
await actorFollow.save({ transaction: t })
|
||||
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ActivityApproveReply, ActivityRejectReply, ActivityType } from '@peertube/peertube-models'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
|
||||
import { sendCreateVideoCommentIfNeeded } from '../send/send-create.js'
|
||||
|
||||
export function processReplyApprovalFactory (type: Extract<ActivityType, 'ApproveReply' | 'RejectReply'>) {
|
||||
return async (options: APProcessorOptions<ActivityApproveReply | ActivityRejectReply>) => {
|
||||
if (type === 'RejectReply') return // Not yet implemented
|
||||
|
||||
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.isLocal() !== true) {
|
||||
throw new Error(`Cannot process reply approval on non-owned comment ${comment.url}`)
|
||||
}
|
||||
|
||||
if (byActor.id !== comment.Video.VideoChannel.Account.Actor.id) {
|
||||
throw new Error(`Cannot process reply approval on ${comment.url} by non video owner`)
|
||||
}
|
||||
|
||||
return processApproveReply(activity.id, comment)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processApproveReply (replyApproval: string, comment: MCommentOwnerVideoReply) {
|
||||
if (comment.heldForReview === false || comment.replyApproval === replyApproval) return
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
comment.heldForReview = false
|
||||
comment.replyApproval = replyApproval
|
||||
await comment.save({ transaction: t })
|
||||
|
||||
await sendCreateVideoCommentIfNeeded(comment, t)
|
||||
})
|
||||
}
|
||||
181
server/core/lib/activitypub/process/process-undo.ts
Normal file
181
server/core/lib/activitypub/process/process-undo.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
ActivityAnnounce,
|
||||
ActivityCreate,
|
||||
ActivityDislike,
|
||||
ActivityFollow,
|
||||
ActivityLike,
|
||||
ActivityUndo,
|
||||
ActivityUndoObject,
|
||||
CacheFileObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy.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 { fetchAPObjectIfNeeded } from '../activity.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { federateVideoIfNeeded, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processUndoActivity (options: APProcessorOptions<ActivityUndo<ActivityUndoObject>>) {
|
||||
const { activity, byActor } = options
|
||||
const activityToUndo = activity.object
|
||||
|
||||
if (activityToUndo.type === 'Like') {
|
||||
return retryTransactionWrapper(processUndoLike, byActor, activity)
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Create') {
|
||||
const objectToUndo = await fetchAPObjectIfNeeded<CacheFileObject>(activityToUndo.object)
|
||||
|
||||
if (objectToUndo.type === 'CacheFile') {
|
||||
return retryTransactionWrapper(processUndoCacheFile, byActor, activity, objectToUndo)
|
||||
}
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Dislike') {
|
||||
return retryTransactionWrapper(processUndoDislike, byActor, activity)
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Follow') {
|
||||
return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Announce') {
|
||||
return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo)
|
||||
}
|
||||
|
||||
logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processUndoActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo<ActivityLike>) {
|
||||
const likeActivity = activity.object
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: likeActivity.object })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
|
||||
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, likeActivity.id, t)
|
||||
if (rate?.type !== 'like') {
|
||||
logger.warn('Unknown like by account %d for video %d.', byActor.Account.id, video.id)
|
||||
return
|
||||
}
|
||||
|
||||
await rate.destroy({ transaction: t })
|
||||
await video.decrement('likes', { transaction: t })
|
||||
|
||||
video.likes--
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
|
||||
async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo<ActivityDislike>) {
|
||||
const dislikeActivity = activity.object
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: dislikeActivity.object })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
|
||||
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislikeActivity.id, t)
|
||||
if (rate?.type !== 'dislike') {
|
||||
logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id)
|
||||
return
|
||||
}
|
||||
|
||||
await rate.destroy({ transaction: t })
|
||||
await video.decrement('dislikes', { transaction: t })
|
||||
video.dislikes--
|
||||
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUndoCacheFile (
|
||||
byActor: MActorSignature,
|
||||
activity: ActivityUndo<ActivityCreate<CacheFileObject>>,
|
||||
cacheFileObject: CacheFileObject
|
||||
) {
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
|
||||
if (!cacheFile) {
|
||||
logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
|
||||
|
||||
await cacheFile.destroy({ transaction: t })
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: t, followersException: exceptions, video })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function processUndoAnnounce (byActor: MActorSignature, announceActivity: ActivityAnnounce) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
|
||||
if (!share) {
|
||||
logger.warn(`Unknown video share ${announceActivity.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (share.actorId !== byActor.id) throw new Error(`${share.url} is not shared by ${byActor.url}.`)
|
||||
|
||||
await share.destroy({ transaction: t })
|
||||
|
||||
if (share.Video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity: announceActivity, transaction: t, followersException: exceptions, video: share.Video })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function processUndoFollow (follower: MActorSignature, followActivity: ActivityFollow) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
|
||||
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
|
||||
|
||||
if (!actorFollow) {
|
||||
logger.warn('Unknown actor follow %d -> %d.', follower.id, following.id)
|
||||
return
|
||||
}
|
||||
|
||||
await actorFollow.destroy({ transaction: t })
|
||||
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
170
server/core/lib/activitypub/process/process-update.ts
Normal file
170
server/core/lib/activitypub/process/process-update.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ActivityPubActor,
|
||||
ActivityPubActorType,
|
||||
ActivityUpdate,
|
||||
ActivityUpdateObject,
|
||||
CacheFileObject,
|
||||
PlayerSettingsObject,
|
||||
PlaylistObject,
|
||||
VideoObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { isActorTypeValid } from '@server/helpers/custom-validators/activitypub/actor.js'
|
||||
import { isRedundancyAccepted } from '@server/lib/redundancy.js'
|
||||
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file.js'
|
||||
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorFull, MActorSignature } from '../../../types/models/index.js'
|
||||
import { fetchAPObjectIfNeeded } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/get.js'
|
||||
import { APActorUpdater } from '../actors/updater.js'
|
||||
import { createOrUpdateCacheFile } from '../cache-file.js'
|
||||
import { upsertAPPlayerSettings } from '../player-settings.js'
|
||||
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
const object = await fetchAPObjectIfNeeded(activity.object)
|
||||
const objectType = object.type
|
||||
|
||||
if (objectType === 'Video') {
|
||||
return retryTransactionWrapper(processUpdateVideo, activity)
|
||||
}
|
||||
|
||||
if (isActorTypeValid(objectType as ActivityPubActorType)) {
|
||||
// We need more attributes
|
||||
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
|
||||
return retryTransactionWrapper(processUpdateActor, byActorFull, object)
|
||||
}
|
||||
|
||||
if (objectType === 'CacheFile') {
|
||||
// We need more attributes
|
||||
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
|
||||
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity, object)
|
||||
}
|
||||
|
||||
if (objectType === 'Playlist') {
|
||||
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
|
||||
}
|
||||
|
||||
if (objectType === 'PlayerSettings') {
|
||||
return retryTransactionWrapper(processUpdatePlayerSettings, byActor, object)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processUpdateActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUpdateVideo (activity: ActivityUpdate<VideoObject | string>) {
|
||||
const videoObject = activity.object as VideoObject
|
||||
|
||||
if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
|
||||
logger.debug('Video sent by update is not valid.', { videoObject })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { video, created } = await getOrCreateAPVideo({
|
||||
videoObject: videoObject.id,
|
||||
allowRefresh: false,
|
||||
fetchType: 'all'
|
||||
})
|
||||
// We did not have this video, it has been created so no need to update
|
||||
if (created) return
|
||||
|
||||
const updater = new APVideoUpdater(videoObject, video)
|
||||
return updater.update(arrayify(activity.to))
|
||||
}
|
||||
|
||||
async function processUpdateCacheFile (
|
||||
byActor: MActorSignature,
|
||||
activity: ActivityUpdate<CacheFileObject | string>,
|
||||
cacheFileObject: CacheFileObject
|
||||
) {
|
||||
if (await isRedundancyAccepted(activity, byActor) !== true) return
|
||||
|
||||
if (!isCacheFileObjectValid(cacheFileObject)) {
|
||||
logger.debug('Cache file object sent by update is not valid.', { cacheFileObject })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
|
||||
|
||||
if (video.isLocal() && !canVideoBeFederated(video)) {
|
||||
logger.warn(`Do not process update cache file on video ${activity.object} that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await createOrUpdateCacheFile(cacheFileObject, video, byActor, t)
|
||||
})
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
|
||||
logger.debug(`Updating remote account "${actorObject.id}".`)
|
||||
|
||||
const updater = new APActorUpdater(actorObject, actor)
|
||||
return updater.update()
|
||||
}
|
||||
|
||||
async function processUpdatePlaylist (
|
||||
byActor: MActorSignature,
|
||||
activity: ActivityUpdate<PlaylistObject | string>,
|
||||
playlistObject: PlaylistObject
|
||||
) {
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
|
||||
|
||||
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: arrayify(activity.to) })
|
||||
}
|
||||
|
||||
async function processUpdatePlayerSettings (
|
||||
byActor: MActorSignature,
|
||||
settingsObject: PlayerSettingsObject
|
||||
) {
|
||||
let actor: MActorFull
|
||||
|
||||
const { video } = await maybeGetOrCreateAPVideo({ videoObject: settingsObject.object })
|
||||
|
||||
if (!video) {
|
||||
try {
|
||||
actor = await getOrCreateAPActor(settingsObject.object, 'all')
|
||||
} catch {
|
||||
actor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!video && !actor?.VideoChannel) {
|
||||
logger.warn(`Do not process update player settings on unknown video/channel`)
|
||||
return
|
||||
}
|
||||
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject,
|
||||
contextUrl: byActor.url,
|
||||
video,
|
||||
channel: actor
|
||||
? Object.assign(actor.VideoChannel, { Actor: actor })
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
57
server/core/lib/activitypub/process/process-view.ts
Normal file
57
server/core/lib/activitypub/process/process-view.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ActivityView } from '@peertube/peertube-models'
|
||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { getOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processViewActivity (options: APProcessorOptions<ActivityView>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
return processCreateView(activity, byActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processViewActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processCreateView (activity: ActivityView, byActor: MActorSignature) {
|
||||
const videoObject = activity.object
|
||||
|
||||
const { video } = await getOrCreateAPVideo({
|
||||
videoObject,
|
||||
fetchType: 'only-video-and-blacklist',
|
||||
allowRefresh: false
|
||||
})
|
||||
|
||||
await VideoViewsManager.Instance.processRemoteView({
|
||||
video,
|
||||
viewerId: activity.id,
|
||||
|
||||
viewerExpires: activity.expires
|
||||
? new Date(activity.expires)
|
||||
: undefined,
|
||||
viewerResultCounter: getViewerResultCounter(activity)
|
||||
})
|
||||
|
||||
if (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 })
|
||||
}
|
||||
}
|
||||
|
||||
function getViewerResultCounter (activity: ActivityView) {
|
||||
const result = activity.result
|
||||
|
||||
if (!activity.expires || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined
|
||||
|
||||
const counter = parseInt(result.userInteractionCount + '')
|
||||
if (isNaN(counter)) return undefined
|
||||
|
||||
return counter
|
||||
}
|
||||
91
server/core/lib/activitypub/process/process.ts
Normal file
91
server/core/lib/activitypub/process/process.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Activity, ActivityType } from '@peertube/peertube-models'
|
||||
import { StatsManager } from '@server/lib/stat-manager.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorDefault, MActorSignature } from '../../../types/models/index.js'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/index.js'
|
||||
import { checkUrlsSameHost } from '../url.js'
|
||||
import { processAcceptActivity } from './process-accept.js'
|
||||
import { processAnnounceActivity } from './process-announce.js'
|
||||
import { processCreateActivity } from './process-create.js'
|
||||
import { processDeleteActivity } from './process-delete.js'
|
||||
import { processDislikeActivity } from './process-dislike.js'
|
||||
import { processFlagActivity } from './process-flag.js'
|
||||
import { processFollowActivity } from './process-follow.js'
|
||||
import { processLikeActivity } from './process-like.js'
|
||||
import { processRejectActivity } from './process-reject.js'
|
||||
import { processReplyApprovalFactory } from './process-reply-approval.js'
|
||||
import { processUndoActivity } from './process-undo.js'
|
||||
import { processUpdateActivity } from './process-update.js'
|
||||
import { processViewActivity } from './process-view.js'
|
||||
|
||||
const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Activity>) => Promise<any> } = {
|
||||
Create: processCreateActivity,
|
||||
Update: processUpdateActivity,
|
||||
Delete: processDeleteActivity,
|
||||
Follow: processFollowActivity,
|
||||
Accept: processAcceptActivity,
|
||||
Reject: processRejectActivity,
|
||||
Announce: processAnnounceActivity,
|
||||
Undo: processUndoActivity,
|
||||
Like: processLikeActivity,
|
||||
Dislike: processDislikeActivity,
|
||||
Flag: processFlagActivity,
|
||||
View: processViewActivity,
|
||||
ApproveReply: processReplyApprovalFactory('ApproveReply'),
|
||||
RejectReply: processReplyApprovalFactory('RejectReply')
|
||||
}
|
||||
|
||||
export async function processActivities (
|
||||
activities: Activity[],
|
||||
options: {
|
||||
signatureActor?: MActorSignature
|
||||
inboxActor?: MActorDefault
|
||||
outboxUrl?: string
|
||||
fromFetch?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { outboxUrl, signatureActor, inboxActor, fromFetch = false } = options
|
||||
|
||||
const actorsCache: { [ url: string ]: MActorSignature } = {}
|
||||
|
||||
for (const activity of activities) {
|
||||
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
|
||||
}
|
||||
|
||||
const actorUrl = getAPId(activity.actor)
|
||||
|
||||
// When we fetch remote data, we don't have signature
|
||||
if (signatureActor && actorUrl !== signatureActor.url) {
|
||||
logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, signatureActor.url)
|
||||
continue
|
||||
}
|
||||
|
||||
if (outboxUrl && checkUrlsSameHost(outboxUrl, actorUrl) !== true) {
|
||||
logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', outboxUrl, actorUrl)
|
||||
continue
|
||||
}
|
||||
|
||||
const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl)
|
||||
actorsCache[actorUrl] = byActor
|
||||
|
||||
const activityProcessor = processActivity[activity.type]
|
||||
if (activityProcessor === undefined) {
|
||||
logger.warn('Unknown activity type %s.', activity.type, { activityId: activity.id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await activityProcessor({ activity, byActor, inboxActor, fromFetch })
|
||||
|
||||
StatsManager.Instance.addInboxProcessedSuccess(activity.type)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot process activity %s.', activity.type, { err })
|
||||
|
||||
StatsManager.Instance.addInboxProcessedError(activity.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
server/core/lib/activitypub/send/http.ts
Normal file
67
server/core/lib/activitypub/send/http.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ContextType } from '@peertube/peertube-models'
|
||||
import { signAndContextify } from '@server/helpers/activity-pub-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@server/initializers/constants.js'
|
||||
import { buildDigestFromWorker, signJsonLDObjectFromWorker } from '@server/lib/worker/parent-process.js'
|
||||
import { ActorReservedModel } from '@server/models/actor/actor-reserved.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { getContextFilter } from '../context.js'
|
||||
|
||||
type Payload<T> = { body: T, contextType: ContextType, signatureActorId?: number }
|
||||
|
||||
export async function computeBody<T> (
|
||||
payload: Payload<T>
|
||||
): Promise<T | T & { type: 'RsaSignature2017', creator: string, created: string }> {
|
||||
let body = payload.body
|
||||
|
||||
if (payload.signatureActorId) {
|
||||
const actorSignature = await loadActorForSignature(payload.signatureActorId)
|
||||
|
||||
try {
|
||||
body = await signAndContextify({
|
||||
byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey },
|
||||
data: payload.body,
|
||||
contextType: payload.contextType,
|
||||
contextFilter: getContextFilter(),
|
||||
signerFunction: signJsonLDObjectFromWorker
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Cannot sign and contextify body', { body, err })
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
export async function buildGlobalHTTPHeaders (body: any) {
|
||||
return {
|
||||
'digest': await buildDigestFromWorker(body),
|
||||
'content-type': 'application/activity+json',
|
||||
'accept': ACTIVITY_PUB.ACCEPT_HEADER
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSignedRequestOptions (options: {
|
||||
signatureActorId?: number
|
||||
hasPayload: boolean
|
||||
}) {
|
||||
const actor = options.signatureActorId
|
||||
? await loadActorForSignature(options.signatureActorId)
|
||||
: await getServerActor() // We need to sign the request, so use the server
|
||||
|
||||
return {
|
||||
keyId: ActorModel.getPublicKeyUrl(actor.url),
|
||||
key: actor.privateKey,
|
||||
headers: options.hasPayload
|
||||
? HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD
|
||||
: HTTP_SIGNATURE.HEADERS_TO_SIGN_WITHOUT_PAYLOAD
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActorForSignature (actorId: number) {
|
||||
const actor = await ActorModel.load(actorId) ?? await ActorReservedModel.loadByActorId(actorId)
|
||||
if (!actor) throw new Error('Unknown signature actor id.')
|
||||
|
||||
return actor
|
||||
}
|
||||
11
server/core/lib/activitypub/send/index.ts
Normal file
11
server/core/lib/activitypub/send/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from './http.js'
|
||||
export * from './send-accept.js'
|
||||
export * from './send-announce.js'
|
||||
export * from './send-create.js'
|
||||
export * from './send-delete.js'
|
||||
export * from './send-follow.js'
|
||||
export * from './send-like.js'
|
||||
export * from './send-reject.js'
|
||||
export * from './send-reply-approval.js'
|
||||
export * from './send-undo.js'
|
||||
export * from './send-update.js'
|
||||
47
server/core/lib/activitypub/send/send-accept.ts
Normal file
47
server/core/lib/activitypub/send/send-accept.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ActivityAccept, ActivityFollow } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorFollowActors } from '../../../types/models/index.js'
|
||||
import { getLocalActorFollowAcceptActivityPubUrl } from '../url.js'
|
||||
import { buildFollowActivity } from './send-follow.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendAccept (actorFollow: MActorFollowActors) {
|
||||
const follower = actorFollow.ActorFollower
|
||||
const me = actorFollow.ActorFollowing
|
||||
|
||||
if (!follower.serverId) { // This should never happen
|
||||
logger.warn('Do not sending accept to local follower.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Creating job to accept follower %s.', follower.url)
|
||||
|
||||
const followData = buildFollowActivity(actorFollow.url, follower, me)
|
||||
|
||||
const url = getLocalActorFollowAcceptActivityPubUrl(actorFollow)
|
||||
const data = buildAcceptActivity(url, me, followData)
|
||||
|
||||
return unicastTo({
|
||||
data,
|
||||
byActor: me,
|
||||
toActorUrl: follower.inboxUrl,
|
||||
contextType: 'Accept'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendAccept
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildAcceptActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityAccept {
|
||||
return {
|
||||
type: 'Accept',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: followActivityData
|
||||
}
|
||||
}
|
||||
47
server/core/lib/activitypub/send/send-announce.ts
Normal file
47
server/core/lib/activitypub/send/send-announce.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ActivityAnnounce, ActivityAudience } from '@peertube/peertube-models'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActorLight, MVideo } from '../../../types/models/index.js'
|
||||
import { MVideoShare } from '../../../types/models/video/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { broadcastToFollowers, getActorsInvolvedInVideo } from './shared/send-utils.js'
|
||||
|
||||
export async function sendVideoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
|
||||
const activity = buildAnnounceWithVideoAudience(byActor, videoShare, video)
|
||||
|
||||
logger.info('Creating job to send announce %s.', videoShare.url)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: await getActorsInvolvedInVideo(video, transaction),
|
||||
transaction,
|
||||
actorsException: [ byActor ],
|
||||
contextType: 'Announce'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAnnounceWithVideoAudience (
|
||||
byActor: MActorLight,
|
||||
videoShare: MVideoShare,
|
||||
video: MVideo
|
||||
) {
|
||||
const announcedObject = video.url
|
||||
|
||||
const audience = getPublicAudience(byActor)
|
||||
|
||||
const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
|
||||
|
||||
return activity
|
||||
}
|
||||
|
||||
export function buildAnnounceActivity (url: string, byActor: MActorLight, object: string, audience?: ActivityAudience): ActivityAnnounce {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify({
|
||||
type: 'Announce' as 'Announce',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
}, audience)
|
||||
}
|
||||
226
server/core/lib/activitypub/send/send-create.ts
Normal file
226
server/core/lib/activitypub/send/send-create.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
ActivityAudience,
|
||||
ActivityCreate,
|
||||
ActivityCreateObject,
|
||||
ContextType,
|
||||
VideoCommentObject,
|
||||
VideoPlaylistPrivacy
|
||||
} from '@peertube/peertube-models'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||
import {
|
||||
MActorLight,
|
||||
MCommentOwnerVideoReply,
|
||||
MLocalVideoViewerWithWatchSections,
|
||||
MVideoAP,
|
||||
MVideoAccountLight,
|
||||
MVideoPlaylistFull,
|
||||
MVideoRedundancyStreamingPlaylistVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getCommentAudience, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../audience.js'
|
||||
import { canVideoBeFederated } from '../videos/federate.js'
|
||||
import {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
sendVideoRelatedActivity,
|
||||
sendVideoRelatedActivityToOrigin,
|
||||
unicastTo
|
||||
} from './shared/send-utils.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'create')
|
||||
|
||||
export async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
|
||||
if (!canVideoBeFederated(video)) return undefined
|
||||
|
||||
logger.info('Creating job to send video creation of %s.', video.url, lTags(video.uuid))
|
||||
|
||||
const byActor = video.VideoChannel.Account.Actor
|
||||
const videoObject = await video.toActivityPubObject()
|
||||
|
||||
const audience = getVideoAudience({ account: video.VideoChannel.Account, channel: video.VideoChannel, privacy: video.privacy })
|
||||
const createActivity = buildCreateActivity(video.url, byActor, videoObject, audience)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf: [ byActor ],
|
||||
transaction,
|
||||
contextType: 'Video'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendCreateCacheFile (
|
||||
byActor: MActorLight,
|
||||
video: MVideoAccountLight,
|
||||
fileRedundancy: MVideoRedundancyStreamingPlaylistVideo
|
||||
) {
|
||||
logger.info('Creating job to send file cache of %s.', fileRedundancy.url, lTags(video.uuid))
|
||||
|
||||
return sendVideoRelatedCreateActivity({
|
||||
byActor,
|
||||
video,
|
||||
url: fileRedundancy.url,
|
||||
object: fileRedundancy.toActivityPubObject(),
|
||||
contextType: 'CacheFile'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
|
||||
logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
|
||||
|
||||
const byActor = await getServerActor()
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
|
||||
}
|
||||
|
||||
export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
|
||||
if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
|
||||
|
||||
logger.info('Creating job to send create video playlist of %s.', playlist.url, lTags(playlist.uuid))
|
||||
|
||||
const byActor = playlist.OwnerAccount.Actor
|
||||
const audience = getPlaylistAudience(byActor, playlist.privacy)
|
||||
|
||||
const object = await playlist.toActivityPubObject(null, transaction)
|
||||
const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
transaction,
|
||||
contextType: 'Playlist'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendCreateVideoCommentIfNeeded (comment: MCommentOwnerVideoReply, transaction: Transaction) {
|
||||
if (comment.Video.isLocal()) {
|
||||
const videoWithBlacklist = await VideoModel.loadWithBlacklist(comment.Video.id)
|
||||
|
||||
if (!canVideoBeFederated(videoWithBlacklist)) {
|
||||
logger.debug(`Do not send comment ${comment.url} on a video that cannot be federated`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (comment.heldForReview) {
|
||||
logger.debug(`Do not send comment ${comment.url} that requires approval`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Creating job to send comment %s.', comment.url)
|
||||
|
||||
const byActor = comment.Account.Actor
|
||||
const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
|
||||
|
||||
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction })
|
||||
const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
|
||||
|
||||
const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
|
||||
.map(c => c.Account.Actor)
|
||||
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccount(comment.Video.url, transaction)
|
||||
const audience = getCommentAudience({ comment, video, threadParentComments })
|
||||
|
||||
const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience)
|
||||
|
||||
// This was a reply, send it to the parent actors
|
||||
const actorsException = [ byActor ]
|
||||
await broadcastToActors({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toActors: parentsCommentActors,
|
||||
transaction,
|
||||
actorsException,
|
||||
contextType: 'Comment'
|
||||
})
|
||||
|
||||
// Broadcast to our followers
|
||||
await broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf: [ byActor ],
|
||||
transaction,
|
||||
contextType: 'Comment'
|
||||
})
|
||||
|
||||
// Send to actors involved in the comment
|
||||
if (comment.Video.isLocal()) {
|
||||
const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
|
||||
// Add the actor that commented too
|
||||
actorsInvolvedInComment.push(byActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInComment,
|
||||
transaction,
|
||||
actorsException,
|
||||
contextType: 'Comment'
|
||||
})
|
||||
}
|
||||
|
||||
// Send to origin
|
||||
return transaction.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toActorUrl: videoAccount.Actor.getSharedInbox(),
|
||||
contextType: 'Comment'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function buildCreateActivity<T extends ActivityCreateObject> (
|
||||
url: string,
|
||||
byActor: MActorLight,
|
||||
object: T,
|
||||
audience?: ActivityAudience
|
||||
): ActivityCreate<T> {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Create' as 'Create',
|
||||
id: url + '/activity',
|
||||
actor: byActor.url,
|
||||
object: typeof object === 'string'
|
||||
? object
|
||||
: audiencify(object, audience)
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendVideoRelatedCreateActivity (options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoAccountLight
|
||||
url: string
|
||||
object: any
|
||||
contextType: ContextType
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
return buildCreateActivity(options.url, options.byActor, options.object, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, options)
|
||||
}
|
||||
181
server/core/lib/activitypub/send/send-delete.ts
Normal file
181
server/core/lib/activitypub/send/send-delete.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||
import { VideoShareModel } from '../../../models/video/video-share.js'
|
||||
import { MActorUrl } from '../../../types/models/index.js'
|
||||
import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../types/models/video/index.js'
|
||||
import { audiencify, getCommentAudience } from '../audience.js'
|
||||
import { getDeleteActivityPubUrl } from '../url.js'
|
||||
import {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
sendVideoRelatedActivity,
|
||||
unicastTo
|
||||
} from './shared/send-utils.js'
|
||||
|
||||
async function sendDeleteVideo (options: {
|
||||
video: MVideoAccountLight
|
||||
transaction: Transaction
|
||||
deleteForPrivacyChange?: boolean // default false
|
||||
}) {
|
||||
const { video, transaction, deleteForPrivacyChange = false } = options
|
||||
|
||||
logger.info('Creating job to broadcast delete of video %s.', video.url)
|
||||
|
||||
const byActor = video.VideoChannel.Account.Actor
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getDeleteActivityPubUrl(video.url)
|
||||
|
||||
return buildDeleteActivity(url, video.url, byActor, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, {
|
||||
byActor,
|
||||
video,
|
||||
contextType: 'Delete',
|
||||
transaction,
|
||||
skipPrivacyCheck: deleteForPrivacyChange
|
||||
})
|
||||
}
|
||||
|
||||
async function sendDeleteActor (byActor: ActorModel, transaction: Transaction) {
|
||||
logger.info('Creating job to broadcast delete of actor %s.', byActor.url)
|
||||
|
||||
const url = getDeleteActivityPubUrl(byActor.url)
|
||||
const activity = buildDeleteActivity(url, byActor.url, byActor)
|
||||
|
||||
const actorsInvolved = await VideoShareModel.listActorsWhoSharedVideosOf({ actorOwnerId: byActor.id, transaction })
|
||||
|
||||
// In case the actor did not have any videos
|
||||
const serverActor = await getServerActor()
|
||||
actorsInvolved.push(serverActor)
|
||||
|
||||
actorsInvolved.push(byActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolved,
|
||||
contextType: 'Delete',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
async function sendDeleteVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to send delete of comment %s.', comment.url)
|
||||
|
||||
const isVideoOrigin = comment.Video.isLocal()
|
||||
|
||||
const url = getDeleteActivityPubUrl(comment.url)
|
||||
|
||||
const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
|
||||
|
||||
const byActor = comment.isLocal()
|
||||
? comment.Account.Actor
|
||||
: videoAccount.Actor
|
||||
|
||||
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction })
|
||||
const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
|
||||
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccount(comment.Video.url, transaction)
|
||||
const audience = getCommentAudience({ video, comment, threadParentComments: threadParentCommentsFiltered })
|
||||
const activity = buildDeleteActivity(url, comment.url, byActor, audience)
|
||||
|
||||
// This was a reply, send it to the parent actors
|
||||
const actorsException = [ byActor ]
|
||||
await broadcastToActors({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActors: threadParentCommentsFiltered.map(c => c.Account.Actor),
|
||||
transaction,
|
||||
contextType: 'Delete',
|
||||
actorsException
|
||||
})
|
||||
|
||||
// Broadcast to our followers
|
||||
await broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: [ byActor ],
|
||||
contextType: 'Delete',
|
||||
transaction
|
||||
})
|
||||
|
||||
// Send to actors involved in the comment
|
||||
if (isVideoOrigin) {
|
||||
const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
|
||||
actorsInvolvedInComment.push(byActor) // Add the actor that commented the video
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInComment,
|
||||
transaction,
|
||||
contextType: 'Delete',
|
||||
actorsException
|
||||
})
|
||||
}
|
||||
|
||||
// Send to origin
|
||||
return transaction.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActorUrl: videoAccount.Actor.getSharedInbox(),
|
||||
contextType: 'Delete'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary, transaction: Transaction) {
|
||||
logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
|
||||
|
||||
const byActor = videoPlaylist.OwnerAccount.Actor
|
||||
|
||||
const url = getDeleteActivityPubUrl(videoPlaylist.url)
|
||||
const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
contextType: 'Delete',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendDeleteActor,
|
||||
sendDeleteVideo,
|
||||
sendDeleteVideoComment,
|
||||
sendDeleteVideoPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDeleteActivity (url: string, object: string, byActor: MActorUrl, audience?: ActivityAudience): ActivityDelete {
|
||||
const activity = {
|
||||
type: 'Delete' as 'Delete',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
}
|
||||
|
||||
if (audience) return audiencify(activity, audience)
|
||||
|
||||
return activity
|
||||
}
|
||||
40
server/core/lib/activitypub/send/send-dislike.ts
Normal file
40
server/core/lib/activitypub/send/send-dislike.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityDislike } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getVideoDislikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { sendVideoRelatedActivityToOrigin } from './shared/send-utils.js'
|
||||
|
||||
function sendDislike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
|
||||
logger.info('Creating job to dislike %s.', video.url)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
|
||||
|
||||
return buildDislikeActivity(url, byActor, video, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
|
||||
}
|
||||
|
||||
function buildDislikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityDislike {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
id: url,
|
||||
type: 'Dislike' as 'Dislike',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendDislike,
|
||||
buildDislikeActivity
|
||||
}
|
||||
42
server/core/lib/activitypub/send/send-flag.ts
Normal file
42
server/core/lib/activitypub/send/send-flag.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityFlag } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MAbuseAP, MAccountLight, MActor } from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getLocalAbuseActivityPubUrl } from '../url.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
|
||||
if (!flaggedAccount.Actor.serverId) return // Local user
|
||||
|
||||
const url = getLocalAbuseActivityPubUrl(abuse)
|
||||
|
||||
logger.info('Creating job to send abuse %s.', url)
|
||||
|
||||
// Custom audience, we only send the abuse to the origin instance
|
||||
const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
|
||||
const flagActivity = buildFlagActivity(url, byActor, abuse, audience)
|
||||
|
||||
return t.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: flagActivity,
|
||||
byActor,
|
||||
toActorUrl: flaggedAccount.Actor.getSharedInbox(),
|
||||
contextType: 'Flag'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
const activity = { id: url, actor: byActor.url, ...abuse.toActivityPubObject() }
|
||||
|
||||
return audiencify(activity, audience)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendAbuse
|
||||
}
|
||||
37
server/core/lib/activitypub/send/send-follow.ts
Normal file
37
server/core/lib/activitypub/send/send-follow.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityFollow } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorFollowActors } from '../../../types/models/index.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendFollow (actorFollow: MActorFollowActors, t: Transaction) {
|
||||
const me = actorFollow.ActorFollower
|
||||
const following = actorFollow.ActorFollowing
|
||||
|
||||
// Same server as ours
|
||||
if (!following.serverId) return
|
||||
|
||||
logger.info('Creating job to send follow request to %s.', following.url)
|
||||
|
||||
const data = buildFollowActivity(actorFollow.url, me, following)
|
||||
|
||||
return t.afterCommit(() => {
|
||||
return unicastTo({ data, byActor: me, toActorUrl: following.inboxUrl, contextType: 'Follow' })
|
||||
})
|
||||
}
|
||||
|
||||
function buildFollowActivity (url: string, byActor: MActor, targetActor: MActor): ActivityFollow {
|
||||
return {
|
||||
type: 'Follow',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: targetActor.url
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendFollow,
|
||||
buildFollowActivity
|
||||
}
|
||||
40
server/core/lib/activitypub/send/send-like.ts
Normal file
40
server/core/lib/activitypub/send/send-like.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityLike } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { sendVideoRelatedActivityToOrigin } from './shared/send-utils.js'
|
||||
|
||||
function sendLike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
|
||||
logger.info('Creating job to like %s.', video.url)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
|
||||
|
||||
return buildLikeActivity(url, byActor, video, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
|
||||
}
|
||||
|
||||
function buildLikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityLike {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
id: url,
|
||||
type: 'Like' as 'Like',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendLike,
|
||||
buildLikeActivity
|
||||
}
|
||||
39
server/core/lib/activitypub/send/send-reject.ts
Normal file
39
server/core/lib/activitypub/send/send-reject.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ActivityFollow, ActivityReject } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor } from '../../../types/models/index.js'
|
||||
import { getLocalActorFollowRejectActivityPubUrl } from '../url.js'
|
||||
import { buildFollowActivity } from './send-follow.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendReject (followUrl: string, follower: MActor, following: MActor) {
|
||||
if (!follower.serverId) { // This should never happen
|
||||
logger.warn('Do not sending reject to local follower.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Creating job to reject follower %s.', follower.url)
|
||||
|
||||
const followData = buildFollowActivity(followUrl, follower, following)
|
||||
|
||||
const url = getLocalActorFollowRejectActivityPubUrl()
|
||||
const data = buildRejectActivity(url, following, followData)
|
||||
|
||||
return unicastTo({ data, byActor: following, toActorUrl: follower.inboxUrl, contextType: 'Reject' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendReject
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRejectActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityReject {
|
||||
return {
|
||||
type: 'Reject',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: followActivityData
|
||||
}
|
||||
}
|
||||
36
server/core/lib/activitypub/send/send-reply-approval.ts
Normal file
36
server/core/lib/activitypub/send/send-reply-approval.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ActivityApproveReply, ActivityRejectReply } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
|
||||
import { getLocalApproveReplyActivityPubUrl } from '../url.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
// We can support type: 'RejectReply' in the future
|
||||
export function sendReplyApproval (comment: MCommentOwnerVideoReply, type: 'ApproveReply') {
|
||||
logger.info('Creating job to approve reply %s.', comment.url)
|
||||
|
||||
const data = buildApprovalActivity({ comment, type })
|
||||
|
||||
return unicastTo({
|
||||
data,
|
||||
byActor: comment.Video.VideoChannel.Account.Actor,
|
||||
toActorUrl: comment.Account.Actor.inboxUrl,
|
||||
contextType: type
|
||||
})
|
||||
}
|
||||
|
||||
export function buildApprovalActivity (options: {
|
||||
comment: MCommentOwnerVideoReply
|
||||
type: 'ApproveReply'
|
||||
}): ActivityApproveReply | ActivityRejectReply {
|
||||
const { comment, type } = options
|
||||
|
||||
return {
|
||||
type,
|
||||
id: type === 'ApproveReply'
|
||||
? getLocalApproveReplyActivityPubUrl(comment.Video, comment)
|
||||
: undefined, // 'RejectReply' Not implemented yet
|
||||
actor: comment.Video.VideoChannel.Account.Actor.url,
|
||||
inReplyTo: comment.InReplyToVideoComment?.url ?? comment.Video.url,
|
||||
object: comment.url
|
||||
}
|
||||
}
|
||||
178
server/core/lib/activitypub/send/send-undo.ts
Normal file
178
server/core/lib/activitypub/send/send-undo.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@peertube/peertube-models'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
import {
|
||||
MActor,
|
||||
MActorAudience,
|
||||
MActorFollowActors,
|
||||
MActorLight,
|
||||
MVideo,
|
||||
MVideoAccountLight,
|
||||
MVideoRedundancyVideo,
|
||||
MVideoShare
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getUndoActivityPubUrl, getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { buildAnnounceWithVideoAudience } from './send-announce.js'
|
||||
import { buildCreateActivity } from './send-create.js'
|
||||
import { buildDislikeActivity } from './send-dislike.js'
|
||||
import { buildFollowActivity } from './send-follow.js'
|
||||
import { buildLikeActivity } from './send-like.js'
|
||||
import {
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
sendVideoRelatedActivity,
|
||||
sendVideoRelatedActivityToOrigin,
|
||||
unicastTo
|
||||
} from './shared/send-utils.js'
|
||||
|
||||
function sendUndoFollow (actorFollow: MActorFollowActors, t: Transaction) {
|
||||
const me = actorFollow.ActorFollower
|
||||
const following = actorFollow.ActorFollowing
|
||||
|
||||
// Same server as ours
|
||||
if (!following.serverId) return
|
||||
|
||||
logger.info('Creating job to send an unfollow request to %s.', following.url)
|
||||
|
||||
const undoUrl = getUndoActivityPubUrl(actorFollow.url)
|
||||
|
||||
const followActivity = buildFollowActivity(actorFollow.url, me, following)
|
||||
const undoActivity = undoActivityData(undoUrl, me, followActivity)
|
||||
|
||||
t.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: undoActivity,
|
||||
byActor: me,
|
||||
toActorUrl: following.inboxUrl,
|
||||
contextType: 'Follow'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendUndoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to undo announce %s.', videoShare.url)
|
||||
|
||||
const undoUrl = getUndoActivityPubUrl(videoShare.url)
|
||||
|
||||
const announce = buildAnnounceWithVideoAudience(byActor, videoShare, video)
|
||||
const undoActivity = undoActivityData(undoUrl, byActor, announce)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: undoActivity,
|
||||
byActor,
|
||||
toFollowersOf: await getActorsInvolvedInVideo(video, transaction),
|
||||
transaction,
|
||||
actorsException: [ byActor ],
|
||||
contextType: 'Announce'
|
||||
})
|
||||
}
|
||||
|
||||
async function sendUndoCacheFile (byActor: MActor, redundancyModel: MVideoRedundancyVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to undo cache file %s.', redundancyModel.url)
|
||||
|
||||
const associatedVideo = redundancyModel.getVideo()
|
||||
if (!associatedVideo) {
|
||||
logger.warn('Cannot send undo activity for redundancy %s: no video files associated.', redundancyModel.url)
|
||||
return
|
||||
}
|
||||
|
||||
const video = await VideoModel.loadFull(associatedVideo.id)
|
||||
const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
|
||||
|
||||
return sendUndoVideoRelatedActivity({
|
||||
byActor,
|
||||
video,
|
||||
url: redundancyModel.url,
|
||||
activity: createActivity,
|
||||
contextType: 'CacheFile',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendUndoLike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
|
||||
logger.info('Creating job to undo a like of video %s.', video.url)
|
||||
|
||||
const likeUrl = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
|
||||
const likeActivity = buildLikeActivity(likeUrl, byActor, video)
|
||||
|
||||
return sendUndoVideoRateToOriginActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t })
|
||||
}
|
||||
|
||||
async function sendUndoDislike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
|
||||
logger.info('Creating job to undo a dislike of video %s.', video.url)
|
||||
|
||||
const dislikeUrl = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
|
||||
const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
|
||||
|
||||
return sendUndoVideoRateToOriginActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendUndoAnnounce,
|
||||
sendUndoCacheFile,
|
||||
sendUndoDislike,
|
||||
sendUndoFollow,
|
||||
sendUndoLike
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function undoActivityData<T extends ActivityUndoObject> (
|
||||
url: string,
|
||||
byActor: MActorAudience,
|
||||
object: T,
|
||||
audience?: ActivityAudience
|
||||
): ActivityUndo<T> {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Undo' as 'Undo',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
async function sendUndoVideoRelatedActivity (options: {
|
||||
byActor: MActor
|
||||
video: MVideoAccountLight
|
||||
url: string
|
||||
activity: ActivityUndoObject
|
||||
contextType: ContextType
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const undoUrl = getUndoActivityPubUrl(options.url)
|
||||
|
||||
return undoActivityData(undoUrl, options.byActor, options.activity, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, options)
|
||||
}
|
||||
|
||||
async function sendUndoVideoRateToOriginActivity (options: {
|
||||
byActor: MActor
|
||||
video: MVideoAccountLight
|
||||
url: string
|
||||
activity: ActivityLike | ActivityDislike
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const undoUrl = getUndoActivityPubUrl(options.url)
|
||||
|
||||
return undoActivityData(undoUrl, options.byActor, options.activity, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { ...options, contextType: 'Rate' })
|
||||
}
|
||||
204
server/core/lib/activitypub/send/send-update.ts
Normal file
204
server/core/lib/activitypub/send/send-update.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
|
||||
import { MPlayerSetting } from '@server/types/models/video/player-setting.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { AccountModel } from '../../../models/account/account.js'
|
||||
import { VideoShareModel } from '../../../models/video/video-share.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
import {
|
||||
MAccountDefault,
|
||||
MActor,
|
||||
MActorLight,
|
||||
MChannelDefault,
|
||||
MVideoAPLight,
|
||||
MVideoFullLight,
|
||||
MVideoPlaylistFull,
|
||||
MVideoRedundancyVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../audience.js'
|
||||
import { getLocalChannelPlayerSettingsActivityPubUrl, getLocalVideoPlayerSettingsActivityPubUrl, getUpdateActivityPubUrl } from '../url.js'
|
||||
import { canVideoBeFederated } from '../videos/federate.js'
|
||||
import { broadcastToFollowers, getActorsInvolvedInVideo, sendVideoRelatedActivity } from './shared/send-utils.js'
|
||||
|
||||
export async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
|
||||
if (!canVideoBeFederated(videoArg)) return undefined
|
||||
|
||||
const video = await videoArg.lightAPToFullAP(transaction)
|
||||
|
||||
logger.info('Creating job to update video %s.', video.url)
|
||||
|
||||
const byActor = overriddenByActor || video.VideoChannel.Account.Actor
|
||||
|
||||
const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
|
||||
|
||||
const videoObject = await video.toActivityPubObject()
|
||||
const audience = getVideoAudience({ account: video.VideoChannel.Account, channel: video.VideoChannel, privacy: video.privacy })
|
||||
|
||||
const updateActivity = buildUpdateActivity(url, byActor, videoObject, audience)
|
||||
|
||||
const actorsInvolved = await getActorsInvolvedInVideo(video, transaction)
|
||||
if (overriddenByActor) actorsInvolved.push(overriddenByActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolved,
|
||||
contextType: 'Video',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) {
|
||||
const byActor = accountOrChannel.Actor
|
||||
|
||||
logger.info('Creating job to update actor %s.', byActor.url)
|
||||
|
||||
const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
|
||||
const accountOrChannelObject = await (accountOrChannel as any).toActivityPubObject() // FIXME: typescript bug?
|
||||
const audience = getPublicAudience(byActor)
|
||||
const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: await getToFollowersOfForActor(accountOrChannel, transaction),
|
||||
transaction,
|
||||
contextType: 'Actor'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) {
|
||||
logger.info('Creating job to update cache file %s.', redundancyModel.url)
|
||||
|
||||
const associatedVideo = redundancyModel.getVideo()
|
||||
if (!associatedVideo) {
|
||||
logger.warn('Cannot send update activity for redundancy %s: no video files associated.', redundancyModel.url)
|
||||
return
|
||||
}
|
||||
|
||||
const video = await VideoModel.loadFull(associatedVideo.id)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const redundancyObject = redundancyModel.toActivityPubObject()
|
||||
const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
|
||||
|
||||
return buildUpdateActivity(url, byActor, redundancyObject, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'CacheFile' })
|
||||
}
|
||||
|
||||
export async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) {
|
||||
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
|
||||
|
||||
const byActor = videoPlaylist.OwnerAccount.Actor
|
||||
|
||||
logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
|
||||
|
||||
const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
|
||||
|
||||
const object = await videoPlaylist.toActivityPubObject(null, transaction)
|
||||
const audience = getPlaylistAudience(byActor, videoPlaylist.privacy)
|
||||
|
||||
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
transaction,
|
||||
contextType: 'Playlist'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateVideoPlayerSettings (video: MVideoFullLight, settings: MPlayerSetting, transaction: Transaction) {
|
||||
if (!canVideoBeFederated(video, false)) return
|
||||
|
||||
const byActor = video.VideoChannel.Account.Actor
|
||||
const settingsUrl = getLocalVideoPlayerSettingsActivityPubUrl(video)
|
||||
|
||||
logger.info('Creating job to update video player settings ' + settingsUrl)
|
||||
|
||||
const updateUrl = getUpdateActivityPubUrl(settingsUrl, settings.updatedAt.toISOString())
|
||||
|
||||
const object = PlayerSettingModel.formatAPPlayerSetting({ settings, video, channel: undefined })
|
||||
const audience = getVideoAudience({ account: video.VideoChannel.Account, channel: video.VideoChannel, privacy: video.privacy })
|
||||
|
||||
const updateActivity = buildUpdateActivity(updateUrl, byActor, object, audience)
|
||||
|
||||
const toFollowersOf = await getActorsInvolvedInVideo(video, transaction)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
transaction,
|
||||
contextType: 'PlayerSettings'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateChannelPlayerSettings (channel: MChannelDefault, settings: MPlayerSetting, transaction: Transaction) {
|
||||
const byActor = channel.Actor
|
||||
const settingsUrl = getLocalChannelPlayerSettingsActivityPubUrl(channel.Actor.preferredUsername)
|
||||
|
||||
logger.info('Creating job to update channel player settings actor ' + settingsUrl)
|
||||
|
||||
const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
|
||||
const object = PlayerSettingModel.formatAPPlayerSetting({ settings, video: undefined, channel })
|
||||
|
||||
const audience = getPublicAudience(byActor)
|
||||
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: await getToFollowersOfForActor(channel, transaction),
|
||||
transaction,
|
||||
contextType: 'PlayerSettings'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildUpdateActivity (
|
||||
url: string,
|
||||
byActor: MActorLight,
|
||||
object: ActivityUpdateObject,
|
||||
audience?: ActivityAudience
|
||||
): ActivityUpdate<ActivityUpdateObject> {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Update' as 'Update',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: audiencify(object, audience)
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
async function getToFollowersOfForActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction?: Transaction) {
|
||||
let actorsInvolved: MActor[]
|
||||
if (accountOrChannel instanceof AccountModel) {
|
||||
// Actors that shared my videos are involved too
|
||||
actorsInvolved = await VideoShareModel.listActorsWhoSharedVideosOf({ actorOwnerId: accountOrChannel.Actor.id, transaction })
|
||||
} else {
|
||||
// Actors that shared videos of my channel are involved too
|
||||
actorsInvolved = await VideoShareModel.listActorsByVideoChannel({ channelId: accountOrChannel.id, transaction })
|
||||
}
|
||||
|
||||
actorsInvolved.push(accountOrChannel.Actor)
|
||||
|
||||
return actorsInvolved
|
||||
}
|
||||
69
server/core/lib/activitypub/send/send-view.ts
Normal file
69
server/core/lib/activitypub/send/send-view.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ActivityAudience, ActivityView } from '@peertube/peertube-models'
|
||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||
import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getLocalVideoViewActivityPubUrl } from '../url.js'
|
||||
import { sendVideoRelatedActivity } from './shared/send-utils.js'
|
||||
|
||||
async function sendView (options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable
|
||||
viewerIdentifier: string
|
||||
viewersCount?: number
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { byActor, viewersCount, video, viewerIdentifier, transaction } = options
|
||||
|
||||
logger.info('Creating job to send %s of %s.', viewersCount !== undefined ? 'viewer' : 'view', video.url)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getLocalVideoViewActivityPubUrl(byActor, video, viewerIdentifier)
|
||||
|
||||
return buildViewActivity({ url, byActor, video, audience, viewersCount })
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View', parallelizable: true })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendView
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildViewActivity (options: {
|
||||
url: string
|
||||
byActor: MActorAudience
|
||||
video: MVideoUrl
|
||||
viewersCount?: number
|
||||
audience?: ActivityAudience
|
||||
}): ActivityView {
|
||||
const { url, byActor, viewersCount, video, audience = getPublicAudience(byActor) } = options
|
||||
|
||||
const base = {
|
||||
id: url,
|
||||
type: 'View' as 'View',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
}
|
||||
|
||||
if (viewersCount === undefined) {
|
||||
return audiencify(base, audience)
|
||||
}
|
||||
|
||||
return audiencify({
|
||||
...base,
|
||||
|
||||
expires: new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString(),
|
||||
|
||||
result: {
|
||||
interactionType: 'WatchAction',
|
||||
type: 'InteractionCounter',
|
||||
userInteractionCount: viewersCount
|
||||
}
|
||||
}, audience)
|
||||
}
|
||||
308
server/core/lib/activitypub/send/shared/send-utils.ts
Normal file
308
server/core/lib/activitypub/send/shared/send-utils.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Activity, ActivityAudience, ActivitypubHttpBroadcastPayload, ContextType } from '@peertube/peertube-models'
|
||||
import { ActorFollowHealthCache } from '@server/lib/actor-follow-health-cache.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { VideoShareModel } from '@server/models/video/video-share.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { afterCommitIfTransaction } from '../../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../../helpers/logger.js'
|
||||
import { ActorFollowModel } from '../../../../models/actor/actor-follow.js'
|
||||
import { ActorModel } from '../../../../models/actor/actor.js'
|
||||
import {
|
||||
MActor,
|
||||
MActorId,
|
||||
MActorLight,
|
||||
MActorWithInboxes,
|
||||
MVideoAccountLight,
|
||||
MVideoId,
|
||||
MVideoImmutable
|
||||
} from '../../../../types/models/index.js'
|
||||
import { JobQueue } from '../../../job-queue/index.js'
|
||||
import { getDirectAudience, getVideoAudience } from '../../audience.js'
|
||||
|
||||
async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable | MVideoAccountLight
|
||||
contextType: ContextType
|
||||
parallelizable?: boolean
|
||||
transaction?: Transaction
|
||||
skipPrivacyCheck?: boolean
|
||||
}) {
|
||||
const { byActor, transaction, contextType, parallelizable, skipPrivacyCheck } = options
|
||||
|
||||
// Send to origin
|
||||
if (options.video.isLocal() === false) {
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, options)
|
||||
}
|
||||
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccount(options.video.url, transaction)
|
||||
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, transaction)
|
||||
|
||||
const audience = getVideoAudience({
|
||||
account: video.VideoChannel.Account,
|
||||
channel: video.VideoChannel,
|
||||
privacy: video.privacy,
|
||||
skipPrivacyCheck
|
||||
})
|
||||
const activity = activityBuilder(audience)
|
||||
|
||||
const actorsException = [ byActor ]
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInVideo,
|
||||
transaction,
|
||||
actorsException,
|
||||
parallelizable,
|
||||
contextType
|
||||
})
|
||||
}
|
||||
|
||||
async function sendVideoRelatedActivityToOrigin (
|
||||
activityBuilder: (audience: ActivityAudience) => Activity,
|
||||
options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable | MVideoAccountLight
|
||||
contextType: ContextType
|
||||
transaction?: Transaction
|
||||
}
|
||||
) {
|
||||
const { byActor, video, transaction, contextType } = options
|
||||
|
||||
if (video.isLocal()) throw new Error('Cannot send activity to owned video origin ' + video.url)
|
||||
|
||||
let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor
|
||||
if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction)
|
||||
|
||||
const audience = getDirectAudience(accountActor)
|
||||
const activity = activityBuilder(audience)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return unicastTo({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActorUrl: accountActor.getSharedInbox(),
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function forwardVideoRelatedActivity (options: {
|
||||
activity: Activity
|
||||
transaction: Transaction
|
||||
followersException: MActorWithInboxes[]
|
||||
video: MVideoId
|
||||
}) {
|
||||
const { activity, transaction, followersException, video } = options
|
||||
|
||||
// Mastodon does not add our announces in audience, so we forward to them manually
|
||||
const additionalActors = await getActorsInvolvedInVideo(video, transaction)
|
||||
const additionalFollowerUrls = additionalActors.map(a => a.followersUrl)
|
||||
|
||||
logger.info('Forwarding activity %s.', activity.id)
|
||||
|
||||
const followersUrls = additionalFollowerUrls
|
||||
|
||||
const toActorFollowers = await ActorModel.listByFollowersUrls(followersUrls, transaction)
|
||||
const uris = await computeFollowerUris(toActorFollowers, followersException, transaction)
|
||||
|
||||
if (uris.length === 0) {
|
||||
logger.info('0 followers for %s, no forwarding.', toActorFollowers.map(a => a.id).join(', '))
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.debug('Creating forwarding job.', { uris })
|
||||
|
||||
const payload: ActivitypubHttpBroadcastPayload = {
|
||||
uris,
|
||||
body: activity,
|
||||
contextType: null
|
||||
}
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => JobQueue.Instance.createJobAsync({ type: 'activitypub-http-broadcast', payload }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function broadcastToFollowers (options: {
|
||||
data: any
|
||||
byActor: MActorId
|
||||
toFollowersOf: MActorId[]
|
||||
transaction: Transaction
|
||||
contextType: ContextType
|
||||
|
||||
parallelizable?: boolean
|
||||
actorsException?: MActorWithInboxes[]
|
||||
}) {
|
||||
const { data, byActor, toFollowersOf, transaction, contextType, actorsException = [], parallelizable } = options
|
||||
|
||||
const uris = await computeFollowerUris(toFollowersOf, actorsException, transaction)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return broadcastTo({
|
||||
uris,
|
||||
data,
|
||||
byActor,
|
||||
parallelizable,
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function broadcastToActors (options: {
|
||||
data: any
|
||||
byActor: MActorId
|
||||
toActors: MActor[]
|
||||
transaction: Transaction
|
||||
contextType: ContextType
|
||||
actorsException?: MActorWithInboxes[]
|
||||
}) {
|
||||
const { data, byActor, toActors, transaction, contextType, actorsException = [] } = options
|
||||
|
||||
const uris = await computeUris(toActors, actorsException)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return broadcastTo({
|
||||
uris,
|
||||
data,
|
||||
byActor,
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function broadcastTo (options: {
|
||||
uris: string[]
|
||||
data: any
|
||||
byActor: MActorId
|
||||
contextType: ContextType
|
||||
parallelizable?: boolean // default to false
|
||||
}) {
|
||||
const { uris, data, byActor, contextType, parallelizable } = options
|
||||
|
||||
if (uris.length === 0) return undefined
|
||||
|
||||
const broadcastUris: string[] = []
|
||||
const unicastUris: string[] = []
|
||||
|
||||
// Bad URIs could be slow to respond, prefer to process them in a dedicated queue
|
||||
for (const uri of uris) {
|
||||
if (ActorFollowHealthCache.Instance.isBadInbox(uri)) {
|
||||
unicastUris.push(uri)
|
||||
} else {
|
||||
broadcastUris.push(uri)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Creating broadcast job.', { broadcastUris, unicastUris })
|
||||
|
||||
if (broadcastUris.length !== 0) {
|
||||
const payload = {
|
||||
uris: broadcastUris,
|
||||
signatureActorId: byActor.id,
|
||||
body: data,
|
||||
contextType
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({
|
||||
type: parallelizable
|
||||
? 'activitypub-http-broadcast-parallel'
|
||||
: 'activitypub-http-broadcast',
|
||||
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
for (const unicastUri of unicastUris) {
|
||||
const payload = {
|
||||
uri: unicastUri,
|
||||
signatureActorId: byActor.id,
|
||||
body: data,
|
||||
contextType
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
|
||||
}
|
||||
}
|
||||
|
||||
function unicastTo (options: {
|
||||
data: any
|
||||
byActor: MActorId
|
||||
toActorUrl: string
|
||||
contextType: ContextType
|
||||
}) {
|
||||
const { data, byActor, toActorUrl, contextType } = options
|
||||
|
||||
logger.debug('Creating unicast job.', { uri: toActorUrl })
|
||||
|
||||
const payload = {
|
||||
uri: toActorUrl,
|
||||
signatureActorId: byActor.id,
|
||||
body: data,
|
||||
contextType
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) {
|
||||
const actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t)
|
||||
|
||||
const alreadyLoadedActor = (video as VideoModel).VideoChannel?.Account?.Actor
|
||||
|
||||
const videoActor = alreadyLoadedActor?.url && alreadyLoadedActor?.followersUrl
|
||||
? alreadyLoadedActor
|
||||
: await ActorModel.loadAccountActorFollowerUrlByVideoId(video.id, t)
|
||||
|
||||
if (videoActor) actors.push(videoActor)
|
||||
|
||||
return actors
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
forwardVideoRelatedActivity,
|
||||
sendVideoRelatedActivity,
|
||||
sendVideoRelatedActivityToOrigin,
|
||||
unicastTo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function computeFollowerUris (toFollowersOf: MActorId[], actorsException: MActorWithInboxes[], t: Transaction) {
|
||||
const toActorFollowerIds = toFollowersOf.map(a => a.id)
|
||||
|
||||
const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t)
|
||||
const sharedInboxesException = await buildSharedInboxesException(actorsException)
|
||||
|
||||
return result.data.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
|
||||
}
|
||||
|
||||
async function computeUris (toActors: MActor[], actorsException: MActorWithInboxes[] = []) {
|
||||
const serverActor = await getServerActor()
|
||||
const targetUrls = toActors
|
||||
.filter(a => a.id !== serverActor.id) // Don't send to ourselves
|
||||
.map(a => a.getSharedInbox())
|
||||
|
||||
const toActorSharedInboxesSet = new Set(targetUrls)
|
||||
|
||||
const sharedInboxesException = await buildSharedInboxesException(actorsException)
|
||||
return Array.from(toActorSharedInboxesSet)
|
||||
.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
|
||||
}
|
||||
|
||||
async function buildSharedInboxesException (actorsException: MActorWithInboxes[]) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
return actorsException
|
||||
.map(f => f.getSharedInbox())
|
||||
.concat([ serverActor.sharedInboxUrl ])
|
||||
}
|
||||
116
server/core/lib/activitypub/share.ts
Normal file
116
server/core/lib/activitypub/share.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import Bluebird from 'bluebird'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger, loggerTagsFactory } from '../../helpers/logger.js'
|
||||
import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
|
||||
import { VideoShareModel } from '../../models/video/video-share.js'
|
||||
import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video/index.js'
|
||||
import { fetchAP, getAPId } from './activity.js'
|
||||
import { getOrCreateAPActor } from './actors/index.js'
|
||||
import { sendUndoAnnounce, sendVideoAnnounce } from './send/index.js'
|
||||
import { checkUrlsSameHost, getLocalVideoAnnounceActivityPubUrl } from './url.js'
|
||||
|
||||
const lTags = loggerTagsFactory('share')
|
||||
|
||||
export async function changeVideoChannelShare (
|
||||
video: MVideoAccountLight,
|
||||
oldVideoChannel: MChannelActorLight,
|
||||
t: Transaction
|
||||
) {
|
||||
logger.info(
|
||||
'Updating video channel of video %s: %s -> %s.',
|
||||
video.uuid,
|
||||
oldVideoChannel.name,
|
||||
video.VideoChannel.name,
|
||||
lTags(video.uuid)
|
||||
)
|
||||
|
||||
await undoShareByVideoChannel(video, oldVideoChannel, t)
|
||||
|
||||
await shareByVideoChannel(video, t)
|
||||
}
|
||||
|
||||
export async function addVideoShares (shareUrls: string[], video: MVideoId) {
|
||||
await Bluebird.map(shareUrls, async shareUrl => {
|
||||
try {
|
||||
await addVideoShare(shareUrl, video)
|
||||
} catch (err) {
|
||||
if (err.statusCode === HttpStatusCode.NOT_FOUND_404 || err.statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.debug(`Cannot add share ${shareUrl} that does not exist anymore`, { err })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Cannot add share ${shareUrl}`, { err })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
export async function shareByServer (video: MVideo, t: Transaction) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
const serverShareUrl = getLocalVideoAnnounceActivityPubUrl(serverActor, video)
|
||||
const [ serverShare ] = await VideoShareModel.findOrCreate({
|
||||
defaults: {
|
||||
actorId: serverActor.id,
|
||||
videoId: video.id,
|
||||
url: serverShareUrl
|
||||
},
|
||||
where: {
|
||||
url: serverShareUrl
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return sendVideoAnnounce(serverActor, serverShare, video, t)
|
||||
}
|
||||
|
||||
export async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
|
||||
const videoChannelShareUrl = getLocalVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
|
||||
const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
|
||||
defaults: {
|
||||
actorId: video.VideoChannel.Actor.id,
|
||||
videoId: video.id,
|
||||
url: videoChannelShareUrl
|
||||
},
|
||||
where: {
|
||||
url: videoChannelShareUrl
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function addVideoShare (shareUrl: string, video: MVideoId) {
|
||||
const { body } = await fetchAP<any>(shareUrl)
|
||||
if (!body?.actor) throw new Error('Body or body actor is invalid')
|
||||
|
||||
const actorUrl = getAPId(body.actor)
|
||||
if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
|
||||
throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
|
||||
}
|
||||
|
||||
const actor = await getOrCreateAPActor(actorUrl)
|
||||
|
||||
const entry = {
|
||||
actorId: actor.id,
|
||||
videoId: video.id,
|
||||
url: shareUrl
|
||||
}
|
||||
|
||||
await VideoShareModel.upsert(entry)
|
||||
}
|
||||
|
||||
async function undoShareByVideoChannel (video: MVideo, oldVideoChannel: MChannelActorLight, t: Transaction) {
|
||||
// Load old share
|
||||
const oldShare = await VideoShareModel.load(oldVideoChannel.Actor.id, video.id, t)
|
||||
if (!oldShare) return new Error(`Cannot find old video channel share ${oldVideoChannel.Actor.id} for video ${video.id}`)
|
||||
|
||||
await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t)
|
||||
await oldShare.destroy({ transaction: t })
|
||||
}
|
||||
160
server/core/lib/activitypub/url.ts
Normal file
160
server/core/lib/activitypub/url.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { REMOTE_SCHEME, WEBSERVER } from '../../initializers/constants.js'
|
||||
import {
|
||||
MAbuseFull,
|
||||
MAbuseId,
|
||||
MActor,
|
||||
MActorFollow,
|
||||
MActorId,
|
||||
MActorUrl,
|
||||
MCommentId,
|
||||
MLocalVideoViewer,
|
||||
MVideoId,
|
||||
MVideoPlaylistElement,
|
||||
MVideoUUID,
|
||||
MVideoUrl,
|
||||
MVideoWithHost
|
||||
} from '../../types/models/index.js'
|
||||
import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist.js'
|
||||
import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist.js'
|
||||
|
||||
export function getLocalVideoActivityPubUrl (video: MVideoUUID) {
|
||||
return WEBSERVER.URL + '/videos/watch/' + video.uuid
|
||||
}
|
||||
|
||||
export function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) {
|
||||
return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
|
||||
}
|
||||
|
||||
export function getLocalVideoPlaylistElementActivityPubUrl (playlist: MVideoPlaylistUUID, element: MVideoPlaylistElement) {
|
||||
return WEBSERVER.URL + '/video-playlists/' + playlist.uuid + '/videos/' + element.id
|
||||
}
|
||||
|
||||
export function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) {
|
||||
return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}`
|
||||
}
|
||||
|
||||
export function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) {
|
||||
return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
|
||||
}
|
||||
|
||||
export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
|
||||
return WEBSERVER.URL + '/video-channels/' + videoChannelName
|
||||
}
|
||||
|
||||
export function getLocalChannelPlayerSettingsActivityPubUrl (videoChannelName: string) {
|
||||
return WEBSERVER.URL + '/video-channels/' + videoChannelName + '/player-settings'
|
||||
}
|
||||
|
||||
export function getLocalAccountActivityPubUrl (accountName: string) {
|
||||
return WEBSERVER.URL + '/accounts/' + accountName
|
||||
}
|
||||
|
||||
export function getLocalAbuseActivityPubUrl (abuse: MAbuseId) {
|
||||
return WEBSERVER.URL + '/admin/abuses/' + abuse.id
|
||||
}
|
||||
|
||||
export function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) {
|
||||
return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier
|
||||
}
|
||||
|
||||
export function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
|
||||
return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
|
||||
}
|
||||
|
||||
export function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
|
||||
return byActor.url + '/likes/' + video.id
|
||||
}
|
||||
|
||||
export function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
|
||||
return byActor.url + '/dislikes/' + video.id
|
||||
}
|
||||
|
||||
export function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/announces'
|
||||
}
|
||||
|
||||
export function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/comments'
|
||||
}
|
||||
|
||||
export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/chapters'
|
||||
}
|
||||
|
||||
export function getLocalVideoPlayerSettingsActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/player-settings'
|
||||
}
|
||||
|
||||
export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/likes'
|
||||
}
|
||||
|
||||
export function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/dislikes'
|
||||
}
|
||||
|
||||
export function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) {
|
||||
return follower.url + '/follows/' + following.id
|
||||
}
|
||||
|
||||
export function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) {
|
||||
return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id
|
||||
}
|
||||
|
||||
export function getLocalActorFollowRejectActivityPubUrl () {
|
||||
return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString()
|
||||
}
|
||||
|
||||
export function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) {
|
||||
return video.url + '/announces/' + byActor.id
|
||||
}
|
||||
|
||||
export function getDeleteActivityPubUrl (originalUrl: string) {
|
||||
return originalUrl + '/delete'
|
||||
}
|
||||
|
||||
export function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) {
|
||||
return originalUrl + '/updates/' + updatedAt
|
||||
}
|
||||
|
||||
export function getUndoActivityPubUrl (originalUrl: string) {
|
||||
return originalUrl + '/undo'
|
||||
}
|
||||
|
||||
export function getLocalActorPlayerSettingsActivityPubUrl (actor: MActorUrl) {
|
||||
return actor.url + '/player-settings'
|
||||
}
|
||||
|
||||
export function getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: MCommentId) {
|
||||
return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Try to fetch target URL
|
||||
// If it doesn't exist anymore use abuse URL
|
||||
export function getAbuseIdentifier (abuse: MAbuseFull) {
|
||||
return abuse.VideoAbuse?.Video?.url ||
|
||||
abuse.VideoCommentAbuse?.VideoComment?.url ||
|
||||
abuse.FlaggedAccount?.Actor?.url ||
|
||||
abuse.id + ''
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) {
|
||||
if (!scheme) scheme = REMOTE_SCHEME.HTTP
|
||||
|
||||
const host = video.VideoChannel.Actor.Server.host
|
||||
|
||||
return scheme + '://' + host + path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function checkUrlsSameHost (url1: string, url2: string) {
|
||||
const idHost = new URL(url1).host
|
||||
const actorHost = new URL(url2).host
|
||||
|
||||
return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
|
||||
}
|
||||
16
server/core/lib/activitypub/video-chapters.ts
Normal file
16
server/core/lib/activitypub/video-chapters.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { VideoChapterObject } from '@peertube/peertube-models'
|
||||
import { MVideo, MVideoChapter } from '@server/types/models/index.js'
|
||||
|
||||
export function buildChaptersAPHasPart (video: MVideo, chapters: MVideoChapter[]) {
|
||||
const hasPart: VideoChapterObject[] = []
|
||||
|
||||
if (chapters.length !== 0) {
|
||||
for (let i = 0; i < chapters.length - 1; i++) {
|
||||
hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
|
||||
}
|
||||
|
||||
hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video, nextChapter: null }))
|
||||
}
|
||||
|
||||
return hasPart
|
||||
}
|
||||
258
server/core/lib/activitypub/video-comments.ts
Normal file
258
server/core/lib/activitypub/video-comments.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { HttpStatusCode, VideoCommentPolicy } from '@peertube/peertube-models'
|
||||
import Bluebird from 'bluebird'
|
||||
import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
|
||||
import { VideoCommentModel } from '../../models/video/video-comment.js'
|
||||
import {
|
||||
MComment,
|
||||
MCommentOwner,
|
||||
MCommentOwnerVideo,
|
||||
MVideoAccountLight,
|
||||
MVideoAccountLightBlacklistAllFiles
|
||||
} from '../../types/models/video/index.js'
|
||||
import { AutomaticTagger } from '../automatic-tags/automatic-tagger.js'
|
||||
import { setAndSaveCommentAutomaticTags } from '../automatic-tags/automatic-tags.js'
|
||||
import { isRemoteVideoCommentAccepted } from '../moderation.js'
|
||||
import { Hooks } from '../plugins/hooks.js'
|
||||
import { shouldCommentBeHeldForReview } from '../video-comment.js'
|
||||
import { fetchAP } from './activity.js'
|
||||
import { getOrCreateAPActor } from './actors/index.js'
|
||||
import { checkUrlsSameHost } from './url.js'
|
||||
import { canVideoBeFederated, getOrCreateAPVideo } from './videos/index.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
|
||||
type ResolveThreadParams = {
|
||||
url: string
|
||||
comments?: MCommentOwner[]
|
||||
isVideo?: boolean
|
||||
commentCreated?: boolean
|
||||
}
|
||||
type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }>
|
||||
|
||||
export async function addVideoComments (commentUrls: string[]) {
|
||||
if (CONFIG.VIDEO_COMMENTS.ACCEPT_REMOTE_COMMENTS !== true) return
|
||||
|
||||
return Bluebird.map(commentUrls, async commentUrl => {
|
||||
try {
|
||||
await resolveThread({ url: commentUrl, isVideo: false })
|
||||
} catch (err) {
|
||||
if (err.statusCode === HttpStatusCode.NOT_FOUND_404 || err.statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.debug(`Cannot resolve thread ${commentUrl} that does not exist anymore`, { err })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Cannot resolve thread ${commentUrl}`, { err })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
export async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
|
||||
const { url, isVideo } = params
|
||||
|
||||
if (params.commentCreated === undefined) params.commentCreated = false
|
||||
if (params.comments === undefined) params.comments = []
|
||||
|
||||
// If it is not a video, or if we don't know if it's a video, try to get the thread from DB
|
||||
if (isVideo === false || isVideo === undefined) {
|
||||
const result = await resolveCommentFromDB(params)
|
||||
if (result) return result
|
||||
}
|
||||
|
||||
try {
|
||||
// If it is a video, or if we don't know if it's a video
|
||||
if (isVideo === true || isVideo === undefined) {
|
||||
// Keep await so we catch the exception
|
||||
return await tryToResolveThreadFromVideo(params)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err })
|
||||
}
|
||||
|
||||
return resolveRemoteParentComment(params)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveCommentFromDB (params: ResolveThreadParams) {
|
||||
const { url, comments, commentCreated } = params
|
||||
|
||||
const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url)
|
||||
if (!commentFromDatabase) return undefined
|
||||
|
||||
let parentComments = comments.concat([ commentFromDatabase ])
|
||||
|
||||
// Speed up things and resolve directly the thread
|
||||
if (commentFromDatabase.InReplyToVideoComment) {
|
||||
const data = await VideoCommentModel.listThreadParentComments({ comment: commentFromDatabase, order: 'DESC' })
|
||||
|
||||
parentComments = parentComments.concat(data)
|
||||
}
|
||||
|
||||
return resolveThread({
|
||||
url: commentFromDatabase.Video.url,
|
||||
comments: parentComments,
|
||||
isVideo: true,
|
||||
commentCreated
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
|
||||
const { url, comments, commentCreated } = params
|
||||
|
||||
// Maybe it's a reply to a video?
|
||||
// If yes, it's done: we resolved all the thread
|
||||
const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false }
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam })
|
||||
|
||||
if (video.isLocal() && !canVideoBeFederated(video)) {
|
||||
throw new Error('Cannot resolve thread of video that is not compatible with federation')
|
||||
}
|
||||
|
||||
if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let resultComment: MCommentOwnerVideo
|
||||
if (comments.length !== 0) {
|
||||
const firstReply = comments[comments.length - 1] as MCommentOwnerVideo
|
||||
firstReply.inReplyToCommentId = null
|
||||
firstReply.originCommentId = null
|
||||
firstReply.videoId = video.id
|
||||
firstReply.changed('updatedAt', true)
|
||||
firstReply.Video = video
|
||||
|
||||
if (await isRemoteCommentAccepted(firstReply) !== true) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const firstReplyAutomaticTags = await getAutomaticTagsAndAssignReview(firstReply, video)
|
||||
comments[comments.length - 1] = await firstReply.save()
|
||||
|
||||
await setAndSaveCommentAutomaticTags({ comment: firstReply, automaticTags: firstReplyAutomaticTags })
|
||||
|
||||
for (let i = comments.length - 2; i >= 0; i--) {
|
||||
const comment = comments[i] as MCommentOwnerVideo
|
||||
comment.originCommentId = firstReply.id
|
||||
comment.inReplyToCommentId = comments[i + 1].id
|
||||
comment.videoId = video.id
|
||||
comment.changed('updatedAt', true)
|
||||
comment.Video = video
|
||||
|
||||
if (await isRemoteCommentAccepted(comment) !== true) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const automaticTags = await getAutomaticTagsAndAssignReview(comment, video)
|
||||
|
||||
comments[i] = await comment.save()
|
||||
|
||||
await setAndSaveCommentAutomaticTags({ comment, automaticTags })
|
||||
}
|
||||
|
||||
resultComment = comments[0] as MCommentOwnerVideo
|
||||
}
|
||||
|
||||
return { video, comment: resultComment, commentCreated }
|
||||
}
|
||||
|
||||
async function getAutomaticTagsAndAssignReview (comment: MComment, video: MVideoAccountLight) {
|
||||
// Remote comment already exists in database or remote video -> we don't need to rebuild automatic tags
|
||||
if (comment.id) return []
|
||||
|
||||
const ownerAccount = video.VideoChannel.Account
|
||||
|
||||
const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ ownerAccount, text: comment.text })
|
||||
|
||||
// Third parties rely on origin, so if origin has the comment it's not held for review
|
||||
if (video.isLocal() || comment.isLocal()) {
|
||||
comment.heldForReview = await shouldCommentBeHeldForReview({ user: null, video, automaticTags })
|
||||
} else {
|
||||
comment.heldForReview = false
|
||||
}
|
||||
|
||||
return automaticTags
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveRemoteParentComment (params: ResolveThreadParams) {
|
||||
const { url, comments } = params
|
||||
|
||||
if (comments.length > ACTIVITY_PUB.MAX_RECURSION_COMMENTS) {
|
||||
throw new Error('Recursion limit reached when resolving a thread')
|
||||
}
|
||||
|
||||
const { body } = await fetchAP<any>(url)
|
||||
|
||||
if (sanitizeAndCheckVideoCommentObject(body) === false) {
|
||||
throw new Error(`Remote video comment JSON ${url} is not valid:` + JSON.stringify(body))
|
||||
}
|
||||
|
||||
const actorUrl = body.attributedTo
|
||||
if (!actorUrl && body.type !== 'Tombstone') throw new Error('Miss attributed to in comment')
|
||||
|
||||
if (actorUrl && checkUrlsSameHost(url, actorUrl) !== true) {
|
||||
throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`)
|
||||
}
|
||||
|
||||
if (checkUrlsSameHost(body.id, url) !== true) {
|
||||
throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`)
|
||||
}
|
||||
|
||||
const actor = actorUrl
|
||||
? await getOrCreateAPActor(actorUrl, 'all')
|
||||
: null
|
||||
|
||||
const comment = new VideoCommentModel({
|
||||
url: body.id,
|
||||
text: body.content ? body.content : '',
|
||||
videoId: null,
|
||||
accountId: actor ? actor.Account.id : null,
|
||||
inReplyToCommentId: null,
|
||||
originCommentId: null,
|
||||
createdAt: new Date(body.published),
|
||||
updatedAt: new Date(body.updated),
|
||||
replyApproval: body.replyApproval,
|
||||
|
||||
deletedAt: body.deleted
|
||||
? new Date(body.deleted)
|
||||
: null
|
||||
}) as MCommentOwner
|
||||
comment.Account = actor ? actor.Account : null
|
||||
|
||||
logger.debug('Created remote comment %s', comment.url, { comment })
|
||||
|
||||
return resolveThread({
|
||||
url: body.inReplyTo,
|
||||
comments: comments.concat([ comment ]),
|
||||
commentCreated: true
|
||||
})
|
||||
}
|
||||
|
||||
async function isRemoteCommentAccepted (comment: MComment) {
|
||||
// Already created
|
||||
if (comment.id) return true
|
||||
|
||||
const acceptParameters = {
|
||||
comment
|
||||
}
|
||||
|
||||
const acceptedResult = await Hooks.wrapFun(
|
||||
isRemoteVideoCommentAccepted,
|
||||
acceptParameters,
|
||||
'filter:activity-pub.remote-video-comment.create.accept.result'
|
||||
)
|
||||
|
||||
if (acceptedResult?.accepted !== true) {
|
||||
logger.info('Refused to create a remote comment.', { acceptedResult, acceptParameters })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
59
server/core/lib/activitypub/video-rates.ts
Normal file
59
server/core/lib/activitypub/video-rates.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { VideoRateType } from '@peertube/peertube-models'
|
||||
import { MAccountActor, MActorUrl, MVideoAccountLight, MVideoFullLight, MVideoId } from '../../types/models/index.js'
|
||||
import { sendLike, sendUndoDislike, sendUndoLike } from './send/index.js'
|
||||
import { sendDislike } from './send/send-dislike.js'
|
||||
import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url.js'
|
||||
import { federateVideoIfNeeded } from './videos/index.js'
|
||||
|
||||
async function sendVideoRateChange (
|
||||
account: MAccountActor,
|
||||
video: MVideoFullLight,
|
||||
likes: number,
|
||||
dislikes: number,
|
||||
t: Transaction
|
||||
) {
|
||||
if (video.isLocal()) return federateVideoIfNeeded(video, false, t)
|
||||
|
||||
return sendVideoRateChangeToOrigin(account, video, likes, dislikes, t)
|
||||
}
|
||||
|
||||
function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVideoId) {
|
||||
return rateType === 'like'
|
||||
? getVideoLikeActivityPubUrlByLocalActor(actor, video)
|
||||
: getVideoDislikeActivityPubUrlByLocalActor(actor, video)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getLocalRateUrl,
|
||||
sendVideoRateChange
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendVideoRateChangeToOrigin (
|
||||
account: MAccountActor,
|
||||
video: MVideoAccountLight,
|
||||
likes: number,
|
||||
dislikes: number,
|
||||
t: Transaction
|
||||
) {
|
||||
// Local video, we don't need to send like
|
||||
if (video.isLocal()) return
|
||||
|
||||
const actor = account.Actor
|
||||
|
||||
// Keep the order: first we undo and then we create
|
||||
|
||||
// Undo Like
|
||||
if (likes < 0) await sendUndoLike(actor, video, t)
|
||||
// Undo Dislike
|
||||
if (dislikes < 0) await sendUndoDislike(actor, video, t)
|
||||
|
||||
// Like
|
||||
if (likes > 0) await sendLike(actor, video, t)
|
||||
// Dislike
|
||||
if (dislikes > 0) await sendDislike(actor, video, t)
|
||||
}
|
||||
53
server/core/lib/activitypub/videos/federate.ts
Normal file
53
server/core/lib/activitypub/videos/federate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { VideoPrivacy, VideoPrivacyType, VideoState, VideoStateType } from '@peertube/peertube-models'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { MVideoAPLight, MVideoWithBlacklistRights } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { sendCreateVideo, sendUpdateVideo } from '../send/index.js'
|
||||
import { shareByServer, shareByVideoChannel } from '../share.js'
|
||||
|
||||
export async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
|
||||
if (!canVideoBeFederated(videoArg, isNewVideo)) return
|
||||
|
||||
const video = await videoArg.lightAPToFullAP(transaction)
|
||||
|
||||
if (isNewVideo) {
|
||||
// Now we'll add the video's meta data to our followers
|
||||
await sendCreateVideo(video, transaction)
|
||||
|
||||
await Promise.all([
|
||||
shareByServer(video, transaction),
|
||||
shareByVideoChannel(video, transaction)
|
||||
])
|
||||
} else {
|
||||
await sendUpdateVideo(video, transaction)
|
||||
}
|
||||
}
|
||||
|
||||
export function canVideoBeFederated (video: MVideoWithBlacklistRights, isNewVideo = false) {
|
||||
// Check this is not a blacklisted video
|
||||
if (video.isBlacklisted() === true) {
|
||||
if (isNewVideo === false) return false
|
||||
if (video.VideoBlacklist.unfederated === true) return false
|
||||
}
|
||||
|
||||
// Check the video is public/unlisted and published
|
||||
return isPrivacyForFederation(video.privacy) && isStateForFederation(video.state)
|
||||
}
|
||||
|
||||
export function isNewVideoPrivacyForFederation (currentPrivacy: VideoPrivacyType, newPrivacy: VideoPrivacyType) {
|
||||
return !isPrivacyForFederation(currentPrivacy) && isPrivacyForFederation(newPrivacy)
|
||||
}
|
||||
|
||||
export function isPrivacyForFederation (privacy: VideoPrivacyType) {
|
||||
const castedPrivacy = forceNumber(privacy)
|
||||
|
||||
return castedPrivacy === VideoPrivacy.PUBLIC ||
|
||||
(CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true && castedPrivacy === VideoPrivacy.UNLISTED)
|
||||
}
|
||||
|
||||
export function isStateForFederation (state: VideoStateType) {
|
||||
const castedState = forceNumber(state)
|
||||
|
||||
return castedState === VideoState.PUBLISHED || castedState === VideoState.WAITING_FOR_LIVE || castedState === VideoState.LIVE_ENDED
|
||||
}
|
||||
134
server/core/lib/activitypub/videos/get.ts
Normal file
134
server/core/lib/activitypub/videos/get.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { APObjectId } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
|
||||
import {
|
||||
MVideoAccountLightBlacklistAllFiles,
|
||||
MVideoImmutable,
|
||||
MVideoThumbnail,
|
||||
MVideoThumbnailBlacklist
|
||||
} from '@server/types/models/index.js'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { refreshVideoIfNeeded } from './refresh.js'
|
||||
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
|
||||
|
||||
type GetVideoResult <T> = Promise<{
|
||||
video: T
|
||||
created: boolean
|
||||
autoBlacklisted?: boolean
|
||||
}>
|
||||
|
||||
type GetVideoParamAll = {
|
||||
videoObject: APObjectId
|
||||
syncParam?: SyncParam
|
||||
fetchType?: 'all'
|
||||
allowRefresh?: boolean
|
||||
}
|
||||
|
||||
type GetVideoParamImmutable = {
|
||||
videoObject: APObjectId
|
||||
syncParam?: SyncParam
|
||||
fetchType: 'unsafe-only-immutable-attributes'
|
||||
allowRefresh: false
|
||||
}
|
||||
|
||||
type GetVideoParamOther = {
|
||||
videoObject: APObjectId
|
||||
syncParam?: SyncParam
|
||||
fetchType?: 'all' | 'only-video-and-blacklist'
|
||||
allowRefresh?: boolean
|
||||
}
|
||||
|
||||
export function getOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
|
||||
export function getOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
|
||||
export function getOrCreateAPVideo (
|
||||
options: GetVideoParamOther
|
||||
): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist>
|
||||
export async function getOrCreateAPVideo (
|
||||
options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
|
||||
): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist | MVideoImmutable> {
|
||||
// Default params
|
||||
const syncParam = options.syncParam || { rates: true, shares: true, comments: true, refreshVideo: false }
|
||||
const fetchType = options.fetchType || 'all'
|
||||
const allowRefresh = options.allowRefresh !== false
|
||||
|
||||
// Get video url
|
||||
const videoUrl = getAPId(options.videoObject)
|
||||
let videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
|
||||
|
||||
if (videoFromDatabase) {
|
||||
if (allowRefresh === true) {
|
||||
// Typings ensure allowRefresh === false in unsafe-only-immutable-attributes fetch type
|
||||
videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam)
|
||||
}
|
||||
|
||||
return { video: videoFromDatabase, created: false }
|
||||
}
|
||||
|
||||
const { videoObject } = await fetchRemoteVideo(videoUrl)
|
||||
if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
|
||||
|
||||
// videoUrl is just an alias/redirection, so process object id instead
|
||||
if (videoObject.id !== videoUrl) return getOrCreateAPVideo({ ...options, fetchType: 'all', videoObject })
|
||||
|
||||
try {
|
||||
const creator = new APVideoCreator(videoObject)
|
||||
const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator))
|
||||
|
||||
await syncVideoExternalAttributes(videoCreated, videoObject, syncParam)
|
||||
|
||||
return { video: videoCreated, created: true, autoBlacklisted }
|
||||
} catch (err) {
|
||||
// Maybe a concurrent getOrCreateAPVideo call created this video
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
const alreadyCreatedVideo = await loadVideoByUrl(videoUrl, fetchType)
|
||||
if (alreadyCreatedVideo) return { video: alreadyCreatedVideo, created: false }
|
||||
|
||||
logger.error('Cannot create video %s because of SequelizeUniqueConstraintError error, but cannot find it in database.', videoUrl)
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function maybeGetOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
|
||||
export function maybeGetOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
|
||||
export function maybeGetOrCreateAPVideo (
|
||||
options: GetVideoParamOther
|
||||
): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist>
|
||||
export async function maybeGetOrCreateAPVideo (options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther) {
|
||||
try {
|
||||
const result = await getOrCreateAPVideo(options as any)
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
logger.debug('Cannot fetch remote video ' + options.videoObject + ': maybe not a video object?', { err })
|
||||
return { video: undefined, created: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scheduleRefresh (video: MVideoThumbnail, fetchType: VideoLoadByUrlType, syncParam: SyncParam) {
|
||||
if (!video.isOutdated()) return video
|
||||
|
||||
const refreshOptions = {
|
||||
video,
|
||||
fetchedType: fetchType,
|
||||
syncParam
|
||||
}
|
||||
|
||||
if (syncParam.refreshVideo === true) {
|
||||
return refreshVideoIfNeeded(refreshOptions)
|
||||
}
|
||||
|
||||
await JobQueue.Instance.createJob({
|
||||
type: 'activitypub-refresher',
|
||||
payload: { type: 'video', url: video.url }
|
||||
})
|
||||
|
||||
return video
|
||||
}
|
||||
4
server/core/lib/activitypub/videos/index.ts
Normal file
4
server/core/lib/activitypub/videos/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './federate.js'
|
||||
export * from './get.js'
|
||||
export * from './refresh.js'
|
||||
export * from './updater.js'
|
||||
70
server/core/lib/activitypub/videos/refresh.ts
Normal file
70
server/core/lib/activitypub/videos/refresh.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideoAccountLightBlacklistAllFiles, MVideoThumbnail } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { ActorFollowHealthCache } from '../../actor-follow-health-cache.js'
|
||||
import { fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
|
||||
import { APVideoUpdater } from './updater.js'
|
||||
|
||||
async function refreshVideoIfNeeded (options: {
|
||||
video: MVideoThumbnail
|
||||
fetchedType: VideoLoadByUrlType
|
||||
syncParam: SyncParam
|
||||
}): Promise<MVideoThumbnail> {
|
||||
if (!options.video.isOutdated()) return options.video
|
||||
|
||||
// We need more attributes if the argument video was fetched with not enough joints
|
||||
const video = options.fetchedType === 'all'
|
||||
? options.video as MVideoAccountLightBlacklistAllFiles
|
||||
: await VideoModel.loadByUrlAndPopulateAccountAndFiles(options.video.url)
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video', 'refresh', video.uuid, video.url)
|
||||
|
||||
logger.info('Refreshing video %s.', video.url, lTags())
|
||||
|
||||
try {
|
||||
const { videoObject } = await fetchRemoteVideo(video.url)
|
||||
|
||||
if (videoObject === undefined) {
|
||||
logger.warn('Cannot refresh remote video %s: invalid body.', video.url, lTags())
|
||||
|
||||
await video.setAsRefreshed()
|
||||
return video
|
||||
}
|
||||
|
||||
const videoUpdater = new APVideoUpdater(videoObject, video)
|
||||
await videoUpdater.update()
|
||||
|
||||
await syncVideoExternalAttributes(video, videoObject, options.syncParam)
|
||||
|
||||
ActorFollowHealthCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
|
||||
|
||||
return video
|
||||
} catch (err) {
|
||||
const statusCode = (err as PeerTubeRequestError).statusCode
|
||||
|
||||
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.info('Cannot refresh remote video %s: video does not exist anymore (404/410 error code). Deleting it.', video.url, lTags())
|
||||
|
||||
// Video does not exist anymore
|
||||
await video.destroy()
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.warn('Cannot refresh video %s.', options.video.url, { err, ...lTags() })
|
||||
|
||||
ActorFollowHealthCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
|
||||
|
||||
// Don't refresh in loop
|
||||
await video.setAsRefreshed()
|
||||
return video
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
refreshVideoIfNeeded
|
||||
}
|
||||
257
server/core/lib/activitypub/videos/shared/abstract-builder.ts
Normal file
257
server/core/lib/activitypub/videos/shared/abstract-builder.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ActivityTagObject,
|
||||
ThumbnailType,
|
||||
VideoChaptersObject,
|
||||
VideoObject,
|
||||
VideoStreamingPlaylistType_Type
|
||||
} from '@peertube/peertube-models'
|
||||
import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
|
||||
import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
|
||||
import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
|
||||
import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
|
||||
import { replaceChapters } from '@server/lib/video-chapters.js'
|
||||
import { setVideoTags } from '@server/lib/video.js'
|
||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||
import {
|
||||
MStreamingPlaylistFiles,
|
||||
MStreamingPlaylistFilesVideo,
|
||||
MVideo,
|
||||
MVideoCaption,
|
||||
MVideoFile,
|
||||
MVideoFullLight,
|
||||
MVideoThumbnail
|
||||
} from '@server/types/models/index.js'
|
||||
import { CreationAttributes, Transaction } from 'sequelize'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { findOwner, getOrCreateAPActor } from '../../actors/index.js'
|
||||
import { upsertAPPlayerSettings } from '../../player-settings.js'
|
||||
import {
|
||||
getCaptionAttributesFromObject,
|
||||
getFileAttributesFromUrl,
|
||||
getLiveAttributesFromObject,
|
||||
getLiveSchedulesAttributesFromObject,
|
||||
getPreviewFromIcons,
|
||||
getStoryboardAttributeFromObject,
|
||||
getStreamingPlaylistAttributesFromObject,
|
||||
getTagsFromObject,
|
||||
getThumbnailFromIcons
|
||||
} from './object-to-model-attributes.js'
|
||||
import { getTrackerUrls, setVideoTrackers } from './trackers.js'
|
||||
|
||||
export abstract class APVideoAbstractBuilder {
|
||||
protected abstract videoObject: VideoObject
|
||||
protected abstract lTags: LoggerTagsFn
|
||||
|
||||
protected async getOrCreateVideoChannelFromVideoObject () {
|
||||
const channel = await findOwner(this.videoObject.id, this.videoObject.attributedTo, 'Group')
|
||||
if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.id)
|
||||
|
||||
return getOrCreateAPActor(channel.id, 'all')
|
||||
}
|
||||
|
||||
protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
|
||||
const miniatureIcon = getThumbnailFromIcons(this.videoObject)
|
||||
if (!miniatureIcon) {
|
||||
logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const miniatureModel = updateRemoteVideoThumbnail({
|
||||
fileUrl: miniatureIcon.url,
|
||||
video,
|
||||
type: ThumbnailType.MINIATURE,
|
||||
size: miniatureIcon,
|
||||
onDisk: false // Lazy download remote thumbnails
|
||||
})
|
||||
|
||||
await video.addAndSaveThumbnail(miniatureModel, t)
|
||||
}
|
||||
|
||||
protected async setPreview (video: MVideoFullLight, t?: Transaction) {
|
||||
const previewIcon = getPreviewFromIcons(this.videoObject)
|
||||
if (!previewIcon) return
|
||||
|
||||
const previewModel = updateRemoteVideoThumbnail({
|
||||
fileUrl: previewIcon.url,
|
||||
video,
|
||||
type: ThumbnailType.PREVIEW,
|
||||
size: previewIcon,
|
||||
onDisk: false // Lazy download remote previews
|
||||
})
|
||||
|
||||
await video.addAndSaveThumbnail(previewModel, t)
|
||||
}
|
||||
|
||||
protected async setTags (video: MVideoFullLight, t: Transaction) {
|
||||
const tags = getTagsFromObject(this.videoObject)
|
||||
await setVideoTags({ video, tags, transaction: t })
|
||||
}
|
||||
|
||||
protected async setTrackers (video: MVideoFullLight, t: Transaction) {
|
||||
const trackers = getTrackerUrls(this.videoObject, video)
|
||||
await setVideoTrackers({ video, trackers, transaction: t })
|
||||
}
|
||||
|
||||
protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) {
|
||||
const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
|
||||
|
||||
let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject)
|
||||
.map(a => new VideoCaptionModel(a) as MVideoCaption)
|
||||
|
||||
for (const existingCaption of existingCaptions) {
|
||||
// Only keep captions that do not already exist
|
||||
const filtered = captionsToCreate.filter(c => !c.isEqual(existingCaption))
|
||||
|
||||
// This caption already exists, we don't need to destroy and create it
|
||||
if (filtered.length !== captionsToCreate.length) {
|
||||
captionsToCreate = filtered
|
||||
continue
|
||||
}
|
||||
|
||||
// Destroy this caption that does not exist anymore
|
||||
await existingCaption.destroy({ transaction: t })
|
||||
}
|
||||
|
||||
for (const captionToCreate of captionsToCreate) {
|
||||
await captionToCreate.save({ transaction: t })
|
||||
}
|
||||
}
|
||||
|
||||
protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) {
|
||||
const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t)
|
||||
if (existingStoryboard) await existingStoryboard.destroy({ transaction: t })
|
||||
|
||||
const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
|
||||
if (!storyboardAttributes) return
|
||||
|
||||
return StoryboardModel.create(storyboardAttributes, { transaction: t })
|
||||
}
|
||||
|
||||
protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
|
||||
const attributes = getLiveAttributesFromObject(video, this.videoObject)
|
||||
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
|
||||
|
||||
await VideoLiveScheduleModel.deleteAllOfLiveId(videoLive.id, transaction)
|
||||
videoLive.LiveSchedules = []
|
||||
|
||||
for (const scheduleAttributes of getLiveSchedulesAttributesFromObject(videoLive, this.videoObject)) {
|
||||
const scheduleModel = new VideoLiveScheduleModel(scheduleAttributes)
|
||||
|
||||
videoLive.LiveSchedules.push(await scheduleModel.save({ transaction }))
|
||||
}
|
||||
}
|
||||
|
||||
protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
|
||||
const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
|
||||
const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
|
||||
|
||||
// Remove video files that do not exist anymore
|
||||
await deleteAllModels(filterNonExistingModels(video.VideoFiles || [], newVideoFiles), t)
|
||||
|
||||
// Update or add other one
|
||||
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
|
||||
video.VideoFiles = await Promise.all(upsertTasks)
|
||||
}
|
||||
|
||||
protected async updateChapters (video: MVideoFullLight) {
|
||||
if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
|
||||
|
||||
const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
|
||||
if (!isVideoChaptersObjectValid(body)) {
|
||||
logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
|
||||
|
||||
return retryTransactionWrapper(() => {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
|
||||
|
||||
await replaceChapters({ chapters, transaction: t, video })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
protected async upsertPlayerSettings (video: MVideoFullLight) {
|
||||
if (typeof this.videoObject.playerSettings !== 'string') return
|
||||
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject: this.videoObject.playerSettings,
|
||||
video,
|
||||
channel: undefined,
|
||||
contextUrl: video.url
|
||||
})
|
||||
}
|
||||
|
||||
protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
|
||||
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
|
||||
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
|
||||
|
||||
// Remove video playlists that do not exist anymore
|
||||
await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t)
|
||||
|
||||
const oldPlaylists = video.VideoStreamingPlaylists
|
||||
video.VideoStreamingPlaylists = []
|
||||
|
||||
for (const playlistAttributes of streamingPlaylistAttributes) {
|
||||
const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
|
||||
streamingPlaylistModel.Video = video
|
||||
|
||||
await this.setStreamingPlaylistFiles(oldPlaylists, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
|
||||
|
||||
video.VideoStreamingPlaylists.push(streamingPlaylistModel)
|
||||
}
|
||||
}
|
||||
|
||||
private async insertOrReplaceStreamingPlaylist (attributes: CreationAttributes<VideoStreamingPlaylistModel>, t: Transaction) {
|
||||
const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
|
||||
|
||||
return streamingPlaylist as MStreamingPlaylistFilesVideo
|
||||
}
|
||||
|
||||
private getStreamingPlaylistFiles (oldPlaylists: MStreamingPlaylistFiles[], type: VideoStreamingPlaylistType_Type) {
|
||||
const playlist = oldPlaylists.find(s => s.type === type)
|
||||
if (!playlist) return []
|
||||
|
||||
return playlist.VideoFiles
|
||||
}
|
||||
|
||||
private async setStreamingPlaylistFiles (
|
||||
oldPlaylists: MStreamingPlaylistFiles[],
|
||||
playlistModel: MStreamingPlaylistFilesVideo,
|
||||
tagObjects: ActivityTagObject[],
|
||||
t: Transaction
|
||||
) {
|
||||
const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type)
|
||||
|
||||
const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
|
||||
|
||||
await deleteAllModels(filterNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles), t)
|
||||
|
||||
// Update or add other one
|
||||
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
|
||||
playlistModel.VideoFiles = await Promise.all(upsertTasks)
|
||||
}
|
||||
|
||||
protected async setAutomaticTags (options: {
|
||||
video: MVideo
|
||||
oldVideo?: Pick<MVideo, 'name' | 'description'>
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const { video, transaction, oldVideo } = options
|
||||
|
||||
if (oldVideo && video.name === oldVideo.name && video.description === oldVideo.description) return
|
||||
|
||||
const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction })
|
||||
await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction })
|
||||
}
|
||||
}
|
||||
70
server/core/lib/activitypub/videos/shared/creator.ts
Normal file
70
server/core/lib/activitypub/videos/shared/creator.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { VideoObject } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
|
||||
import { APVideoAbstractBuilder } from './abstract-builder.js'
|
||||
import { getVideoAttributesFromObject } from './object-to-model-attributes.js'
|
||||
|
||||
export class APVideoCreator extends APVideoAbstractBuilder {
|
||||
protected lTags: LoggerTagsFn
|
||||
|
||||
constructor (protected readonly videoObject: VideoObject) {
|
||||
super()
|
||||
|
||||
this.lTags = loggerTagsFactory('ap', 'video', 'create', this.videoObject.uuid, this.videoObject.id)
|
||||
}
|
||||
|
||||
async create () {
|
||||
logger.debug('Adding remote video %s.', this.videoObject.id, { ...this.videoObject, ...this.lTags() })
|
||||
|
||||
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
|
||||
const channel = channelActor.VideoChannel
|
||||
channel.Actor = channelActor
|
||||
|
||||
const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
|
||||
const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail
|
||||
|
||||
const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
|
||||
const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
|
||||
videoCreated.VideoChannel = channel
|
||||
|
||||
await this.setThumbnail(videoCreated, t)
|
||||
await this.setPreview(videoCreated, t)
|
||||
await this.setWebVideoFiles(videoCreated, t)
|
||||
await this.setStreamingPlaylists(videoCreated, t)
|
||||
await this.setTags(videoCreated, t)
|
||||
await this.setTrackers(videoCreated, t)
|
||||
await this.insertOrReplaceCaptions(videoCreated, t)
|
||||
await this.insertOrReplaceLive(videoCreated, t)
|
||||
await this.insertOrReplaceStoryboard(videoCreated, t)
|
||||
|
||||
await this.setAutomaticTags({ video: videoCreated, transaction: t })
|
||||
|
||||
// We added a video in this channel, set it as updated
|
||||
await channel.setAsUpdated(t)
|
||||
|
||||
const autoBlacklisted = await autoBlacklistVideoIfNeeded({
|
||||
video: videoCreated,
|
||||
user: undefined,
|
||||
isRemote: true,
|
||||
isNew: true,
|
||||
isNewFile: true,
|
||||
transaction: t
|
||||
})
|
||||
|
||||
logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
|
||||
|
||||
Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
})
|
||||
|
||||
await this.updateChapters(videoCreated)
|
||||
await this.upsertPlayerSettings(videoCreated)
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
}
|
||||
}
|
||||
6
server/core/lib/activitypub/videos/shared/index.ts
Normal file
6
server/core/lib/activitypub/videos/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './abstract-builder.js'
|
||||
export * from './creator.js'
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './trackers.js'
|
||||
export * from './url-to-object.js'
|
||||
export * from './video-sync-attributes.js'
|
||||
@@ -0,0 +1,398 @@
|
||||
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ActivityHashTagObject,
|
||||
ActivityMagnetUrlObject,
|
||||
ActivityPlaylistSegmentHashesObject,
|
||||
ActivityPlaylistUrlObject,
|
||||
ActivitySensitiveTagObject,
|
||||
ActivityTagObject,
|
||||
ActivityUrlObject,
|
||||
ActivityVideoUrlObject,
|
||||
NSFWFlag,
|
||||
stringToNSFWFlag,
|
||||
VideoFileFormatFlag,
|
||||
VideoFileStream,
|
||||
VideoObject,
|
||||
VideoPrivacy,
|
||||
VideoResolution,
|
||||
VideoStreamingPlaylistType
|
||||
} from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
|
||||
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { exists, isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js'
|
||||
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||
import { getExtFromMimetype } from '@server/helpers/video.js'
|
||||
import { MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants.js'
|
||||
import { generateTorrentFileName } from '@server/lib/paths.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import {
|
||||
isStreamingPlaylist,
|
||||
MChannelId,
|
||||
MStreamingPlaylistVideo,
|
||||
MVideo,
|
||||
MVideoFile,
|
||||
MVideoId,
|
||||
MVideoLive
|
||||
} from '@server/types/models/index.js'
|
||||
import { decode as magnetUriDecode } from 'magnet-uri'
|
||||
import { basename, extname } from 'path'
|
||||
import { getDurationFromActivityStream } from '../../activity.js'
|
||||
|
||||
export function getThumbnailFromIcons (videoObject: VideoObject) {
|
||||
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minRemoteWidth)
|
||||
// Fallback if there are not valid icons
|
||||
if (validIcons.length === 0) validIcons = videoObject.icon
|
||||
|
||||
return minBy(validIcons, 'width')
|
||||
}
|
||||
|
||||
export function getPreviewFromIcons (videoObject: VideoObject) {
|
||||
const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minRemoteWidth)
|
||||
|
||||
return maxBy(validIcons, 'width')
|
||||
}
|
||||
|
||||
export function getTagsFromObject (videoObject: VideoObject) {
|
||||
return videoObject.tag
|
||||
.filter(isAPHashTagObject)
|
||||
.map(t => t.name)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getFileAttributesFromUrl (
|
||||
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
|
||||
urls: (ActivityTagObject | ActivityUrlObject)[]
|
||||
) {
|
||||
const fileUrls = urls.filter(u => isAPVideoUrlObject(u))
|
||||
if (fileUrls.length === 0) return []
|
||||
|
||||
const attributes: FilteredModelAttributes<VideoFileModel>[] = []
|
||||
for (const fileUrl of fileUrls) {
|
||||
// Fetch associated metadata url, if any
|
||||
const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
|
||||
.find(u => {
|
||||
return u.height === fileUrl.height &&
|
||||
u.fps === fileUrl.fps &&
|
||||
u.rel.includes(fileUrl.mediaType)
|
||||
})
|
||||
|
||||
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
|
||||
const resolution = fileUrl.height
|
||||
|
||||
const [ videoId, videoStreamingPlaylistId ] = isStreamingPlaylist(videoOrPlaylist)
|
||||
? [ null, videoOrPlaylist.id ]
|
||||
: [ videoOrPlaylist.id, null ]
|
||||
|
||||
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl })
|
||||
|
||||
const attribute: Partial<AttributesOnly<MVideoFile>> = {
|
||||
extname,
|
||||
resolution,
|
||||
|
||||
size: fileUrl.size,
|
||||
fps: exists(fileUrl.fps) && fileUrl.fps >= 0
|
||||
? fileUrl.fps
|
||||
: -1,
|
||||
|
||||
metadataUrl: metadata?.href,
|
||||
|
||||
width: fileUrl.width,
|
||||
height: fileUrl.height,
|
||||
|
||||
// Use the name of the remote file because we don't proxify video file requests
|
||||
filename: basename(fileUrl.href),
|
||||
fileUrl: fileUrl.href,
|
||||
|
||||
infoHash,
|
||||
torrentFilename,
|
||||
torrentUrl,
|
||||
|
||||
formatFlags: buildFileFormatFlags(fileUrl, isStreamingPlaylist(videoOrPlaylist)),
|
||||
streams: buildFileStreams(fileUrl, resolution),
|
||||
|
||||
// This is a video file owned by a video or by a streaming playlist
|
||||
videoId,
|
||||
videoStreamingPlaylistId
|
||||
}
|
||||
|
||||
attributes.push(attribute)
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
function buildFileFormatFlags (fileUrl: ActivityVideoUrlObject, isStreamingPlaylist: boolean) {
|
||||
const attachment = fileUrl.attachment || []
|
||||
|
||||
const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'peertube_format_flag')
|
||||
if (formatHints.length === 0) {
|
||||
return isStreamingPlaylist
|
||||
? VideoFileFormatFlag.FRAGMENTED
|
||||
: VideoFileFormatFlag.WEB_VIDEO
|
||||
}
|
||||
|
||||
let formatFlags = VideoFileFormatFlag.NONE
|
||||
|
||||
for (const hint of formatHints) {
|
||||
if (hint.value === 'fragmented') formatFlags |= VideoFileFormatFlag.FRAGMENTED
|
||||
else if (hint.value === 'web-video') formatFlags |= VideoFileFormatFlag.WEB_VIDEO
|
||||
}
|
||||
|
||||
return formatFlags
|
||||
}
|
||||
|
||||
function buildFileStreams (fileUrl: ActivityVideoUrlObject, resolution: number) {
|
||||
const attachment = fileUrl.attachment || []
|
||||
|
||||
const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'ffprobe_codec_type')
|
||||
|
||||
if (formatHints.length === 0) {
|
||||
if (resolution === VideoResolution.H_NOVIDEO) return VideoFileStream.AUDIO
|
||||
|
||||
return VideoFileStream.VIDEO | VideoFileStream.AUDIO
|
||||
}
|
||||
|
||||
let streams = VideoFileStream.NONE
|
||||
|
||||
for (const hint of formatHints) {
|
||||
if (hint.value === 'audio') streams |= VideoFileStream.AUDIO
|
||||
else if (hint.value === 'video') streams |= VideoFileStream.VIDEO
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u))
|
||||
if (playlistUrls.length === 0) return []
|
||||
|
||||
const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
|
||||
for (const playlistUrlObject of playlistUrls) {
|
||||
const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
|
||||
|
||||
const files = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u))
|
||||
|
||||
const attribute = {
|
||||
type: VideoStreamingPlaylistType.HLS,
|
||||
|
||||
playlistFilename: basename(playlistUrlObject.href),
|
||||
playlistUrl: playlistUrlObject.href,
|
||||
|
||||
segmentsSha256Filename: segmentsSha256UrlObject
|
||||
? basename(segmentsSha256UrlObject.href)
|
||||
: null,
|
||||
|
||||
segmentsSha256Url: segmentsSha256UrlObject?.href ?? null,
|
||||
|
||||
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
|
||||
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
|
||||
videoId: video.id,
|
||||
|
||||
tagAPObject: playlistUrlObject.tag
|
||||
}
|
||||
|
||||
attributes.push(attribute)
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
export function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
return {
|
||||
saveReplay: videoObject.liveSaveReplay,
|
||||
permanentLive: videoObject.permanentLive,
|
||||
latencyMode: videoObject.latencyMode,
|
||||
videoId: video.id
|
||||
}
|
||||
}
|
||||
export function getLiveSchedulesAttributesFromObject (live: MVideoLive, videoObject: VideoObject) {
|
||||
const schedules = videoObject.schedules || []
|
||||
|
||||
return schedules.map(s => ({
|
||||
liveVideoId: live.id,
|
||||
startAt: s.startDate
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
return videoObject.subtitleLanguage.map(c => {
|
||||
// This field is sanitized in validators
|
||||
const url = c.url
|
||||
|
||||
const filename = VideoCaptionModel.generateCaptionName(c.identifier)
|
||||
|
||||
return {
|
||||
videoId: video.id,
|
||||
filename,
|
||||
language: c.identifier,
|
||||
automaticallyGenerated: c.automaticallyGenerated === true,
|
||||
fileUrl: url.find(u => u.mediaType === 'text/vtt')?.href,
|
||||
m3u8Filename: VideoCaptionModel.generateM3U8Filename(filename),
|
||||
m3u8Url: url.find(u => u.mediaType === 'application/x-mpegURL')?.href
|
||||
} as Partial<AttributesOnly<VideoCaptionModel>>
|
||||
})
|
||||
}
|
||||
|
||||
export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
if (!isArray(videoObject.preview)) return undefined
|
||||
|
||||
const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard'))
|
||||
if (!storyboard) return undefined
|
||||
|
||||
const url = arrayify(storyboard.url).find(u => u.mediaType === 'image/jpeg')
|
||||
|
||||
return {
|
||||
filename: generateImageFilename(extname(url.href)),
|
||||
totalHeight: url.height,
|
||||
totalWidth: url.width,
|
||||
spriteHeight: url.tileHeight,
|
||||
spriteWidth: url.tileWidth,
|
||||
spriteDuration: getDurationFromActivityStream(url.tileDuration),
|
||||
fileUrl: url.href,
|
||||
videoId: video.id
|
||||
}
|
||||
}
|
||||
|
||||
export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
|
||||
const privacy = hasAPPublic(to)
|
||||
? VideoPrivacy.PUBLIC
|
||||
: VideoPrivacy.UNLISTED
|
||||
|
||||
const language = videoObject.language?.identifier
|
||||
|
||||
const category = videoObject.category
|
||||
? parseInt(videoObject.category.identifier, 10)
|
||||
: undefined
|
||||
|
||||
const licence = videoObject.licence
|
||||
? parseInt(videoObject.licence.identifier, 10)
|
||||
: undefined
|
||||
|
||||
const description = videoObject.content || null
|
||||
const support = videoObject.support || null
|
||||
|
||||
return {
|
||||
name: videoObject.name,
|
||||
uuid: videoObject.uuid,
|
||||
url: videoObject.id,
|
||||
category,
|
||||
licence,
|
||||
language,
|
||||
description,
|
||||
support,
|
||||
|
||||
nsfw: videoObject.sensitive,
|
||||
nsfwSummary: videoObject.sensitive
|
||||
? (videoObject.summary ?? null)
|
||||
: null,
|
||||
nsfwFlags: videoObject.sensitive
|
||||
? getNSFWFlags(videoObject.tag)
|
||||
: NSFWFlag.NONE,
|
||||
|
||||
commentsPolicy: videoObject.commentsPolicy,
|
||||
|
||||
downloadEnabled: videoObject.downloadEnabled,
|
||||
waitTranscoding: videoObject.waitTranscoding,
|
||||
isLive: videoObject.isLiveBroadcast,
|
||||
state: videoObject.state,
|
||||
aspectRatio: videoObject.aspectRatio,
|
||||
channelId: videoChannel.id,
|
||||
duration: getDurationFromActivityStream(videoObject.duration),
|
||||
createdAt: new Date(videoObject.published),
|
||||
publishedAt: new Date(videoObject.published),
|
||||
|
||||
originallyPublishedAt: videoObject.originallyPublishedAt
|
||||
? new Date(videoObject.originallyPublishedAt)
|
||||
: null,
|
||||
|
||||
inputFileUpdatedAt: videoObject.uploadDate
|
||||
? new Date(videoObject.uploadDate)
|
||||
: null,
|
||||
|
||||
updatedAt: new Date(videoObject.updated),
|
||||
views: videoObject.views,
|
||||
remote: true,
|
||||
privacy
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
|
||||
return !!MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType]
|
||||
}
|
||||
|
||||
function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
|
||||
return url?.mediaType === 'application/x-mpegURL'
|
||||
}
|
||||
|
||||
function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
|
||||
return tag?.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
|
||||
}
|
||||
|
||||
function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
|
||||
return url?.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
|
||||
}
|
||||
|
||||
function isAPHashTagObject (tag: any): tag is ActivityHashTagObject {
|
||||
return tag?.type === 'Hashtag'
|
||||
}
|
||||
|
||||
function isAPSensitiveTagObject (tag: any): tag is ActivitySensitiveTagObject {
|
||||
return tag?.type === 'SensitiveTag'
|
||||
}
|
||||
|
||||
function getTorrentRelatedInfo (options: {
|
||||
videoOrPlaylist: MVideo | MStreamingPlaylistVideo
|
||||
urls: (ActivityTagObject | ActivityUrlObject)[]
|
||||
fileUrl: ActivityVideoUrlObject
|
||||
}) {
|
||||
const { urls, fileUrl, videoOrPlaylist } = options
|
||||
|
||||
// Fetch associated magnet uri
|
||||
const magnet = urls.filter(isAPMagnetUrlObject)
|
||||
.find(u => u.height === fileUrl.height)
|
||||
|
||||
if (!magnet) {
|
||||
return {
|
||||
torrentUrl: null,
|
||||
torrentFilename: null,
|
||||
infoHash: null
|
||||
}
|
||||
}
|
||||
|
||||
const magnetParsed = magnetUriDecode(magnet.href)
|
||||
if (magnetParsed && isVideoFileInfoHashValid(magnetParsed.infoHash) === false) {
|
||||
throw new Error('Info hash is not valid in magnet URI ' + magnet.href)
|
||||
}
|
||||
|
||||
const torrentUrl = Array.isArray(magnetParsed.xs)
|
||||
? magnetParsed.xs[0]
|
||||
: magnetParsed.xs
|
||||
|
||||
return {
|
||||
torrentUrl,
|
||||
|
||||
// Use our own torrent name since we proxify torrent requests
|
||||
torrentFilename: generateTorrentFileName(videoOrPlaylist, fileUrl.height),
|
||||
|
||||
infoHash: magnetParsed.infoHash
|
||||
}
|
||||
}
|
||||
|
||||
function getNSFWFlags (tags: ActivityTagObject[]) {
|
||||
return tags.filter(t => isAPSensitiveTagObject(t))
|
||||
.map(t => stringToNSFWFlag(t.name))
|
||||
.filter(t => !!t)
|
||||
.reduce((acc, t) => acc | t, 0)
|
||||
}
|
||||
43
server/core/lib/activitypub/videos/shared/trackers.ts
Normal file
43
server/core/lib/activitypub/videos/shared/trackers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { REMOTE_SCHEME } from '@server/initializers/constants.js'
|
||||
import { TrackerModel } from '@server/models/server/tracker.js'
|
||||
import { MVideo, MVideoWithHost } from '@server/types/models/index.js'
|
||||
import { ActivityTrackerUrlObject, VideoObject } from '@peertube/peertube-models'
|
||||
import { buildRemoteUrl } from '../../url.js'
|
||||
|
||||
function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
|
||||
let wsFound = false
|
||||
|
||||
const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
|
||||
.map((u: ActivityTrackerUrlObject) => {
|
||||
if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
|
||||
|
||||
return u.href
|
||||
})
|
||||
|
||||
if (wsFound) return trackers
|
||||
|
||||
return [
|
||||
buildRemoteUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
|
||||
buildRemoteUrl(video, '/tracker/announce')
|
||||
]
|
||||
}
|
||||
|
||||
async function setVideoTrackers (options: {
|
||||
video: MVideo
|
||||
trackers: string[]
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const { video, trackers, transaction } = options
|
||||
|
||||
const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
|
||||
|
||||
await video.$set('Trackers', trackerInstances, { transaction })
|
||||
}
|
||||
|
||||
export {
|
||||
getTrackerUrls,
|
||||
setVideoTrackers
|
||||
}
|
||||
25
server/core/lib/activitypub/videos/shared/url-to-object.ts
Normal file
25
server/core/lib/activitypub/videos/shared/url-to-object.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { VideoObject } from '@peertube/peertube-models'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video')
|
||||
|
||||
async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
|
||||
logger.info('Fetching remote video %s.', videoUrl, lTags(videoUrl))
|
||||
|
||||
const { statusCode, body } = await fetchAP<any>(videoUrl)
|
||||
|
||||
if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
|
||||
logger.debug('Remote video JSON is not valid.', { body, ...lTags(videoUrl) })
|
||||
|
||||
return { statusCode, videoObject: undefined }
|
||||
}
|
||||
|
||||
return { statusCode, videoObject: body }
|
||||
}
|
||||
|
||||
export {
|
||||
fetchRemoteVideo
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ActivityPubOrderedCollection, ActivitypubHttpFetcherPayload, VideoObject } from '@peertube/peertube-models'
|
||||
import { runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { VideoShareModel } from '@server/models/video/video-share.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { crawlCollectionPage } from '../../crawl.js'
|
||||
import { addVideoShares } from '../../share.js'
|
||||
import { addVideoComments } from '../../video-comments.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video')
|
||||
|
||||
export type SyncParam = {
|
||||
rates: boolean
|
||||
shares: boolean
|
||||
comments: boolean
|
||||
refreshVideo?: boolean
|
||||
}
|
||||
|
||||
export async function syncVideoExternalAttributes (
|
||||
video: MVideo,
|
||||
fetchedVideo: VideoObject,
|
||||
syncParam: Pick<SyncParam, 'rates' | 'shares' | 'comments'>
|
||||
) {
|
||||
logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
|
||||
|
||||
const ratePromise = updateVideoRates(video, fetchedVideo)
|
||||
if (syncParam.rates) await ratePromise
|
||||
|
||||
await syncShares(video, fetchedVideo, syncParam.shares)
|
||||
|
||||
await syncComments(video, fetchedVideo, syncParam.comments)
|
||||
}
|
||||
|
||||
export async function updateVideoRates (video: MVideo, fetchedVideo: VideoObject) {
|
||||
const [ likes, dislikes ] = await Promise.all([
|
||||
getRatesCount('like', video, fetchedVideo),
|
||||
getRatesCount('dislike', video, fetchedVideo)
|
||||
])
|
||||
|
||||
return runInReadCommittedTransaction(async t => {
|
||||
await VideoModel.updateRatesOf(video.id, 'like', likes, t)
|
||||
await VideoModel.updateRatesOf(video.id, 'dislike', dislikes, t)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getRatesCount (type: 'like' | 'dislike', video: MVideo, fetchedVideo: VideoObject) {
|
||||
const uri = type === 'like'
|
||||
? fetchedVideo.likes
|
||||
: fetchedVideo.dislikes
|
||||
|
||||
if (!uri) return
|
||||
|
||||
logger.info('Sync %s of video %s', type, video.url)
|
||||
|
||||
const { body } = await fetchAP<ActivityPubOrderedCollection<any>>(uri)
|
||||
|
||||
if (isNaN(body.totalItems)) {
|
||||
logger.error('Cannot sync %s of video %s, totalItems is not a number', type, video.url, { body })
|
||||
return
|
||||
}
|
||||
|
||||
return body.totalItems
|
||||
}
|
||||
|
||||
function syncShares (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
|
||||
const uri = fetchedVideo.shares
|
||||
if (!uri) return
|
||||
|
||||
if (!isSync) {
|
||||
return createJob({ uri, videoId: video.id, type: 'video-shares' })
|
||||
}
|
||||
|
||||
const handler = items => addVideoShares(items, video)
|
||||
const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
|
||||
|
||||
return crawlCollectionPage<string>(uri, handler, cleaner)
|
||||
.catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
|
||||
}
|
||||
|
||||
function syncComments (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
|
||||
const uri = fetchedVideo.comments
|
||||
if (!uri) return
|
||||
|
||||
if (!isSync) {
|
||||
return createJob({ uri, videoId: video.id, type: 'video-comments' })
|
||||
}
|
||||
|
||||
const handler = items => addVideoComments(items)
|
||||
const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
|
||||
|
||||
return crawlCollectionPage<string>(uri, handler, cleaner)
|
||||
.catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
|
||||
}
|
||||
|
||||
function createJob (payload: ActivitypubHttpFetcherPayload) {
|
||||
return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
192
server/core/lib/activitypub/videos/updater.ts
Normal file
192
server/core/lib/activitypub/videos/updater.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { VideoObject, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||
import { Notifier } from '@server/lib/notifier/index.js'
|
||||
import { PeerTubeSocket } from '@server/lib/peertube-socket.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||
import {
|
||||
MActorHost,
|
||||
MChannelAccountLight,
|
||||
MChannelId,
|
||||
MVideoAccountLightBlacklistAllFiles,
|
||||
MVideoFullLight
|
||||
} from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { haveActorsSameRemoteHost } from '../actors/check-actor.js'
|
||||
import { APVideoAbstractBuilder, getVideoAttributesFromObject, updateVideoRates } from './shared/index.js'
|
||||
|
||||
export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||
private readonly wasPrivateVideo: boolean
|
||||
private readonly wasUnlistedVideo: boolean
|
||||
|
||||
private readonly oldVideoChannel: MChannelAccountLight
|
||||
|
||||
protected lTags: LoggerTagsFn
|
||||
|
||||
constructor (
|
||||
protected readonly videoObject: VideoObject,
|
||||
private readonly video: MVideoAccountLightBlacklistAllFiles
|
||||
) {
|
||||
super()
|
||||
|
||||
this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
|
||||
this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
|
||||
|
||||
this.oldVideoChannel = this.video.VideoChannel
|
||||
|
||||
this.lTags = loggerTagsFactory('ap', 'video', 'update', video.uuid, video.url)
|
||||
}
|
||||
|
||||
async update (overrideTo?: string[]) {
|
||||
logger.debug(
|
||||
'Updating remote video "%s".',
|
||||
this.videoObject.uuid,
|
||||
{ videoObject: this.videoObject, ...this.lTags() }
|
||||
)
|
||||
|
||||
const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt
|
||||
|
||||
try {
|
||||
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
|
||||
|
||||
this.checkChannelUpdateOrThrow(channelActor)
|
||||
|
||||
const oldState = this.video.state
|
||||
const oldVideo = { name: this.video.name, description: this.video.description }
|
||||
|
||||
const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo)
|
||||
|
||||
await runInReadCommittedTransaction(async t => {
|
||||
await this.setWebVideoFiles(videoUpdated, t)
|
||||
await this.setStreamingPlaylists(videoUpdated, t)
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
|
||||
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
|
||||
runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
|
||||
runInReadCommittedTransaction(t => this.setAutomaticTags({ video: videoUpdated, transaction: t, oldVideo })),
|
||||
runInReadCommittedTransaction(t => {
|
||||
return Promise.all([
|
||||
this.setPreview(videoUpdated, t),
|
||||
this.setThumbnail(videoUpdated, t)
|
||||
])
|
||||
}),
|
||||
this.setOrDeleteLive(videoUpdated)
|
||||
])
|
||||
|
||||
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
|
||||
|
||||
await this.updateChapters(videoUpdated)
|
||||
await this.upsertPlayerSettings(videoUpdated)
|
||||
|
||||
await autoBlacklistVideoIfNeeded({
|
||||
video: videoUpdated,
|
||||
user: undefined,
|
||||
isRemote: true,
|
||||
isNew: false,
|
||||
isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt,
|
||||
transaction: undefined
|
||||
})
|
||||
|
||||
await updateVideoRates(videoUpdated, this.videoObject)
|
||||
|
||||
// Notify our users?
|
||||
if (this.wasPrivateVideo || this.wasUnlistedVideo) {
|
||||
Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(videoUpdated)
|
||||
}
|
||||
|
||||
if (videoUpdated.isLive && oldState !== videoUpdated.state) {
|
||||
PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
|
||||
Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(videoUpdated)
|
||||
}
|
||||
|
||||
Hooks.runAction('action:activity-pub.remote-video.updated', { video: videoUpdated, videoAPObject: this.videoObject })
|
||||
|
||||
logger.info('Remote video with uuid %s updated', this.videoObject.uuid, this.lTags())
|
||||
|
||||
return videoUpdated
|
||||
} catch (err) {
|
||||
await this.catchUpdateError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check we can update the channel: we trust the remote server
|
||||
private checkChannelUpdateOrThrow (newChannelActor: MActorHost) {
|
||||
if (haveActorsSameRemoteHost(this.oldVideoChannel.Actor, newChannelActor) !== true) {
|
||||
throw new Error(`Actor ${this.oldVideoChannel.Actor.url} is not on the same host as ${newChannelActor.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
private updateVideo (channel: MChannelId, transaction?: Transaction, overrideTo?: string[]) {
|
||||
const to = overrideTo || this.videoObject.to
|
||||
const videoData = getVideoAttributesFromObject(channel, this.videoObject, to)
|
||||
|
||||
this.video.name = videoData.name
|
||||
this.video.uuid = videoData.uuid
|
||||
this.video.url = videoData.url
|
||||
this.video.category = videoData.category
|
||||
this.video.licence = videoData.licence
|
||||
this.video.language = videoData.language
|
||||
this.video.description = videoData.description
|
||||
this.video.support = videoData.support
|
||||
this.video.nsfw = videoData.nsfw
|
||||
this.video.nsfwSummary = videoData.nsfwSummary
|
||||
this.video.nsfwFlags = videoData.nsfwFlags
|
||||
this.video.commentsPolicy = videoData.commentsPolicy
|
||||
this.video.downloadEnabled = videoData.downloadEnabled
|
||||
this.video.waitTranscoding = videoData.waitTranscoding
|
||||
this.video.state = videoData.state
|
||||
this.video.duration = videoData.duration
|
||||
this.video.createdAt = videoData.createdAt
|
||||
this.video.publishedAt = videoData.publishedAt
|
||||
this.video.originallyPublishedAt = videoData.originallyPublishedAt
|
||||
this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt
|
||||
this.video.privacy = videoData.privacy
|
||||
this.video.channelId = videoData.channelId
|
||||
this.video.views = videoData.views
|
||||
this.video.isLive = videoData.isLive
|
||||
this.video.aspectRatio = videoData.aspectRatio
|
||||
|
||||
// Ensures we update the updatedAt attribute, even if main attributes did not change
|
||||
this.video.changed('updatedAt', true)
|
||||
|
||||
return this.video.save({ transaction }) as Promise<MVideoFullLight>
|
||||
}
|
||||
|
||||
private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
|
||||
await this.insertOrReplaceCaptions(videoUpdated, t)
|
||||
}
|
||||
|
||||
private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) {
|
||||
await this.insertOrReplaceStoryboard(videoUpdated, t)
|
||||
}
|
||||
|
||||
private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
|
||||
if (!this.video.isLive) return
|
||||
|
||||
if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction)
|
||||
|
||||
// Delete existing live if it exists
|
||||
await VideoLiveModel.destroy({
|
||||
where: {
|
||||
videoId: this.video.id
|
||||
},
|
||||
transaction
|
||||
})
|
||||
|
||||
videoUpdated.VideoLive = null
|
||||
}
|
||||
|
||||
private async catchUpdateError (err: Error) {
|
||||
if (this.video !== undefined) {
|
||||
await resetSequelizeInstance(this.video)
|
||||
}
|
||||
|
||||
// This is just a debug because we will retry the insert
|
||||
logger.debug('Cannot update the remote video.', { err, ...this.lTags() })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user