はじまりの大地
このコミットが含まれているのは:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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'
|
||||
|
||||
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 loadActorFromDB(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, actorUrl: string) {
|
||||
const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person')
|
||||
if (!accountAttributedTo) {
|
||||
throw new Error(`Cannot find account attributed to video channel ${actorUrl}`)
|
||||
}
|
||||
|
||||
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 ' + actorUrl)
|
||||
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 %s for owner %s', actorToCheck, 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 {
|
||||
getOrCreateAPOwner,
|
||||
getOrCreateAPActor,
|
||||
findOwner
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadActorFromDB (actorUrl: string, fetchType: ActorLoadByUrlType) {
|
||||
let actor = await loadActorByUrl(actorUrl, fetchType)
|
||||
|
||||
// Orphan actor (not associated to an account of channel) so recreate it
|
||||
if (actor && (!actor.Account && !actor.VideoChannel)) {
|
||||
await actor.destroy()
|
||||
actor = null
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
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' }
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './get.js'
|
||||
export * from './image.js'
|
||||
export * from './keys.js'
|
||||
export * from './refresh.js'
|
||||
export * from './updater.js'
|
||||
export * from './webfinger.js'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.warn('Cannot get actor URL from webfinger, keeping the old one.', { err })
|
||||
return actor.url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Op, Transaction } from 'sequelize'
|
||||
import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
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,
|
||||
MAccountDefault,
|
||||
MActor,
|
||||
MActorFullActor,
|
||||
MActorId,
|
||||
MActorImages,
|
||||
MChannel,
|
||||
MServer
|
||||
} from '@server/types/models/index.js'
|
||||
import { updateActorImages } from '../image.js'
|
||||
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js'
|
||||
import { fetchActorFollowsCount } from './url-to-object.js'
|
||||
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
|
||||
|
||||
export class APActorCreator {
|
||||
|
||||
constructor (
|
||||
private readonly actorObject: ActivityPubActor,
|
||||
private readonly ownerActor?: MActorFullActor
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
async create (): Promise<MActorFullActor> {
|
||||
const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject)
|
||||
|
||||
const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount))
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const server = await this.setServer(actorInstance, t)
|
||||
|
||||
const { actorCreated, created } = await this.saveActor(actorInstance, t)
|
||||
|
||||
await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t)
|
||||
await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t)
|
||||
|
||||
await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
|
||||
|
||||
if (isAccountActor(actorCreated.type)) {
|
||||
actorCreated.Account = await this.saveAccount(actorCreated, t) as MAccountDefault
|
||||
actorCreated.Account.Actor = actorCreated
|
||||
}
|
||||
|
||||
if (isChannelActor(actorCreated.type)) {
|
||||
const channel = await this.saveVideoChannel(actorCreated, t)
|
||||
actorCreated.VideoChannel = Object.assign(channel, { Actor: actorCreated, Account: this.ownerActor.Account })
|
||||
}
|
||||
|
||||
actorCreated.Server = server
|
||||
|
||||
return actorCreated
|
||||
})
|
||||
}
|
||||
|
||||
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 saveActor (actor: MActor, t: Transaction) {
|
||||
// Force the actor creation using findOrCreate() instead of save()
|
||||
// Sometimes Sequelize skips the save() when it thinks the instance already exists
|
||||
// (which could be false in a retried query)
|
||||
const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({
|
||||
defaults: actor.toJSON(),
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
url: actor.url
|
||||
},
|
||||
{
|
||||
serverId: actor.serverId,
|
||||
preferredUsername: actor.preferredUsername
|
||||
}
|
||||
]
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return { actorCreated, created }
|
||||
}
|
||||
|
||||
private async tryToFixActorUrlIfNeeded (actorCreated: MActor, newActor: MActor, created: boolean, t: Transaction) {
|
||||
// Try to fix non HTTPS accounts of remote instances that fixed their URL afterwards
|
||||
if (created !== true && actorCreated.url !== newActor.url) {
|
||||
// Only fix http://example.com/account/djidane to https://example.com/account/djidane
|
||||
if (actorCreated.url.replace(/^http:\/\//, '') !== newActor.url.replace(/^https:\/\//, '')) {
|
||||
throw new Error(`Actor from DB with URL ${actorCreated.url} does not correspond to actor ${newActor.url}`)
|
||||
}
|
||||
|
||||
actorCreated.url = newActor.url
|
||||
await actorCreated.save({ transaction: t })
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAccount (actor: MActorId, t: Transaction) {
|
||||
const [ accountCreated ] = await AccountModel.findOrCreate({
|
||||
defaults: {
|
||||
name: getActorDisplayNameFromObject(this.actorObject),
|
||||
description: this.actorObject.summary,
|
||||
actorId: actor.id
|
||||
},
|
||||
where: {
|
||||
actorId: actor.id
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return accountCreated as MAccount
|
||||
}
|
||||
|
||||
private async saveVideoChannel (actor: MActorId, t: Transaction) {
|
||||
const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({
|
||||
defaults: {
|
||||
name: getActorDisplayNameFromObject(this.actorObject),
|
||||
description: this.actorObject.summary,
|
||||
support: this.actorObject.support,
|
||||
actorId: actor.id,
|
||||
accountId: this.ownerActor.Account.id
|
||||
},
|
||||
where: {
|
||||
actorId: actor.id
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return videoChannelCreated as MChannel
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ActivityPubActor, ActivityPubOrderedCollection } from '@peertube/peertube-models'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
async function fetchRemoteActor (actorUrl: string): 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 })
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
fetchActorFollowsCount,
|
||||
fetchRemoteActor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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 { ActivityPubActor, ActorImageType } from '@peertube/peertube-models'
|
||||
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
|
||||
|
||||
if (this.accountOrChannel instanceof VideoChannelModel) {
|
||||
const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.url)
|
||||
this.accountOrChannel.accountId = owner.Account.id
|
||||
this.accountOrChannel.Account = owner.Account as AccountModel
|
||||
|
||||
this.accountOrChannel.support = this.actorObject.support
|
||||
}
|
||||
|
||||
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.actor.save({ transaction: t })
|
||||
await this.accountOrChannel.save({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote account %s updated', this.actorObject.url)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import WebFinger from 'webfinger.js'
|
||||
import { WebFingerData } from '@peertube/peertube-models'
|
||||
import { isProdInstance } from '@peertube/peertube-node-utils'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.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'
|
||||
|
||||
const webfinger = new WebFinger({
|
||||
webfist_fallback: false,
|
||||
tls_only: isProdInstance(),
|
||||
uri_fallback: false,
|
||||
request_timeout: REQUEST_TIMEOUTS.DEFAULT
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function getUrlFromWebfinger (uri: string) {
|
||||
const webfingerData: WebFingerData = await webfingerLookup(uri)
|
||||
return getLinkOrThrow(webfingerData)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getUrlFromWebfinger,
|
||||
loadActorUrlOrGetFromWebfinger
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getLinkOrThrow (webfingerData: WebFingerData) {
|
||||
if (Array.isArray(webfingerData.links) === false) throw new Error('WebFinger links is not an array.')
|
||||
|
||||
const selfLink = webfingerData.links.find(l => l.rel === 'self')
|
||||
if (selfLink === undefined || isActivityPubUrlValid(selfLink.href) === false) {
|
||||
throw new Error('Cannot find self link or href is not a valid URL.')
|
||||
}
|
||||
|
||||
return selfLink.href
|
||||
}
|
||||
|
||||
function webfingerLookup (nameWithHost: string) {
|
||||
return new Promise<WebFingerData>((res, rej) => {
|
||||
webfinger.lookup(nameWithHost, (err, p) => {
|
||||
if (err) return rej(err)
|
||||
|
||||
return res(p.object)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ActivityAudience } from '@peertube/peertube-models'
|
||||
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
|
||||
import { MActorFollowersUrl } from '../../types/models/index.js'
|
||||
|
||||
export function getAudience (actorSender: MActorFollowersUrl, isPublic = true) {
|
||||
return buildAudience([ actorSender.followersUrl ], isPublic)
|
||||
}
|
||||
|
||||
export function buildAudience (followerUrls: string[], isPublic = true) {
|
||||
let to: string[] = []
|
||||
let cc: string[] = []
|
||||
|
||||
if (isPublic) {
|
||||
to = [ getAPPublicValue() ]
|
||||
cc = followerUrls
|
||||
} else { // Unlisted
|
||||
to = []
|
||||
cc = []
|
||||
}
|
||||
|
||||
return { to, cc }
|
||||
}
|
||||
|
||||
export function audiencify<T> (object: T, audience: ActivityAudience) {
|
||||
return { ...audience, ...object }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js'
|
||||
import { exists } from '@server/helpers/custom-validators/misc.js'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createOrUpdateCacheFile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
|
||||
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
|
||||
|
||||
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)
|
||||
|
||||
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') {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const url = cacheFileObject.url
|
||||
const urlFPS = exists(url.fps) // TODO: compat with < 6.1, remove in 7.0
|
||||
? url.fps
|
||||
: url['_:fps']
|
||||
|
||||
const videoFile = video.VideoFiles.find(f => {
|
||||
return f.resolution === url.height && f.fps === urlFPS
|
||||
})
|
||||
|
||||
if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${urlFPS} of video ${video.url}`)
|
||||
|
||||
return {
|
||||
expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
|
||||
url: cacheFileObject.id,
|
||||
fileUrl: url.href,
|
||||
strategy: null,
|
||||
videoFileId: videoFile.id,
|
||||
actorId: byActor.id
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
|
||||
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) {
|
||||
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
|
||||
i++
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export {
|
||||
crawlCollectionPage
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Bluebird from 'bluebird'
|
||||
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 { 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 { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js'
|
||||
import { PlaylistObject } from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/index.js'
|
||||
import { crawlCollectionPage } from '../crawl.js'
|
||||
import { getOrCreateAPVideo } from '../videos/index.js'
|
||||
import {
|
||||
fetchRemotePlaylistElement,
|
||||
fetchRemoteVideoPlaylist,
|
||||
playlistElementObjectToDBAttributes,
|
||||
playlistObjectToDBAttributes
|
||||
} from './shared/index.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist')
|
||||
|
||||
async function createAccountPlaylists (playlistUrls: string[]) {
|
||||
await Bluebird.map(playlistUrls, async playlistUrl => {
|
||||
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)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
|
||||
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
|
||||
|
||||
await setVideoChannel(playlistObject, playlistAttributes)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createAccountPlaylists,
|
||||
createOrUpdateVideoPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
|
||||
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
|
||||
throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
|
||||
}
|
||||
|
||||
const actor = await getOrCreateAPActor(getAPId(playlistObject.attributedTo[0]), 'all')
|
||||
|
||||
if (!actor.VideoChannel) {
|
||||
logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
|
||||
return
|
||||
}
|
||||
|
||||
playlistAttributes.videoChannelId = actor.VideoChannel.id
|
||||
playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
|
||||
}
|
||||
|
||||
async function fetchElementUrls (playlistObject: PlaylistObject) {
|
||||
let accItems: string[] = []
|
||||
await crawlCollectionPage<string>(playlistObject.id, items => {
|
||||
accItems = accItems.concat(items)
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
return accItems
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
|
||||
return elementsToCreate
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { MVideoPlaylistFullSummary } from '@server/types/models/index.js'
|
||||
import { APObjectId } from '@peertube/peertube-models'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { createOrUpdateVideoPlaylist } from './create-update.js'
|
||||
import { scheduleRefreshIfNeeded } from './refresh.js'
|
||||
import { fetchRemoteVideoPlaylist } from './shared/index.js'
|
||||
|
||||
async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise<MVideoPlaylistFullSummary> {
|
||||
const playlistUrl = getAPId(playlistObjectArg)
|
||||
|
||||
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(playlistObject)
|
||||
|
||||
const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject)
|
||||
|
||||
return playlistCreated
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getOrCreateAPVideoPlaylist
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './get.js'
|
||||
export * from './create-update.js'
|
||||
export * from './refresh.js'
|
||||
@@ -0,0 +1,55 @@
|
||||
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, MVideoPlaylistOwner } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
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: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
|
||||
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)
|
||||
|
||||
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 {
|
||||
scheduleRefreshIfNeeded,
|
||||
refreshVideoPlaylistIfNeeded
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './url-to-object.js'
|
||||
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
ownerAccountId: null,
|
||||
videoChannelId: 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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './process.js'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.isOwned() && created === true) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ actorAnnouncer ]
|
||||
|
||||
await forwardVideoRelatedActivity(activity, t, exceptions, video)
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (videoCreated && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
AbuseObject,
|
||||
ActivityCreate,
|
||||
ActivityCreateObject,
|
||||
ActivityObject,
|
||||
CacheFileObject,
|
||||
PlaylistObject,
|
||||
VideoCommentObject,
|
||||
VideoObject,
|
||||
WatchActionObject
|
||||
} from '@peertube/peertube-models'
|
||||
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.isOwned() && !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.isOwned()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity(activity, undefined, 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 (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.isOwned()) {
|
||||
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, undefined, 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, activity.to)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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 (isAccountActor(byActorFull.type)) {
|
||||
if (!byActorFull.Account) throw new Error('Actor ' + byActorFull.url + ' is a person but we cannot find it 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.isOwned()) 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.isOwned()) 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.isOwned()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity(activity, t, exceptions, videoComment.Video)
|
||||
}
|
||||
|
||||
logger.info('Remote video comment %s removed.', videoComment.url)
|
||||
})
|
||||
}
|
||||
@@ -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?.isOwned()) 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 && existingRate.type === 'dislike') return
|
||||
|
||||
await video.increment('dislikes', { transaction: t })
|
||||
video.dislikes++
|
||||
|
||||
if (existingRate && 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)
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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.isOwned() === 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) && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
|
||||
logger.info('Rejecting %s because instance 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
|
||||
}
|
||||
@@ -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?.isOwned()) 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 && existingRate.type === 'like') return
|
||||
|
||||
if (existingRate && 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)
|
||||
})
|
||||
}
|
||||
@@ -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.isOwned() !== 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)
|
||||
})
|
||||
}
|
||||
@@ -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?.isOwned()) 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 || 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?.isOwned()) 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 || 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.isOwned()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity(activity, t, 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 %d', 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.isOwned()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity(announceActivity, t, exceptions, 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ActivityPubActor,
|
||||
ActivityPubActorType,
|
||||
ActivityUpdate,
|
||||
ActivityUpdateObject,
|
||||
CacheFileObject,
|
||||
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 { APActorUpdater } from '../actors/updater.js'
|
||||
import { createOrUpdateCacheFile } from '../cache-file.js'
|
||||
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo } 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)
|
||||
}
|
||||
|
||||
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(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.isOwned() && !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.isOwned()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
|
||||
}
|
||||
}
|
||||
|
||||
async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
|
||||
logger.debug('Updating remote account "%s".', actorObject.url)
|
||||
|
||||
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, activity.to)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||
import { ActivityView } from '@peertube/peertube-models'
|
||||
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: getExpires(activity)
|
||||
? new Date(getExpires(activity))
|
||||
: undefined,
|
||||
viewerResultCounter: getViewerResultCounter(activity)
|
||||
})
|
||||
|
||||
if (video.isOwned()) {
|
||||
// Forward the view but don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer protocol V2
|
||||
function getViewerResultCounter (activity: ActivityView) {
|
||||
const result = activity.result
|
||||
|
||||
if (!getExpires(activity) || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined
|
||||
|
||||
const counter = parseInt(result.userInteractionCount + '')
|
||||
if (isNaN(counter)) return undefined
|
||||
|
||||
return counter
|
||||
}
|
||||
|
||||
// TODO: compat with < 6.1, remove in 7.0
|
||||
function getExpires (activity: ActivityView) {
|
||||
return activity.expires || activity['expiration'] as string
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ContextType } from '@peertube/peertube-models'
|
||||
import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@server/initializers/constants.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { MActor } from '@server/types/models/index.js'
|
||||
import { getContextFilter } from '../context.js'
|
||||
import { buildDigestFromWorker, signJsonLDObjectFromWorker } from '@server/lib/worker/parent-process.js'
|
||||
import { signAndContextify } from '@server/helpers/activity-pub-utils.js'
|
||||
import { logger } from '@server/helpers/logger.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 ActorModel.load(payload.signatureActorId)
|
||||
if (!actorSignature) throw new Error('Unknown signature actor id.')
|
||||
|
||||
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
|
||||
}) {
|
||||
let actor: MActor | null
|
||||
|
||||
if (options.signatureActorId) {
|
||||
actor = await ActorModel.load(options.signatureActorId)
|
||||
if (!actor) throw new Error('Unknown signature actor id.')
|
||||
} else {
|
||||
// We need to sign the request, so use the server
|
||||
actor = await getServerActor()
|
||||
}
|
||||
|
||||
const keyId = actor.url
|
||||
return {
|
||||
algorithm: HTTP_SIGNATURE.ALGORITHM,
|
||||
authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
|
||||
keyId,
|
||||
key: actor.privateKey,
|
||||
headers: options.hasPayload
|
||||
? HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD
|
||||
: HTTP_SIGNATURE.HEADERS_TO_SIGN_WITHOUT_PAYLOAD
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAnnounce, ActivityAudience } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActorLight, MVideo } from '../../../types/models/index.js'
|
||||
import { MVideoShare } from '../../../types/models/video/index.js'
|
||||
import { audiencify, getAudience } from '../audience.js'
|
||||
import { getActorsInvolvedInVideo, getAudienceFromFollowersOf } from './shared/index.js'
|
||||
import { broadcastToFollowers } from './shared/send-utils.js'
|
||||
|
||||
async function buildAnnounceWithVideoAudience (
|
||||
byActor: MActorLight,
|
||||
videoShare: MVideoShare,
|
||||
video: MVideo,
|
||||
t: Transaction
|
||||
) {
|
||||
const announcedObject = video.url
|
||||
|
||||
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
|
||||
const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
|
||||
|
||||
const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
|
||||
|
||||
return { activity, actorsInvolvedInVideo }
|
||||
}
|
||||
|
||||
async function sendVideoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
|
||||
const { activity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction)
|
||||
|
||||
logger.info('Creating job to send announce %s.', videoShare.url)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInVideo,
|
||||
transaction,
|
||||
actorsException: [ byActor ],
|
||||
contextType: 'Announce'
|
||||
})
|
||||
}
|
||||
|
||||
function buildAnnounceActivity (url: string, byActor: MActorLight, object: string, audience?: ActivityAudience): ActivityAnnounce {
|
||||
if (!audience) audience = getAudience(byActor)
|
||||
|
||||
return audiencify({
|
||||
type: 'Announce' as 'Announce',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
}, audience)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendVideoAnnounce,
|
||||
buildAnnounceActivity,
|
||||
buildAnnounceWithVideoAudience
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
ActivityAudience,
|
||||
ActivityCreate,
|
||||
ActivityCreateObject,
|
||||
ContextType,
|
||||
VideoCommentObject,
|
||||
VideoPlaylistPrivacy,
|
||||
VideoPrivacy
|
||||
} 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,
|
||||
MVideoRedundancyFileVideo,
|
||||
MVideoRedundancyStreamingPlaylistVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getAudience } from '../audience.js'
|
||||
import { canVideoBeFederated } from '../videos/federate.js'
|
||||
import {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
getAudienceFromFollowersOf,
|
||||
getVideoCommentAudience,
|
||||
sendVideoActivityToOrigin,
|
||||
sendVideoRelatedActivity,
|
||||
unicastTo
|
||||
} from './shared/index.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 = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
|
||||
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 | MVideoRedundancyFileVideo
|
||||
) {
|
||||
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 sendVideoActivityToOrigin(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 = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
|
||||
|
||||
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) {
|
||||
const isOrigin = comment.Video.isOwned()
|
||||
|
||||
if (isOrigin) {
|
||||
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 actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
|
||||
// Add the actor that commented too
|
||||
actorsInvolvedInComment.push(byActor)
|
||||
|
||||
const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
|
||||
.map(c => c.Account.Actor)
|
||||
|
||||
let audience: ActivityAudience
|
||||
if (isOrigin) {
|
||||
audience = getVideoCommentAudience(comment, threadParentComments, actorsInvolvedInComment, isOrigin)
|
||||
} else {
|
||||
audience = getAudienceFromFollowersOf(actorsInvolvedInComment.concat(parentsCommentActors))
|
||||
}
|
||||
|
||||
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 (isOrigin) {
|
||||
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 = getAudience(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)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
|
||||
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 } from '../audience.js'
|
||||
import { getDeleteActivityPubUrl } from '../url.js'
|
||||
import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared/index.js'
|
||||
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './shared/send-utils.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
|
||||
async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
|
||||
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 })
|
||||
}
|
||||
|
||||
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.loadActorsWhoSharedVideosOf(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 (videoComment: MCommentOwnerVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to send delete of comment %s.', videoComment.url)
|
||||
|
||||
const isVideoOrigin = videoComment.Video.isOwned()
|
||||
|
||||
const url = getDeleteActivityPubUrl(videoComment.url)
|
||||
|
||||
const videoAccount = await AccountModel.load(videoComment.Video.VideoChannel.Account.id, transaction)
|
||||
|
||||
const byActor = videoComment.isOwned()
|
||||
? videoComment.Account.Actor
|
||||
: videoAccount.Actor
|
||||
|
||||
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment, transaction })
|
||||
const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
|
||||
|
||||
const actorsInvolvedInComment = await getActorsInvolvedInVideo(videoComment.Video, transaction)
|
||||
actorsInvolvedInComment.push(byActor) // Add the actor that commented the video
|
||||
|
||||
const audience = getVideoCommentAudience(videoComment, threadParentCommentsFiltered, actorsInvolvedInComment, isVideoOrigin)
|
||||
const activity = buildDeleteActivity(url, videoComment.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) {
|
||||
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 {
|
||||
sendDeleteVideo,
|
||||
sendDeleteActor,
|
||||
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
|
||||
}
|
||||
@@ -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, getAudience } from '../audience.js'
|
||||
import { getVideoDislikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { sendVideoActivityToOrigin } 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 sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
|
||||
}
|
||||
|
||||
function buildDislikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityDislike {
|
||||
if (!audience) audience = getAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
id: url,
|
||||
type: 'Dislike' as 'Dislike',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendDislike,
|
||||
buildDislikeActivity
|
||||
}
|
||||
@@ -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, getAudience } 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 = getAudience(byActor)
|
||||
|
||||
const activity = { id: url, actor: byActor.url, ...abuse.toActivityPubObject() }
|
||||
|
||||
return audiencify(activity, audience)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendAbuse
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, getAudience } from '../audience.js'
|
||||
import { getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { sendVideoActivityToOrigin } 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 sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
|
||||
}
|
||||
|
||||
function buildLikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityLike {
|
||||
if (!audience) audience = getAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
id: url,
|
||||
type: 'Like' as 'Like',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendLike,
|
||||
buildLikeActivity
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@peertube/peertube-models'
|
||||
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, getAudience } 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, sendVideoActivityToOrigin, sendVideoRelatedActivity, 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 { activity: announce, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction)
|
||||
const undoActivity = undoActivityData(undoUrl, byActor, announce)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: undoActivity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInVideo,
|
||||
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 {
|
||||
sendUndoFollow,
|
||||
sendUndoLike,
|
||||
sendUndoDislike,
|
||||
sendUndoAnnounce,
|
||||
sendUndoCacheFile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function undoActivityData <T extends ActivityUndoObject> (
|
||||
url: string,
|
||||
byActor: MActorAudience,
|
||||
object: T,
|
||||
audience?: ActivityAudience
|
||||
): ActivityUndo<T> {
|
||||
if (!audience) audience = getAudience(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 sendVideoActivityToOrigin(activityBuilder, { ...options, contextType: 'Rate' })
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { getServerActor } from '@server/models/application/application.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,
|
||||
MVideoPlaylistFull,
|
||||
MVideoRedundancyVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getAudience } from '../audience.js'
|
||||
import { getUpdateActivityPubUrl } from '../url.js'
|
||||
import { canVideoBeFederated } from '../videos/federate.js'
|
||||
import { getActorsInvolvedInVideo } from './shared/index.js'
|
||||
import { broadcastToFollowers, 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 = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
|
||||
|
||||
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 = getAudience(byActor)
|
||||
const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience)
|
||||
|
||||
let actorsInvolved: MActor[]
|
||||
if (accountOrChannel instanceof AccountModel) {
|
||||
// Actors that shared my videos are involved too
|
||||
actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction)
|
||||
} else {
|
||||
// Actors that shared videos of my channel are involved too
|
||||
actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, transaction)
|
||||
}
|
||||
|
||||
actorsInvolved.push(byActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolved,
|
||||
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 = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC)
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildUpdateActivity (
|
||||
url: string,
|
||||
byActor: MActorLight,
|
||||
object: ActivityUpdateObject,
|
||||
audience?: ActivityAudience
|
||||
): ActivityUpdate<ActivityUpdateObject> {
|
||||
if (!audience) audience = getAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Update' as 'Update',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: audiencify(object, audience)
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||
import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models/index.js'
|
||||
import { ActivityAudience, ActivityView } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { audiencify, getAudience } from '../audience.js'
|
||||
import { getLocalVideoViewActivityPubUrl } from '../url.js'
|
||||
import { sendVideoRelatedActivity } from './shared/send-utils.js'
|
||||
import { isUsingViewersFederationV2 } from '@peertube/peertube-node-utils'
|
||||
|
||||
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 = getAudience(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: isUsingViewersFederationV2()
|
||||
? {
|
||||
interactionType: 'WatchAction',
|
||||
type: 'InteractionCounter',
|
||||
userInteractionCount: viewersCount
|
||||
}
|
||||
: undefined
|
||||
}, audience)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ActivityAudience } from '@peertube/peertube-models'
|
||||
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { VideoShareModel } from '@server/models/video/video-share.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
|
||||
export function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience {
|
||||
return {
|
||||
to: [ accountActor.url ],
|
||||
cc: actorsInvolvedInVideo.map(a => a.followersUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function getVideoCommentAudience (
|
||||
videoComment: MCommentOwnerVideo,
|
||||
threadParentComments: MCommentOwner[],
|
||||
actorsInvolvedInVideo: MActorFollowersUrl[],
|
||||
isOrigin = false
|
||||
): ActivityAudience {
|
||||
const to = [ getAPPublicValue() ]
|
||||
const cc: string[] = []
|
||||
|
||||
// Owner of the video we comment
|
||||
if (isOrigin === false) {
|
||||
cc.push(videoComment.Video.VideoChannel.Account.Actor.url)
|
||||
}
|
||||
|
||||
// Followers of the poster
|
||||
cc.push(videoComment.Account.Actor.followersUrl)
|
||||
|
||||
// Send to actors we reply to
|
||||
for (const parentComment of threadParentComments) {
|
||||
if (parentComment.isDeleted()) continue
|
||||
|
||||
cc.push(parentComment.Account.Actor.url)
|
||||
}
|
||||
|
||||
return {
|
||||
to,
|
||||
cc: cc.concat(actorsInvolvedInVideo.map(a => a.followersUrl))
|
||||
}
|
||||
}
|
||||
|
||||
export function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience {
|
||||
return {
|
||||
to: [ getAPPublicValue() as string ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
|
||||
cc: []
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './audience-utils.js'
|
||||
export * from './send-utils.js'
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
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 { 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 { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getOriginVideoAudience } from './audience-utils.js'
|
||||
|
||||
async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable | MVideoAccountLight
|
||||
contextType: ContextType
|
||||
parallelizable?: boolean
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { byActor, video, transaction, contextType, parallelizable } = options
|
||||
|
||||
// Send to origin
|
||||
if (video.isOwned() === false) {
|
||||
return sendVideoActivityToOrigin(activityBuilder, options)
|
||||
}
|
||||
|
||||
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, transaction)
|
||||
|
||||
// Send to followers
|
||||
const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
|
||||
const activity = activityBuilder(audience)
|
||||
|
||||
const actorsException = [ byActor ]
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInVideo,
|
||||
transaction,
|
||||
actorsException,
|
||||
parallelizable,
|
||||
contextType
|
||||
})
|
||||
}
|
||||
|
||||
async function sendVideoActivityToOrigin (activityBuilder: (audience: ActivityAudience) => Activity, options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable | MVideoAccountLight
|
||||
contextType: ContextType
|
||||
|
||||
actorsInvolvedInVideo?: MActorLight[]
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { byActor, video, actorsInvolvedInVideo, transaction, contextType } = options
|
||||
|
||||
if (video.isOwned()) 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 = getOriginVideoAudience(accountActor, actorsInvolvedInVideo)
|
||||
const activity = activityBuilder(audience)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return unicastTo({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActorUrl: accountActor.getSharedInbox(),
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function forwardVideoRelatedActivity (
|
||||
activity: Activity,
|
||||
t: Transaction,
|
||||
followersException: MActorWithInboxes[],
|
||||
video: MVideoId
|
||||
) {
|
||||
// Mastodon does not add our announces in audience, so we forward to them manually
|
||||
const additionalActors = await getActorsInvolvedInVideo(video, t)
|
||||
const additionalFollowerUrls = additionalActors.map(a => a.followersUrl)
|
||||
|
||||
return forwardActivity(activity, t, followersException, additionalFollowerUrls)
|
||||
}
|
||||
|
||||
async function forwardActivity (
|
||||
activity: Activity,
|
||||
t: Transaction,
|
||||
followersException: MActorWithInboxes[] = [],
|
||||
additionalFollowerUrls: string[] = []
|
||||
) {
|
||||
logger.info('Forwarding activity %s.', activity.id)
|
||||
|
||||
const to = activity.to || []
|
||||
const cc = activity.cc || []
|
||||
|
||||
const followersUrls = additionalFollowerUrls
|
||||
for (const dest of to.concat(cc)) {
|
||||
if (dest.endsWith('/followers')) {
|
||||
followersUrls.push(dest)
|
||||
}
|
||||
}
|
||||
|
||||
const toActorFollowers = await ActorModel.listByFollowersUrls(followersUrls, t)
|
||||
const uris = await computeFollowerUris(toActorFollowers, followersException, t)
|
||||
|
||||
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(t, () => 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 {
|
||||
broadcastToFollowers,
|
||||
unicastTo,
|
||||
broadcastToActors,
|
||||
sendVideoActivityToOrigin,
|
||||
forwardVideoRelatedActivity,
|
||||
sendVideoRelatedActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 ])
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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) {
|
||||
logger.warn('Cannot add share %s.', 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.actorId,
|
||||
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.actorId, video.id, t)
|
||||
if (!oldShare) return new Error('Cannot find old video channel share ' + oldVideoChannel.actorId + ' for video ' + video.id)
|
||||
|
||||
await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t)
|
||||
await oldShare.destroy({ transaction: t })
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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 { MVideoFileVideoUUID } from '../../types/models/video/video-file.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 getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) {
|
||||
const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
|
||||
|
||||
return `${WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
|
||||
}
|
||||
|
||||
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 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 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 getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: MCommentId) {
|
||||
return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getAbuseTargetUrl (abuse: MAbuseFull) {
|
||||
return abuse.VideoAbuse?.Video?.url ||
|
||||
abuse.VideoCommentAbuse?.VideoComment?.url ||
|
||||
abuse.FlaggedAccount.Actor.url
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { 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'
|
||||
|
||||
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[]) {
|
||||
return Bluebird.map(commentUrls, async commentUrl => {
|
||||
try {
|
||||
await resolveThread({ url: commentUrl, isVideo: false })
|
||||
} catch (err) {
|
||||
logger.warn('Cannot resolve thread %s.', 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.isOwned() && !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.isOwned() || comment.isOwned()) {
|
||||
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 || acceptedResult.accepted !== true) {
|
||||
logger.info('Refused to create a remote comment.', { acceptedResult, acceptParameters })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -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.isOwned()) 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.isOwned()) 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './federate.js'
|
||||
export * from './get.js'
|
||||
export * from './refresh.js'
|
||||
export * from './updater.js'
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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 { 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 {
|
||||
getCaptionAttributesFromObject,
|
||||
getFileAttributesFromUrl,
|
||||
getLiveAttributesFromObject,
|
||||
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.url)
|
||||
|
||||
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 })
|
||||
|
||||
video.VideoLive = videoLive
|
||||
}
|
||||
|
||||
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 updateChaptersOutsideTransaction (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 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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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.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.updateChaptersOutsideTransaction(videoCreated)
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
}
|
||||
}
|
||||
@@ -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,299 @@
|
||||
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ActivityHashTagObject,
|
||||
ActivityMagnetUrlObject,
|
||||
ActivityPlaylistSegmentHashesObject,
|
||||
ActivityPlaylistUrlObject,
|
||||
ActivityTagObject,
|
||||
ActivityUrlObject,
|
||||
ActivityVideoUrlObject,
|
||||
VideoObject,
|
||||
VideoPrivacy,
|
||||
VideoStreamingPlaylistType
|
||||
} from '@peertube/peertube-models'
|
||||
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
|
||||
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { 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 { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId, isStreamingPlaylist } 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 = isStreamingPlaylist(videoOrPlaylist) ? null : videoOrPlaylist.id
|
||||
|
||||
const videoStreamingPlaylistId = isStreamingPlaylist(videoOrPlaylist)
|
||||
? videoOrPlaylist.id
|
||||
: null
|
||||
|
||||
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl })
|
||||
|
||||
const attribute = {
|
||||
extname,
|
||||
resolution,
|
||||
|
||||
size: fileUrl.size,
|
||||
fps: 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,
|
||||
|
||||
// This is a video file owned by a video or by a streaming playlist
|
||||
videoId,
|
||||
videoStreamingPlaylistId
|
||||
}
|
||||
|
||||
attributes.push(attribute)
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
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: unknown[] = 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 getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
return videoObject.subtitleLanguage.map(c => ({
|
||||
videoId: video.id,
|
||||
filename: VideoCaptionModel.generateCaptionName(c.identifier),
|
||||
language: c.identifier,
|
||||
automaticallyGenerated: c.automaticallyGenerated === true,
|
||||
fileUrl: c.url
|
||||
}))
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
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 && url.mediaType === 'application/x-mpegURL'
|
||||
}
|
||||
|
||||
function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
|
||||
return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
|
||||
}
|
||||
|
||||
function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
|
||||
return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
|
||||
}
|
||||
|
||||
function isAPHashTagObject (url: any): url is ActivityHashTagObject {
|
||||
return url && url.type === 'Hashtag'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
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 {
|
||||
MActor,
|
||||
MChannelAccountLight,
|
||||
MChannelId,
|
||||
MVideoAccountLightBlacklistAllFiles,
|
||||
MVideoFullLight
|
||||
} from '@server/types/models/index.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.updateChaptersOutsideTransaction(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: MActor) {
|
||||
if (!this.oldVideoChannel.Actor.serverId || !newChannelActor.serverId) {
|
||||
throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
|
||||
}
|
||||
|
||||
if (this.oldVideoChannel.Actor.serverId !== newChannelActor.serverId) {
|
||||
throw new Error(`New channel ${newChannelActor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.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.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ACTOR_FOLLOW_SCORE } from '../initializers/constants.js'
|
||||
import { logger } from '../helpers/logger.js'
|
||||
|
||||
// Cache follows scores, instead of writing them too often in database
|
||||
// Keep data in memory, we don't really need Redis here as we don't really care to loose some scores
|
||||
class ActorFollowHealthCache {
|
||||
|
||||
private static instance: ActorFollowHealthCache
|
||||
|
||||
private pendingFollowsScore: { [ url: string ]: number } = {}
|
||||
|
||||
private pendingBadServer = new Set<number>()
|
||||
private pendingGoodServer = new Set<number>()
|
||||
|
||||
private readonly badInboxes = new Set<string>()
|
||||
|
||||
private constructor () {}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
||||
updateActorFollowsHealth (goodInboxes: string[], badInboxes: string[]) {
|
||||
this.badInboxes.clear()
|
||||
|
||||
if (goodInboxes.length === 0 && badInboxes.length === 0) return
|
||||
|
||||
logger.info(
|
||||
'Updating %d good actor follows and %d bad actor follows scores in cache.',
|
||||
goodInboxes.length, badInboxes.length, { badInboxes }
|
||||
)
|
||||
|
||||
for (const goodInbox of goodInboxes) {
|
||||
if (this.pendingFollowsScore[goodInbox] === undefined) this.pendingFollowsScore[goodInbox] = 0
|
||||
|
||||
this.pendingFollowsScore[goodInbox] += ACTOR_FOLLOW_SCORE.BONUS
|
||||
}
|
||||
|
||||
for (const badInbox of badInboxes) {
|
||||
if (this.pendingFollowsScore[badInbox] === undefined) this.pendingFollowsScore[badInbox] = 0
|
||||
|
||||
this.pendingFollowsScore[badInbox] += ACTOR_FOLLOW_SCORE.PENALTY
|
||||
this.badInboxes.add(badInbox)
|
||||
}
|
||||
}
|
||||
|
||||
isBadInbox (inboxUrl: string) {
|
||||
return this.badInboxes.has(inboxUrl)
|
||||
}
|
||||
|
||||
addBadServerId (serverId: number) {
|
||||
this.pendingBadServer.add(serverId)
|
||||
}
|
||||
|
||||
getBadFollowingServerIds () {
|
||||
return Array.from(this.pendingBadServer)
|
||||
}
|
||||
|
||||
clearBadFollowingServerIds () {
|
||||
this.pendingBadServer = new Set<number>()
|
||||
}
|
||||
|
||||
addGoodServerId (serverId: number) {
|
||||
this.pendingGoodServer.add(serverId)
|
||||
}
|
||||
|
||||
getGoodFollowingServerIds () {
|
||||
return Array.from(this.pendingGoodServer)
|
||||
}
|
||||
|
||||
clearGoodFollowingServerIds () {
|
||||
this.pendingGoodServer = new Set<number>()
|
||||
}
|
||||
|
||||
getPendingFollowsScore () {
|
||||
return this.pendingFollowsScore
|
||||
}
|
||||
|
||||
clearPendingFollowsScore () {
|
||||
this.pendingFollowsScore = {}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ActorFollowHealthCache
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
isUserAdminFlagsValid,
|
||||
isUserDisplayNameValid,
|
||||
isUserRoleValid,
|
||||
isUserUsernameValid,
|
||||
isUserVideoQuotaDailyValid,
|
||||
isUserVideoQuotaValid
|
||||
} from '@server/helpers/custom-validators/users.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { generateRandomString } from '@server/helpers/utils.js'
|
||||
import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants.js'
|
||||
import { PluginManager } from '@server/lib/plugins/plugin-manager.js'
|
||||
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
|
||||
import { MUser } from '@server/types/models/index.js'
|
||||
import {
|
||||
RegisterServerAuthenticatedResult,
|
||||
RegisterServerAuthPassOptions,
|
||||
RegisterServerExternalAuthenticatedResult
|
||||
} from '@server/types/plugins/register-server-auth.model.js'
|
||||
import { UserAdminFlag, UserRole } from '@peertube/peertube-models'
|
||||
import { BypassLogin } from './oauth-model.js'
|
||||
|
||||
export type ExternalUser =
|
||||
Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
|
||||
{ displayName: string }
|
||||
|
||||
// Token is the key, expiration date is the value
|
||||
const authBypassTokens = new Map<string, {
|
||||
expires: Date
|
||||
user: ExternalUser
|
||||
userUpdater: RegisterServerAuthenticatedResult['userUpdater']
|
||||
authName: string
|
||||
npmName: string
|
||||
}>()
|
||||
|
||||
async function onExternalUserAuthenticated (options: {
|
||||
npmName: string
|
||||
authName: string
|
||||
authResult: RegisterServerExternalAuthenticatedResult
|
||||
}) {
|
||||
const { npmName, authName, authResult } = options
|
||||
|
||||
if (!authResult.req || !authResult.res) {
|
||||
logger.error('Cannot authenticate external user for auth %s of plugin %s: no req or res are provided.', authName, npmName)
|
||||
return
|
||||
}
|
||||
|
||||
const { res } = authResult
|
||||
|
||||
if (!isAuthResultValid(npmName, authName, authResult)) {
|
||||
res.redirect('/login?externalAuthError=true')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Generating auth bypass token for %s in auth %s of plugin %s.', authResult.username, authName, npmName)
|
||||
|
||||
const bypassToken = await generateRandomString(32)
|
||||
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME)
|
||||
|
||||
const user = buildUserResult(authResult)
|
||||
authBypassTokens.set(bypassToken, {
|
||||
expires,
|
||||
user,
|
||||
npmName,
|
||||
authName,
|
||||
userUpdater: authResult.userUpdater
|
||||
})
|
||||
|
||||
// Cleanup expired tokens
|
||||
const now = new Date()
|
||||
for (const [ key, value ] of authBypassTokens) {
|
||||
if (value.expires.getTime() < now.getTime()) {
|
||||
authBypassTokens.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect(`/login?externalAuthToken=${bypassToken}&username=${user.username}`)
|
||||
}
|
||||
|
||||
async function getAuthNameFromRefreshGrant (refreshToken?: string) {
|
||||
if (!refreshToken) return undefined
|
||||
|
||||
const tokenModel = await OAuthTokenModel.loadByRefreshToken(refreshToken)
|
||||
|
||||
return tokenModel?.authName
|
||||
}
|
||||
|
||||
async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
|
||||
const plugins = PluginManager.Instance.getIdAndPassAuths()
|
||||
const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const auths = plugin.idAndPassAuths
|
||||
|
||||
for (const auth of auths) {
|
||||
pluginAuths.push({
|
||||
npmName: plugin.npmName,
|
||||
registerAuthOptions: auth
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pluginAuths.sort((a, b) => {
|
||||
const aWeight = a.registerAuthOptions.getWeight()
|
||||
const bWeight = b.registerAuthOptions.getWeight()
|
||||
|
||||
// DESC weight order
|
||||
if (aWeight === bWeight) return 0
|
||||
if (aWeight < bWeight) return 1
|
||||
return -1
|
||||
})
|
||||
|
||||
const loginOptions = {
|
||||
id: username,
|
||||
password
|
||||
}
|
||||
|
||||
for (const pluginAuth of pluginAuths) {
|
||||
const authOptions = pluginAuth.registerAuthOptions
|
||||
const authName = authOptions.authName
|
||||
const npmName = pluginAuth.npmName
|
||||
|
||||
logger.debug(
|
||||
'Using auth method %s of plugin %s to login %s with weight %d.',
|
||||
authName, npmName, loginOptions.id, authOptions.getWeight()
|
||||
)
|
||||
|
||||
try {
|
||||
const loginResult = await authOptions.login(loginOptions)
|
||||
|
||||
if (!loginResult) continue
|
||||
if (!isAuthResultValid(pluginAuth.npmName, authOptions.authName, loginResult)) continue
|
||||
|
||||
logger.info(
|
||||
'Login success with auth method %s of plugin %s for %s.',
|
||||
authName, npmName, loginOptions.id
|
||||
)
|
||||
|
||||
return {
|
||||
bypass: true,
|
||||
pluginName: pluginAuth.npmName,
|
||||
authName: authOptions.authName,
|
||||
user: buildUserResult(loginResult),
|
||||
userUpdater: loginResult.userUpdater
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
|
||||
const obj = authBypassTokens.get(externalAuthToken)
|
||||
if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
|
||||
|
||||
const { expires, user, authName, npmName } = obj
|
||||
|
||||
const now = new Date()
|
||||
if (now.getTime() > expires.getTime()) {
|
||||
throw new Error('Cannot authenticate user with an expired external auth token')
|
||||
}
|
||||
|
||||
if (user.username !== username) {
|
||||
throw new Error(`Cannot authenticate user ${user.username} with invalid username ${username}`)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'Auth success with external auth method %s of plugin %s for %s.',
|
||||
authName, npmName, user.email
|
||||
)
|
||||
|
||||
return {
|
||||
bypass: true,
|
||||
pluginName: npmName,
|
||||
authName,
|
||||
userUpdater: obj.userUpdater,
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
|
||||
const returnError = (field: string) => {
|
||||
logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isUserUsernameValid(result.username)) return returnError('username')
|
||||
if (!result.email) return returnError('email')
|
||||
|
||||
// Following fields are optional
|
||||
if (result.role && !isUserRoleValid(result.role)) return returnError('role')
|
||||
if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
|
||||
if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
|
||||
if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
|
||||
if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
|
||||
|
||||
if (result.userUpdater && typeof result.userUpdater !== 'function') {
|
||||
logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
|
||||
return {
|
||||
username: pluginResult.username,
|
||||
email: pluginResult.email,
|
||||
role: pluginResult.role ?? UserRole.USER,
|
||||
displayName: pluginResult.displayName || pluginResult.username,
|
||||
|
||||
adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
|
||||
|
||||
videoQuota: pluginResult.videoQuota,
|
||||
videoQuotaDaily: pluginResult.videoQuotaDaily
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
onExternalUserAuthenticated,
|
||||
getBypassFromExternalAuth,
|
||||
getAuthNameFromRefreshGrant,
|
||||
getBypassFromPasswordGrant
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import express from 'express'
|
||||
import { AccessDeniedError } from '@node-oauth/oauth2-server'
|
||||
import { PluginManager } from '@server/lib/plugins/plugin-manager.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types/index.js'
|
||||
import { MOAuthClient } from '@server/types/models/index.js'
|
||||
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
|
||||
import { MUser, MUserDefault } from '@server/types/models/user/user.js'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { OAuthClientModel } from '../../models/oauth/oauth-client.js'
|
||||
import { OAuthTokenModel } from '../../models/oauth/oauth-token.js'
|
||||
import { UserModel } from '../../models/user/user.js'
|
||||
import { findAvailableLocalActorName } from '../local-actor.js'
|
||||
import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user.js'
|
||||
import { ExternalUser } from './external-auth.js'
|
||||
import { TokensCache } from './tokens-cache.js'
|
||||
|
||||
type TokenInfo = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accessTokenExpiresAt: Date
|
||||
refreshTokenExpiresAt: Date
|
||||
}
|
||||
|
||||
export type BypassLogin = {
|
||||
bypass: boolean
|
||||
pluginName: string
|
||||
authName?: string
|
||||
user: ExternalUser
|
||||
userUpdater: RegisterServerAuthenticatedResult['userUpdater']
|
||||
}
|
||||
|
||||
async function getAccessToken (bearerToken: string) {
|
||||
logger.debug('Getting access token.')
|
||||
|
||||
if (!bearerToken) return undefined
|
||||
|
||||
let tokenModel: MOAuthTokenUser
|
||||
|
||||
if (TokensCache.Instance.hasToken(bearerToken)) {
|
||||
tokenModel = TokensCache.Instance.getByToken(bearerToken)
|
||||
} else {
|
||||
tokenModel = await OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
|
||||
|
||||
if (tokenModel) TokensCache.Instance.setToken(tokenModel)
|
||||
}
|
||||
|
||||
if (!tokenModel) return undefined
|
||||
|
||||
if (tokenModel.User.pluginAuth) {
|
||||
const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'access')
|
||||
|
||||
if (valid !== true) return undefined
|
||||
}
|
||||
|
||||
return tokenModel
|
||||
}
|
||||
|
||||
function getClient (clientId: string, clientSecret: string) {
|
||||
logger.debug('Getting Client (clientId: ' + clientId + ', clientSecret: ' + clientSecret + ').')
|
||||
|
||||
return OAuthClientModel.getByIdAndSecret(clientId, clientSecret)
|
||||
}
|
||||
|
||||
async function getRefreshToken (refreshToken: string) {
|
||||
logger.debug('Getting RefreshToken (refreshToken: ' + refreshToken + ').')
|
||||
|
||||
const tokenInfo = await OAuthTokenModel.getByRefreshTokenAndPopulateClient(refreshToken)
|
||||
if (!tokenInfo) return undefined
|
||||
|
||||
const tokenModel = tokenInfo.token
|
||||
|
||||
if (tokenModel.User.pluginAuth) {
|
||||
const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'refresh')
|
||||
|
||||
if (valid !== true) return undefined
|
||||
}
|
||||
|
||||
return tokenInfo
|
||||
}
|
||||
|
||||
async function getUser (usernameOrEmail?: string, password?: string, bypassLogin?: BypassLogin) {
|
||||
// Special treatment coming from a plugin
|
||||
if (bypassLogin && bypassLogin.bypass === true) {
|
||||
logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
|
||||
|
||||
let user = await UserModel.loadByEmail(bypassLogin.user.email)
|
||||
|
||||
if (!user) {
|
||||
user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
|
||||
} else if (user.pluginAuth === bypassLogin.pluginName) {
|
||||
user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
|
||||
}
|
||||
|
||||
// Cannot create a user
|
||||
if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
|
||||
|
||||
// If the user does not belongs to a plugin, it was created before its installation
|
||||
// Then we just go through a regular login process
|
||||
if (user.pluginAuth !== null) {
|
||||
// This user does not belong to this plugin, skip it
|
||||
if (user.pluginAuth !== bypassLogin.pluginName) {
|
||||
logger.info(
|
||||
'Cannot bypass oauth login by plugin %s because %s has another plugin auth method (%s).',
|
||||
bypassLogin.pluginName, bypassLogin.user.email, user.pluginAuth
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
checkUserValidityOrThrow(user)
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
|
||||
|
||||
const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail)
|
||||
|
||||
// If we don't find the user, or if the user belongs to a plugin
|
||||
if (!user || user.pluginAuth !== null || !password) return null
|
||||
|
||||
const passwordMatch = await user.isPasswordMatch(password)
|
||||
if (passwordMatch !== true) return null
|
||||
|
||||
checkUserValidityOrThrow(user)
|
||||
|
||||
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
|
||||
throw new AccessDeniedError('User email is not verified.')
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async function revokeToken (
|
||||
tokenInfo: { refreshToken: string },
|
||||
options: {
|
||||
req?: express.Request
|
||||
explicitLogout?: boolean
|
||||
} = {}
|
||||
): Promise<{ success: boolean, redirectUrl?: string }> {
|
||||
const { req, explicitLogout } = options
|
||||
|
||||
const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
|
||||
|
||||
if (token) {
|
||||
let redirectUrl: string
|
||||
|
||||
if (explicitLogout === true && token.User.pluginAuth && token.authName) {
|
||||
redirectUrl = await PluginManager.Instance.onLogout(token.User.pluginAuth, token.authName, token.User, req)
|
||||
}
|
||||
|
||||
TokensCache.Instance.clearCacheByToken(token.accessToken)
|
||||
|
||||
token.destroy()
|
||||
.catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
|
||||
|
||||
return { success: true, redirectUrl }
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
async function saveToken (
|
||||
token: TokenInfo,
|
||||
client: MOAuthClient,
|
||||
user: MUser,
|
||||
options: {
|
||||
refreshTokenAuthName?: string
|
||||
bypassLogin?: BypassLogin
|
||||
} = {}
|
||||
) {
|
||||
const { refreshTokenAuthName, bypassLogin } = options
|
||||
let authName: string = null
|
||||
|
||||
if (bypassLogin?.bypass === true) {
|
||||
authName = bypassLogin.authName
|
||||
} else if (refreshTokenAuthName) {
|
||||
authName = refreshTokenAuthName
|
||||
}
|
||||
|
||||
logger.debug('Saving token ' + token.accessToken + ' for client ' + client.id + ' and user ' + user.id + '.')
|
||||
|
||||
const tokenToCreate = {
|
||||
accessToken: token.accessToken,
|
||||
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
||||
refreshToken: token.refreshToken,
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
|
||||
authName,
|
||||
oAuthClientId: client.id,
|
||||
userId: user.id
|
||||
}
|
||||
|
||||
const tokenCreated = await OAuthTokenModel.create(tokenToCreate)
|
||||
|
||||
user.lastLoginDate = new Date()
|
||||
await user.save()
|
||||
|
||||
return {
|
||||
accessToken: tokenCreated.accessToken,
|
||||
accessTokenExpiresAt: tokenCreated.accessTokenExpiresAt,
|
||||
refreshToken: tokenCreated.refreshToken,
|
||||
refreshTokenExpiresAt: tokenCreated.refreshTokenExpiresAt,
|
||||
client,
|
||||
user,
|
||||
accessTokenExpiresIn: buildExpiresIn(tokenCreated.accessTokenExpiresAt),
|
||||
refreshTokenExpiresIn: buildExpiresIn(tokenCreated.refreshTokenExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getAccessToken,
|
||||
getClient,
|
||||
getRefreshToken,
|
||||
getUser,
|
||||
revokeToken,
|
||||
saveToken
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
|
||||
const username = await findAvailableLocalActorName(userOptions.username)
|
||||
|
||||
const userToCreate = buildUser({
|
||||
...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
|
||||
|
||||
username,
|
||||
emailVerified: null,
|
||||
password: null,
|
||||
pluginAuth
|
||||
})
|
||||
|
||||
const { user } = await createUserAccountAndChannelAndPlaylist({
|
||||
userToCreate,
|
||||
userDisplayName: userOptions.displayName
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async function updateUserFromExternal (
|
||||
user: MUserDefault,
|
||||
userOptions: ExternalUser,
|
||||
userUpdater: RegisterServerAuthenticatedResult['userUpdater']
|
||||
) {
|
||||
if (!userUpdater) return user
|
||||
|
||||
{
|
||||
type UserAttributeKeys = keyof AttributesOnly<UserModel>
|
||||
const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
|
||||
role: 'role',
|
||||
adminFlags: 'adminFlags',
|
||||
videoQuota: 'videoQuota',
|
||||
videoQuotaDaily: 'videoQuotaDaily'
|
||||
}
|
||||
|
||||
for (const modelKey of Object.keys(mappingKeys)) {
|
||||
const pluginOptionKey = mappingKeys[modelKey]
|
||||
|
||||
const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
|
||||
user.set(modelKey, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
|
||||
const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
|
||||
name: 'displayName'
|
||||
}
|
||||
|
||||
for (const modelKey of Object.keys(mappingKeys)) {
|
||||
const optionKey = mappingKeys[modelKey]
|
||||
|
||||
const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
|
||||
user.Account.set(modelKey, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
|
||||
|
||||
user.Account = await user.Account.save()
|
||||
|
||||
return user.save()
|
||||
}
|
||||
|
||||
function checkUserValidityOrThrow (user: MUser) {
|
||||
if (user.blocked) throw new AccessDeniedError('User is blocked.')
|
||||
}
|
||||
|
||||
function buildExpiresIn (expiresAt: Date) {
|
||||
return Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import express from 'express'
|
||||
import OAuth2Server, {
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
Request,
|
||||
Response,
|
||||
UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError
|
||||
} from '@node-oauth/oauth2-server'
|
||||
import { randomBytesPromise } from '@server/helpers/core-utils.js'
|
||||
import { isOTPValid } from '@server/helpers/otp.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
|
||||
import { MOAuthClient } from '@server/types/models/index.js'
|
||||
import { sha1 } from '@peertube/peertube-node-utils'
|
||||
import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@peertube/peertube-models'
|
||||
import { OTP } from '../../initializers/constants.js'
|
||||
import { BypassLogin, getAccessToken, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model.js'
|
||||
|
||||
class MissingTwoFactorError extends Error {
|
||||
code = HttpStatusCode.UNAUTHORIZED_401
|
||||
name = ServerErrorCode.MISSING_TWO_FACTOR
|
||||
}
|
||||
|
||||
class InvalidTwoFactorError extends Error {
|
||||
code = HttpStatusCode.BAD_REQUEST_400
|
||||
name = ServerErrorCode.INVALID_TWO_FACTOR
|
||||
}
|
||||
|
||||
class RegistrationWaitingForApproval extends Error {
|
||||
code = HttpStatusCode.BAD_REQUEST_400
|
||||
name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
|
||||
}
|
||||
|
||||
class RegistrationApprovalRejected extends Error {
|
||||
code = HttpStatusCode.BAD_REQUEST_400
|
||||
name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Reimplement some functions of OAuth2Server to inject external auth methods
|
||||
*
|
||||
*/
|
||||
const oAuthServer = new OAuth2Server({
|
||||
// Wants seconds
|
||||
accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
|
||||
refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
|
||||
|
||||
// See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
|
||||
model: {
|
||||
getAccessToken,
|
||||
getClient,
|
||||
getRefreshToken,
|
||||
getUser,
|
||||
revokeToken,
|
||||
saveToken
|
||||
} as any // FIXME: typings
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleOAuthToken (req: express.Request, options: { refreshTokenAuthName?: string, bypassLogin?: BypassLogin }) {
|
||||
const request = new Request(req)
|
||||
const { refreshTokenAuthName, bypassLogin } = options
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
throw new InvalidRequestError('Invalid request: method must be POST')
|
||||
}
|
||||
|
||||
if (!request.is([ 'application/x-www-form-urlencoded' ])) {
|
||||
throw new InvalidRequestError('Invalid request: content must be application/x-www-form-urlencoded')
|
||||
}
|
||||
|
||||
const clientId = request.body.client_id
|
||||
const clientSecret = request.body.client_secret
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new InvalidClientError('Invalid client: cannot retrieve client credentials')
|
||||
}
|
||||
|
||||
const client = await getClient(clientId, clientSecret)
|
||||
if (!client) {
|
||||
throw new InvalidClientError('Invalid client: client is invalid')
|
||||
}
|
||||
|
||||
const grantType = request.body.grant_type
|
||||
if (!grantType) {
|
||||
throw new InvalidRequestError('Missing parameter: `grant_type`')
|
||||
}
|
||||
|
||||
if (![ 'password', 'refresh_token' ].includes(grantType)) {
|
||||
throw new UnsupportedGrantTypeError('Unsupported grant type: `grant_type` is invalid')
|
||||
}
|
||||
|
||||
if (!client.grants.includes(grantType)) {
|
||||
throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
|
||||
}
|
||||
|
||||
if (grantType === 'password') {
|
||||
return handlePasswordGrant({
|
||||
request,
|
||||
client,
|
||||
bypassLogin
|
||||
})
|
||||
}
|
||||
|
||||
return handleRefreshGrant({
|
||||
request,
|
||||
client,
|
||||
refreshTokenAuthName
|
||||
})
|
||||
}
|
||||
|
||||
function handleOAuthAuthenticate (
|
||||
req: express.Request,
|
||||
res: express.Response
|
||||
) {
|
||||
return oAuthServer.authenticate(new Request(req), new Response(res))
|
||||
}
|
||||
|
||||
export {
|
||||
MissingTwoFactorError,
|
||||
InvalidTwoFactorError,
|
||||
|
||||
handleOAuthToken,
|
||||
handleOAuthAuthenticate
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handlePasswordGrant (options: {
|
||||
request: Request
|
||||
client: MOAuthClient
|
||||
bypassLogin?: BypassLogin
|
||||
}) {
|
||||
const { request, client, bypassLogin } = options
|
||||
|
||||
if (!request.body.username) {
|
||||
throw new InvalidRequestError('Missing parameter: `username`')
|
||||
}
|
||||
|
||||
if (!bypassLogin && !request.body.password) {
|
||||
throw new InvalidRequestError('Missing parameter: `password`')
|
||||
}
|
||||
|
||||
const user = await getUser(request.body.username, request.body.password, bypassLogin)
|
||||
if (!user) {
|
||||
const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
|
||||
|
||||
if (registration?.state === UserRegistrationState.REJECTED) {
|
||||
throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
|
||||
} else if (registration?.state === UserRegistrationState.PENDING) {
|
||||
throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
|
||||
}
|
||||
|
||||
throw new InvalidGrantError('Invalid grant: user credentials are invalid')
|
||||
}
|
||||
|
||||
if (user.otpSecret) {
|
||||
if (!request.headers[OTP.HEADER_NAME]) {
|
||||
throw new MissingTwoFactorError('Missing two factor header')
|
||||
}
|
||||
|
||||
if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
|
||||
throw new InvalidTwoFactorError('Invalid two factor header')
|
||||
}
|
||||
}
|
||||
|
||||
const token = await buildToken()
|
||||
|
||||
return saveToken(token, client, user, { bypassLogin })
|
||||
}
|
||||
|
||||
async function handleRefreshGrant (options: {
|
||||
request: Request
|
||||
client: MOAuthClient
|
||||
refreshTokenAuthName: string
|
||||
}) {
|
||||
const { request, client, refreshTokenAuthName } = options
|
||||
|
||||
if (!request.body.refresh_token) {
|
||||
throw new InvalidRequestError('Missing parameter: `refresh_token`')
|
||||
}
|
||||
|
||||
const refreshToken = await getRefreshToken(request.body.refresh_token)
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new InvalidGrantError('Invalid grant: refresh token is invalid')
|
||||
}
|
||||
|
||||
if (refreshToken.client.id !== client.id) {
|
||||
throw new InvalidGrantError('Invalid grant: refresh token is invalid')
|
||||
}
|
||||
|
||||
if (refreshToken.refreshTokenExpiresAt && refreshToken.refreshTokenExpiresAt < new Date()) {
|
||||
throw new InvalidGrantError('Invalid grant: refresh token has expired')
|
||||
}
|
||||
|
||||
await revokeToken({ refreshToken: refreshToken.refreshToken })
|
||||
|
||||
const token = await buildToken()
|
||||
|
||||
return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })
|
||||
}
|
||||
|
||||
function generateRandomToken () {
|
||||
return randomBytesPromise(256)
|
||||
.then(buffer => sha1(buffer))
|
||||
}
|
||||
|
||||
function getTokenExpiresAt (type: 'access' | 'refresh') {
|
||||
const lifetime = type === 'access'
|
||||
? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
|
||||
: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
|
||||
|
||||
return new Date(Date.now() + lifetime)
|
||||
}
|
||||
|
||||
async function buildToken () {
|
||||
const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: getTokenExpiresAt('access'),
|
||||
refreshTokenExpiresAt: getTokenExpiresAt('refresh')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { MOAuthTokenUser } from '@server/types/models/index.js'
|
||||
import { LRU_CACHE } from '../../initializers/constants.js'
|
||||
|
||||
export class TokensCache {
|
||||
|
||||
private static instance: TokensCache
|
||||
|
||||
private readonly accessTokenCache = new LRUCache<string, MOAuthTokenUser>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
|
||||
private readonly userHavingToken = new LRUCache<number, string>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
|
||||
|
||||
private constructor () { }
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
||||
hasToken (token: string) {
|
||||
return this.accessTokenCache.has(token)
|
||||
}
|
||||
|
||||
getByToken (token: string) {
|
||||
return this.accessTokenCache.get(token)
|
||||
}
|
||||
|
||||
setToken (token: MOAuthTokenUser) {
|
||||
this.accessTokenCache.set(token.accessToken, token)
|
||||
this.userHavingToken.set(token.userId, token.accessToken)
|
||||
}
|
||||
|
||||
deleteUserToken (userId: number) {
|
||||
this.clearCacheByUserId(userId)
|
||||
}
|
||||
|
||||
clearCacheByUserId (userId: number) {
|
||||
const token = this.userHavingToken.get(userId)
|
||||
|
||||
if (token !== undefined) {
|
||||
this.accessTokenCache.delete(token)
|
||||
this.userHavingToken.delete(userId)
|
||||
}
|
||||
}
|
||||
|
||||
clearCacheByToken (token: string) {
|
||||
const tokenModel = this.accessTokenCache.get(token)
|
||||
|
||||
if (tokenModel !== undefined) {
|
||||
this.userHavingToken.delete(tokenModel.userId)
|
||||
this.accessTokenCache.delete(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { AutomaticTagAvailable, AutomaticTagPolicy, CommentAutomaticTagPolicies } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { WEBSERVER } from '@server/initializers/constants.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
|
||||
import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
|
||||
import { MAccount, MAccountId, MVideo } from '@server/types/models/index.js'
|
||||
import Linkifyit from 'linkify-it'
|
||||
import { Transaction } from 'sequelize'
|
||||
|
||||
const lTags = loggerTagsFactory('automatic-tags')
|
||||
|
||||
const linkifyit = new Linkifyit()
|
||||
|
||||
export class AutomaticTagger {
|
||||
|
||||
private static readonly SPECIAL_TAGS = {
|
||||
EXTERNAL_LINK: 'external-link'
|
||||
}
|
||||
|
||||
async buildCommentsAutomaticTags (options: {
|
||||
ownerAccount: MAccount
|
||||
text: string
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { text, ownerAccount, transaction } = options
|
||||
|
||||
const serverAccount = (await getServerActor()).Account
|
||||
|
||||
try {
|
||||
const [ accountTags, serverTags ] = await Promise.all([
|
||||
this.buildAutomaticTags({ account: ownerAccount, text, transaction }),
|
||||
this.buildAutomaticTags({ account: serverAccount, text, transaction })
|
||||
])
|
||||
|
||||
logger.debug('Built automatic tags for comment', { text, accountTags, serverTags, ...lTags() })
|
||||
|
||||
return [ ...accountTags, ...serverTags ]
|
||||
} catch (err) {
|
||||
logger.error('Cannot build comment automatic tags', { text, err, ...lTags() })
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async buildVideoAutomaticTags (options: {
|
||||
video: MVideo
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { video, transaction } = options
|
||||
|
||||
const serverAccount = (await getServerActor()).Account
|
||||
|
||||
try {
|
||||
const [ videoNameTags, videoDescriptionTags ] = await Promise.all([
|
||||
this.buildAutomaticTags({ account: serverAccount, text: video.name, transaction }),
|
||||
this.buildAutomaticTags({ account: serverAccount, text: video.description, transaction })
|
||||
])
|
||||
|
||||
logger.debug('Built automatic tags for video', { video, videoNameTags, videoDescriptionTags, ...lTags() })
|
||||
|
||||
return [ ...videoNameTags, ...videoDescriptionTags ]
|
||||
} catch (err) {
|
||||
logger.error('Cannot build video automatic tags', { video, err, ...lTags() })
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAutomaticTags (options: {
|
||||
account: MAccount
|
||||
text: string
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { text, account, transaction } = options
|
||||
|
||||
const tagsDone = new Set<string>()
|
||||
const automaticTags: { name: string, accountId: number }[] = []
|
||||
|
||||
// Watched words by account that published the video
|
||||
const watchedWords = await WatchedWordsListModel.buildWatchedWordsRegexp({ accountId: account.id, transaction })
|
||||
|
||||
logger.debug(`Got watched words regex for account ${account.getDisplayName()}`, { watchedWords, ...lTags() })
|
||||
|
||||
for (const { listName, regex } of watchedWords) {
|
||||
try {
|
||||
if (regex.test(text)) {
|
||||
tagsDone.add(listName)
|
||||
automaticTags.push({ name: listName, accountId: account.id })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Cannot test regex against text', { regex, err, ...lTags() })
|
||||
}
|
||||
}
|
||||
|
||||
// Core PeerTube tags
|
||||
if (!tagsDone.has(AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK) && this.hasExternalLinks(text)) {
|
||||
// This is a global tag, not assigned to a specific account
|
||||
automaticTags.push({ name: AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK, accountId: account.id })
|
||||
tagsDone.add(AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK)
|
||||
}
|
||||
|
||||
logger.debug('Built automatic tags for text', { text, automaticTags, ...lTags() })
|
||||
|
||||
return automaticTags
|
||||
}
|
||||
|
||||
private hasExternalLinks (text: string) {
|
||||
if (!text) return false
|
||||
|
||||
const matches = linkifyit.match(text)
|
||||
if (!matches) return false
|
||||
|
||||
logger.debug('Found external links in text', { matches, text, ...lTags() })
|
||||
|
||||
return matches.some(({ url }) => new URL(url).host !== WEBSERVER.HOST)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static async getAutomaticTagPolicies (account: MAccountId) {
|
||||
const policies = await AccountAutomaticTagPolicyModel.listOfAccount(account)
|
||||
|
||||
const result: CommentAutomaticTagPolicies = {
|
||||
review: policies.filter(p => p.policy === AutomaticTagPolicy.REVIEW_COMMENT).map(p => p.name)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static async getAutomaticTagAvailable (account: MAccountId) {
|
||||
const result: AutomaticTagAvailable = {
|
||||
available: [
|
||||
...(await WatchedWordsListModel.listNamesOf(account)).map(t => ({ name: t, type: 'watched-words-list' as 'watched-words-list' })),
|
||||
|
||||
...Object.values(AutomaticTagger.SPECIAL_TAGS).map(t => ({ name: t, type: 'core' as 'core' }))
|
||||
]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { AutomaticTagPolicyType } from '@peertube/peertube-models'
|
||||
import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
|
||||
import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
|
||||
import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
|
||||
import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js'
|
||||
import {
|
||||
MAccountId,
|
||||
MComment,
|
||||
MCommentAdminOrUserFormattable,
|
||||
MCommentAutomaticTagWithTag,
|
||||
MVideo,
|
||||
MVideoAutomaticTagWithTag
|
||||
} from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
|
||||
export async function setAndSaveCommentAutomaticTags (options: {
|
||||
comment: MComment
|
||||
automaticTags: { accountId: number, name: string }[]
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { comment, automaticTags, transaction } = options
|
||||
|
||||
if (automaticTags.length === 0) return
|
||||
|
||||
const commentAutomaticTags: MCommentAutomaticTagWithTag[] = []
|
||||
|
||||
const accountIds = new Set(automaticTags.map(t => t.accountId))
|
||||
for (const accountId of accountIds) {
|
||||
await CommentAutomaticTagModel.deleteAllOfAccountAndComment({ accountId, commentId: comment.id, transaction })
|
||||
}
|
||||
|
||||
for (const tag of automaticTags) {
|
||||
const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag: tag.name, transaction })
|
||||
|
||||
const [ commentAutomaticTag ] = await CommentAutomaticTagModel.upsert({
|
||||
accountId: tag.accountId,
|
||||
automaticTagId: automaticTagInstance.id,
|
||||
commentId: comment.id
|
||||
}, { transaction })
|
||||
|
||||
commentAutomaticTag.AutomaticTag = automaticTagInstance
|
||||
|
||||
commentAutomaticTags.push(commentAutomaticTag)
|
||||
}
|
||||
|
||||
(comment as MCommentAdminOrUserFormattable).CommentAutomaticTags = commentAutomaticTags
|
||||
}
|
||||
|
||||
export async function setAndSaveVideoAutomaticTags (options: {
|
||||
video: MVideo
|
||||
automaticTags: { accountId: number, name: string }[]
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { video, automaticTags, transaction } = options
|
||||
|
||||
if (automaticTags.length === 0) return
|
||||
|
||||
const accountIds = new Set(automaticTags.map(t => t.accountId))
|
||||
for (const accountId of accountIds) {
|
||||
await VideoAutomaticTagModel.deleteAllOfAccountAndVideo({ accountId, videoId: video.id, transaction })
|
||||
}
|
||||
|
||||
const videoAutomaticTags: MVideoAutomaticTagWithTag[] = []
|
||||
|
||||
for (const tag of automaticTags) {
|
||||
const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag: tag.name, transaction })
|
||||
|
||||
const [ videoAutomaticTag ] = await VideoAutomaticTagModel.upsert({
|
||||
accountId: tag.accountId,
|
||||
automaticTagId: automaticTagInstance.id,
|
||||
videoId: video.id
|
||||
}, { transaction })
|
||||
|
||||
videoAutomaticTag.AutomaticTag = automaticTagInstance
|
||||
|
||||
videoAutomaticTags.push(videoAutomaticTag)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAccountAutomaticTagsPolicy (options: {
|
||||
account: MAccountId
|
||||
tags: string[]
|
||||
policy: AutomaticTagPolicyType
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { account, policy, tags, transaction } = options
|
||||
|
||||
await AccountAutomaticTagPolicyModel.deleteOfAccount({ account, policy, transaction })
|
||||
|
||||
for (const tag of tags) {
|
||||
const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag, transaction })
|
||||
|
||||
await AccountAutomaticTagPolicyModel.create({
|
||||
policy,
|
||||
accountId: account.id,
|
||||
automaticTagId: automaticTagInstance.id
|
||||
}, { transaction })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models/index.js'
|
||||
import { AccountBlocklistModel } from '../models/account/account-blocklist.js'
|
||||
import { ServerBlocklistModel } from '../models/server/server-blocklist.js'
|
||||
import { UserNotificationModel } from '@server/models/user/user-notification.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
|
||||
async function addAccountInBlocklist (options: {
|
||||
byAccountId: number
|
||||
targetAccountId: number
|
||||
|
||||
removeNotificationOfUserId: number | null // If blocked by a user
|
||||
}) {
|
||||
const { byAccountId, targetAccountId, removeNotificationOfUserId } = options
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
return AccountBlocklistModel.upsert({
|
||||
accountId: byAccountId,
|
||||
targetAccountId
|
||||
}, { transaction: t })
|
||||
})
|
||||
|
||||
UserNotificationModel.removeNotificationsOf({
|
||||
id: targetAccountId,
|
||||
type: 'account',
|
||||
forUserId: removeNotificationOfUserId
|
||||
}).catch(err => logger.error('Cannot remove notifications after an account mute.', { err }))
|
||||
}
|
||||
|
||||
async function addServerInBlocklist (options: {
|
||||
byAccountId: number
|
||||
targetServerId: number
|
||||
|
||||
removeNotificationOfUserId: number | null
|
||||
}) {
|
||||
const { byAccountId, targetServerId, removeNotificationOfUserId } = options
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
return ServerBlocklistModel.upsert({
|
||||
accountId: byAccountId,
|
||||
targetServerId
|
||||
}, { transaction: t })
|
||||
})
|
||||
|
||||
UserNotificationModel.removeNotificationsOf({
|
||||
id: targetServerId,
|
||||
type: 'server',
|
||||
forUserId: removeNotificationOfUserId
|
||||
}).catch(err => logger.error('Cannot remove notifications after a server mute.', { err }))
|
||||
}
|
||||
|
||||
function removeAccountFromBlocklist (accountBlock: MAccountBlocklist) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
return accountBlock.destroy({ transaction: t })
|
||||
})
|
||||
}
|
||||
|
||||
function removeServerFromBlocklist (serverBlock: MServerBlocklist) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
return serverBlock.destroy({ transaction: t })
|
||||
})
|
||||
}
|
||||
|
||||
async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) {
|
||||
const serverAccountId = (await getServerActor()).Account.id
|
||||
const sourceAccounts = [ serverAccountId ]
|
||||
|
||||
if (userAccount) sourceAccounts.push(userAccount.id)
|
||||
|
||||
const accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, targetAccount.id)
|
||||
if (accountMutedHash[serverAccountId] || (userAccount && accountMutedHash[userAccount.id])) {
|
||||
return true
|
||||
}
|
||||
|
||||
const instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, targetAccount.Actor.serverId)
|
||||
if (instanceMutedHash[serverAccountId] || (userAccount && instanceMutedHash[userAccount.id])) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export {
|
||||
addAccountInBlocklist,
|
||||
addServerInBlocklist,
|
||||
removeAccountFromBlocklist,
|
||||
removeServerFromBlocklist,
|
||||
isBlockedByServerOrAccount
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import { EmailPayload, SendEmailDefaultOptions, UserExportState, UserRegistrationState } from '@peertube/peertube-models'
|
||||
import { isTestOrDevInstance, root } from '@peertube/peertube-node-utils'
|
||||
import { readFileSync } from 'fs'
|
||||
import merge from 'lodash-es/merge.js'
|
||||
import { Transporter, createTransport } from 'nodemailer'
|
||||
import { join } from 'path'
|
||||
import { bunyanLogger, logger } from '../helpers/logger.js'
|
||||
import { CONFIG, isEmailEnabled } from '../initializers/config.js'
|
||||
import { WEBSERVER } from '../initializers/constants.js'
|
||||
import { MRegistration, MUser, MUserExport, MUserImport } from '../types/models/index.js'
|
||||
import { JobQueue } from './job-queue/index.js'
|
||||
import { UserModel } from '@server/models/user/user.js'
|
||||
|
||||
class Emailer {
|
||||
|
||||
private static instance: Emailer
|
||||
private initialized = false
|
||||
private transporter: Transporter
|
||||
|
||||
private constructor () {
|
||||
}
|
||||
|
||||
init () {
|
||||
// Already initialized
|
||||
if (this.initialized === true) return
|
||||
this.initialized = true
|
||||
|
||||
if (!isEmailEnabled()) {
|
||||
if (!isTestOrDevInstance()) {
|
||||
logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (CONFIG.SMTP.TRANSPORT === 'smtp') this.initSMTPTransport()
|
||||
else if (CONFIG.SMTP.TRANSPORT === 'sendmail') this.initSendmailTransport()
|
||||
}
|
||||
|
||||
async checkConnection () {
|
||||
if (!this.transporter || CONFIG.SMTP.TRANSPORT !== 'smtp') return
|
||||
|
||||
logger.info('Testing SMTP server...')
|
||||
|
||||
try {
|
||||
const success = await this.transporter.verify()
|
||||
if (success !== true) this.warnOnConnectionFailure()
|
||||
|
||||
logger.info('Successfully connected to SMTP server.')
|
||||
} catch (err) {
|
||||
this.warnOnConnectionFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
addPasswordResetEmailJob (username: string, to: string, resetPasswordUrl: string) {
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'password-reset',
|
||||
to: [ to ],
|
||||
subject: 'Reset your account password',
|
||||
locals: {
|
||||
username,
|
||||
resetPasswordUrl,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addPasswordCreateEmailJob (username: string, to: string, createPasswordUrl: string) {
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'password-create',
|
||||
to: [ to ],
|
||||
subject: 'Create your account password',
|
||||
locals: {
|
||||
username,
|
||||
createPasswordUrl,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addVerifyEmailJob (options: {
|
||||
username: string
|
||||
isRegistrationRequest: boolean
|
||||
to: string
|
||||
verifyEmailUrl: string
|
||||
}) {
|
||||
const { username, isRegistrationRequest, to, verifyEmailUrl } = options
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'verify-email',
|
||||
to: [ to ],
|
||||
subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
|
||||
locals: {
|
||||
username,
|
||||
verifyEmailUrl,
|
||||
isRegistrationRequest,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addUserBlockJob (user: MUser, blocked: boolean, reason?: string) {
|
||||
const reasonString = reason ? ` for the following reason: ${reason}` : ''
|
||||
const blockedWord = blocked ? 'blocked' : 'unblocked'
|
||||
|
||||
const to = user.email
|
||||
const emailPayload: EmailPayload = {
|
||||
to: [ to ],
|
||||
subject: 'Account ' + blockedWord,
|
||||
text: `Your account ${user.username} on ${CONFIG.INSTANCE.NAME} has been ${blockedWord}${reasonString}.`
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addContactFormJob (fromEmail: string, fromName: string, subject: string, body: string) {
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'contact-form',
|
||||
to: [ CONFIG.ADMIN.EMAIL ],
|
||||
replyTo: `"${fromName}" <${fromEmail}>`,
|
||||
subject: `(contact form) ${subject}`,
|
||||
locals: {
|
||||
fromName,
|
||||
fromEmail,
|
||||
body,
|
||||
|
||||
// There are not notification preferences for the contact form
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addUserRegistrationRequestProcessedJob (registration: MRegistration) {
|
||||
let template: string
|
||||
let subject: string
|
||||
if (registration.state === UserRegistrationState.ACCEPTED) {
|
||||
template = 'user-registration-request-accepted'
|
||||
subject = `Your registration request for ${registration.username} has been accepted`
|
||||
} else {
|
||||
template = 'user-registration-request-rejected'
|
||||
subject = `Your registration request for ${registration.username} has been rejected`
|
||||
}
|
||||
|
||||
const to = registration.email
|
||||
const emailPayload: EmailPayload = {
|
||||
to: [ to ],
|
||||
template,
|
||||
subject,
|
||||
locals: {
|
||||
username: registration.username,
|
||||
moderationResponse: registration.moderationResponse,
|
||||
loginLink: WEBSERVER.URL + '/login',
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async addUserExportCompletedOrErroredJob (userExport: MUserExport) {
|
||||
let template: string
|
||||
let subject: string
|
||||
|
||||
if (userExport.state === UserExportState.COMPLETED) {
|
||||
template = 'user-export-completed'
|
||||
subject = `Your export archive has been created`
|
||||
} else {
|
||||
template = 'user-export-errored'
|
||||
subject = `Failed to create your export archive`
|
||||
}
|
||||
|
||||
const user = await UserModel.loadById(userExport.userId)
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to: [ user.email ],
|
||||
template,
|
||||
subject,
|
||||
locals: {
|
||||
exportsUrl: WEBSERVER.URL + '/my-account/import-export',
|
||||
errorMessage: userExport.error,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
async addUserImportErroredJob (userImport: MUserImport) {
|
||||
const user = await UserModel.loadById(userImport.userId)
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to: [ user.email ],
|
||||
template: 'user-import-errored',
|
||||
subject: 'Failed to import your archive',
|
||||
locals: {
|
||||
errorMessage: userImport.error,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
async addUserImportSuccessJob (userImport: MUserImport) {
|
||||
const user = await UserModel.loadById(userImport.userId)
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to: [ user.email ],
|
||||
template: 'user-import-completed',
|
||||
subject: 'Your archive import has finished',
|
||||
locals: {
|
||||
resultStats: userImport.resultSummary.stats,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendMail (options: EmailPayload) {
|
||||
if (!isEmailEnabled()) {
|
||||
logger.info('Cannot send mail because SMTP is not configured.')
|
||||
return
|
||||
}
|
||||
|
||||
const fromDisplayName = options.from
|
||||
? options.from
|
||||
: CONFIG.INSTANCE.NAME
|
||||
|
||||
const EmailTemplates = (await import('email-templates')).default
|
||||
|
||||
const email = new EmailTemplates({
|
||||
send: true,
|
||||
htmlToText: {
|
||||
selectors: [
|
||||
{ selector: 'img', format: 'skip' },
|
||||
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true } }
|
||||
]
|
||||
},
|
||||
message: {
|
||||
from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
|
||||
},
|
||||
transport: this.transporter,
|
||||
views: {
|
||||
root: join(root(), 'dist', 'core', 'assets', 'email-templates')
|
||||
},
|
||||
subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX
|
||||
})
|
||||
|
||||
const toEmails = arrayify(options.to)
|
||||
|
||||
for (const to of toEmails) {
|
||||
const baseOptions: SendEmailDefaultOptions = {
|
||||
template: 'common',
|
||||
message: {
|
||||
to,
|
||||
from: options.from,
|
||||
subject: options.subject,
|
||||
replyTo: options.replyTo
|
||||
},
|
||||
locals: { // default variables available in all templates
|
||||
WEBSERVER,
|
||||
EMAIL: CONFIG.EMAIL,
|
||||
instanceName: CONFIG.INSTANCE.NAME,
|
||||
text: options.text,
|
||||
subject: options.subject
|
||||
}
|
||||
}
|
||||
|
||||
// overridden/new variables given for a specific template in the payload
|
||||
const sendOptions = merge(baseOptions, options)
|
||||
|
||||
await email.send(sendOptions)
|
||||
.then(res => logger.debug('Sent email.', { res }))
|
||||
.catch(err => logger.error('Error in email sender.', { err }))
|
||||
}
|
||||
}
|
||||
|
||||
private warnOnConnectionFailure (err?: Error) {
|
||||
logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, { err })
|
||||
}
|
||||
|
||||
private initSMTPTransport () {
|
||||
logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
|
||||
|
||||
let tls: { ca: [ Buffer ] }
|
||||
if (CONFIG.SMTP.CA_FILE) {
|
||||
tls = {
|
||||
ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ]
|
||||
}
|
||||
}
|
||||
|
||||
let auth: { user: string, pass: string }
|
||||
if (CONFIG.SMTP.USERNAME && CONFIG.SMTP.PASSWORD) {
|
||||
auth = {
|
||||
user: CONFIG.SMTP.USERNAME,
|
||||
pass: CONFIG.SMTP.PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
this.transporter = createTransport({
|
||||
host: CONFIG.SMTP.HOSTNAME,
|
||||
port: CONFIG.SMTP.PORT,
|
||||
secure: CONFIG.SMTP.TLS,
|
||||
debug: CONFIG.LOG.LEVEL === 'debug',
|
||||
logger: bunyanLogger as any,
|
||||
ignoreTLS: CONFIG.SMTP.DISABLE_STARTTLS,
|
||||
tls,
|
||||
auth
|
||||
})
|
||||
}
|
||||
|
||||
private initSendmailTransport () {
|
||||
logger.info('Using sendmail to send emails')
|
||||
|
||||
this.transporter = createTransport({
|
||||
sendmail: true,
|
||||
newline: 'unix',
|
||||
path: CONFIG.SMTP.SENDMAIL,
|
||||
logger: bunyanLogger
|
||||
})
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
Emailer
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants.js'
|
||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||
import { MActorImage } from '@server/types/models/index.js'
|
||||
import { AbstractPermanentFileCache } from './shared/index.js'
|
||||
|
||||
export class AvatarPermanentFileCache extends AbstractPermanentFileCache<MActorImage> {
|
||||
|
||||
constructor () {
|
||||
super(CONFIG.STORAGE.ACTOR_IMAGES_DIR)
|
||||
}
|
||||
|
||||
protected loadModel (filename: string) {
|
||||
return ActorImageModel.loadByFilename(filename)
|
||||
}
|
||||
|
||||
protected getImageSize (image: MActorImage): { width: number, height: number } {
|
||||
if (image.width && image.height) {
|
||||
return {
|
||||
height: image.height,
|
||||
width: image.width
|
||||
}
|
||||
}
|
||||
|
||||
return ACTOR_IMAGES_SIZE[image.type][0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './avatar-permanent-file-cache.js'
|
||||
export * from './video-miniature-permanent-file-cache.js'
|
||||
export * from './video-captions-simple-file-cache.js'
|
||||
export * from './video-previews-simple-file-cache.js'
|
||||
export * from './video-storyboards-simple-file-cache.js'
|
||||
export * from './video-torrents-simple-file-cache.js'
|
||||
@@ -0,0 +1,132 @@
|
||||
import express from 'express'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { Model } from 'sequelize'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { CachePromise } from '@server/helpers/promise-cache.js'
|
||||
import { LRU_CACHE, STATIC_MAX_AGE } from '@server/initializers/constants.js'
|
||||
import { downloadImageFromWorker } from '@server/lib/worker/parent-process.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
|
||||
type ImageModel = {
|
||||
fileUrl: string
|
||||
filename: string
|
||||
onDisk: boolean
|
||||
|
||||
isOwned (): boolean
|
||||
getPath (): string
|
||||
|
||||
save (): Promise<Model>
|
||||
}
|
||||
|
||||
export abstract class AbstractPermanentFileCache <M extends ImageModel> {
|
||||
// Unsafe because it can return paths that do not exist anymore
|
||||
private readonly filenameToPathUnsafeCache = new LRUCache<string, string>({
|
||||
max: LRU_CACHE.FILENAME_TO_PATH_PERMANENT_FILE_CACHE.MAX_SIZE
|
||||
})
|
||||
|
||||
protected abstract getImageSize (image: M): { width: number, height: number }
|
||||
protected abstract loadModel (filename: string): Promise<M>
|
||||
|
||||
constructor (private readonly directory: string) {
|
||||
|
||||
}
|
||||
|
||||
async lazyServe (options: {
|
||||
filename: string
|
||||
res: express.Response
|
||||
next: express.NextFunction
|
||||
}) {
|
||||
const { filename, res, next } = options
|
||||
|
||||
if (this.filenameToPathUnsafeCache.has(filename)) {
|
||||
return res.sendFile(this.filenameToPathUnsafeCache.get(filename), { maxAge: STATIC_MAX_AGE.SERVER })
|
||||
}
|
||||
|
||||
const image = await this.lazyLoadIfNeeded(filename)
|
||||
if (!image) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
||||
|
||||
const path = image.getPath()
|
||||
this.filenameToPathUnsafeCache.set(filename, path)
|
||||
|
||||
return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
|
||||
if (!err) return
|
||||
|
||||
this.onServeError({ err, image, next, filename })
|
||||
})
|
||||
}
|
||||
|
||||
@CachePromise({
|
||||
keyBuilder: filename => filename
|
||||
})
|
||||
private async lazyLoadIfNeeded (filename: string) {
|
||||
const image = await this.loadModel(filename)
|
||||
if (!image) return undefined
|
||||
|
||||
if (image.onDisk === false) {
|
||||
if (!image.fileUrl) return undefined
|
||||
|
||||
try {
|
||||
await this.downloadRemoteFile(image)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot process remote image %s.', image.fileUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
async downloadRemoteFile (image: M) {
|
||||
logger.info('Download remote image %s lazily.', image.fileUrl)
|
||||
|
||||
const destination = await this.downloadImage({
|
||||
filename: image.filename,
|
||||
fileUrl: image.fileUrl,
|
||||
size: this.getImageSize(image)
|
||||
})
|
||||
|
||||
image.onDisk = true
|
||||
image.save()
|
||||
.catch(err => logger.error('Cannot save new image disk state.', { err }))
|
||||
|
||||
return destination
|
||||
}
|
||||
|
||||
private onServeError (options: {
|
||||
err: any
|
||||
image: M
|
||||
filename: string
|
||||
next: express.NextFunction
|
||||
}) {
|
||||
const { err, image, filename, next } = options
|
||||
|
||||
// It seems this actor image is not on the disk anymore
|
||||
if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) {
|
||||
logger.error('Cannot lazy serve image %s.', filename, { err })
|
||||
|
||||
this.filenameToPathUnsafeCache.delete(filename)
|
||||
|
||||
image.onDisk = false
|
||||
image.save()
|
||||
.catch(err => logger.error('Cannot save new image disk state.', { err }))
|
||||
}
|
||||
|
||||
return next(err)
|
||||
}
|
||||
|
||||
private downloadImage (options: {
|
||||
fileUrl: string
|
||||
filename: string
|
||||
size: { width: number, height: number }
|
||||
}) {
|
||||
const downloaderOptions = {
|
||||
url: options.fileUrl,
|
||||
destDir: this.directory,
|
||||
destName: options.filename,
|
||||
size: options.size
|
||||
}
|
||||
|
||||
return downloadImageFromWorker(downloaderOptions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import memoizee from 'memoizee'
|
||||
|
||||
type GetFilePathResult = { isOwned: boolean, path: string, downloadName?: string } | undefined
|
||||
|
||||
export abstract class AbstractSimpleFileCache <T> {
|
||||
|
||||
getFilePath: (params: T) => Promise<GetFilePathResult>
|
||||
|
||||
abstract getFilePathImpl (params: T): Promise<GetFilePathResult>
|
||||
|
||||
// Load and save the remote file, then return the local path from filesystem
|
||||
protected abstract loadRemoteFile (key: string): Promise<GetFilePathResult>
|
||||
|
||||
init (max: number, maxAge: number) {
|
||||
this.getFilePath = memoizee(this.getFilePathImpl.bind(this), {
|
||||
maxAge,
|
||||
max,
|
||||
promise: true,
|
||||
dispose: (result?: GetFilePathResult) => {
|
||||
if (result && result.isOwned !== true) {
|
||||
remove(result.path)
|
||||
.then(() => logger.debug('%s removed from %s', result.path, this.constructor.name))
|
||||
.catch(err => logger.error('Cannot remove %s from cache %s.', result.path, this.constructor.name, { err }))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './abstract-permanent-file-cache.js'
|
||||
export * from './abstract-simple-file-cache.js'
|
||||
@@ -0,0 +1,60 @@
|
||||
import { join } from 'path'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
|
||||
import { FILES_CACHE } from '../../initializers/constants.js'
|
||||
import { VideoModel } from '../../models/video/video.js'
|
||||
import { VideoCaptionModel } from '../../models/video/video-caption.js'
|
||||
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
|
||||
|
||||
class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
|
||||
|
||||
private static instance: VideoCaptionsSimpleFileCache
|
||||
|
||||
private constructor () {
|
||||
super()
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
||||
async getFilePathImpl (filename: string) {
|
||||
const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename)
|
||||
if (!videoCaption) return undefined
|
||||
|
||||
if (videoCaption.isOwned()) {
|
||||
return { isOwned: true, path: videoCaption.getFSPath() }
|
||||
}
|
||||
|
||||
return this.loadRemoteFile(filename)
|
||||
}
|
||||
|
||||
// Key is the caption filename
|
||||
protected async loadRemoteFile (key: string) {
|
||||
const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(key)
|
||||
if (!videoCaption) return undefined
|
||||
|
||||
if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
|
||||
|
||||
// Used to fetch the path
|
||||
const video = await VideoModel.loadFull(videoCaption.videoId)
|
||||
if (!video) return undefined
|
||||
|
||||
const remoteUrl = videoCaption.getFileUrl(video)
|
||||
const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.filename)
|
||||
|
||||
try {
|
||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||
|
||||
return { isOwned: false, path: destPath }
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote caption file %s.', remoteUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VideoCaptionsSimpleFileCache
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { THUMBNAILS_SIZE } from '@server/initializers/constants.js'
|
||||
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
|
||||
import { MThumbnail } from '@server/types/models/index.js'
|
||||
import { ThumbnailType } from '@peertube/peertube-models'
|
||||
import { AbstractPermanentFileCache } from './shared/index.js'
|
||||
|
||||
export class VideoMiniaturePermanentFileCache extends AbstractPermanentFileCache<MThumbnail> {
|
||||
|
||||
constructor () {
|
||||
super(CONFIG.STORAGE.THUMBNAILS_DIR)
|
||||
}
|
||||
|
||||
protected loadModel (filename: string) {
|
||||
return ThumbnailModel.loadByFilename(filename, ThumbnailType.MINIATURE)
|
||||
}
|
||||
|
||||
protected getImageSize (image: MThumbnail): { width: number, height: number } {
|
||||
if (image.width && image.height) {
|
||||
return {
|
||||
height: image.height,
|
||||
width: image.width
|
||||
}
|
||||
}
|
||||
|
||||
return THUMBNAILS_SIZE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { join } from 'path'
|
||||
import { FILES_CACHE } from '../../initializers/constants.js'
|
||||
import { VideoModel } from '../../models/video/video.js'
|
||||
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
|
||||
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
|
||||
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
|
||||
import { ThumbnailType } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
|
||||
class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache <string> {
|
||||
|
||||
private static instance: VideoPreviewsSimpleFileCache
|
||||
|
||||
private constructor () {
|
||||
super()
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
||||
async getFilePathImpl (filename: string) {
|
||||
const thumbnail = await ThumbnailModel.loadWithVideoByFilename(filename, ThumbnailType.PREVIEW)
|
||||
if (!thumbnail) return undefined
|
||||
|
||||
if (thumbnail.Video.isOwned()) return { isOwned: true, path: thumbnail.getPath() }
|
||||
|
||||
return this.loadRemoteFile(thumbnail.Video.uuid)
|
||||
}
|
||||
|
||||
// Key is the video UUID
|
||||
protected async loadRemoteFile (key: string) {
|
||||
const video = await VideoModel.loadFull(key)
|
||||
if (!video) return undefined
|
||||
|
||||
if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
|
||||
|
||||
const preview = video.getPreview()
|
||||
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
|
||||
const remoteUrl = preview.getOriginFileUrl(video)
|
||||
|
||||
try {
|
||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||
|
||||
logger.debug('Fetched remote preview %s to %s.', remoteUrl, destPath)
|
||||
|
||||
return { isOwned: false, path: destPath }
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote preview file %s.', remoteUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VideoPreviewsSimpleFileCache
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { join } from 'path'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
|
||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||
import { FILES_CACHE } from '../../initializers/constants.js'
|
||||
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
|
||||
|
||||
class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache <string> {
|
||||
|
||||
private static instance: VideoStoryboardsSimpleFileCache
|
||||
|
||||
private constructor () {
|
||||
super()
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
||||
async getFilePathImpl (filename: string) {
|
||||
const storyboard = await StoryboardModel.loadWithVideoByFilename(filename)
|
||||
if (!storyboard) return undefined
|
||||
|
||||
if (storyboard.Video.isOwned()) return { isOwned: true, path: storyboard.getPath() }
|
||||
|
||||
return this.loadRemoteFile(storyboard.filename)
|
||||
}
|
||||
|
||||
// Key is the storyboard filename
|
||||
protected async loadRemoteFile (key: string) {
|
||||
const storyboard = await StoryboardModel.loadWithVideoByFilename(key)
|
||||
if (!storyboard) return undefined
|
||||
|
||||
const destPath = join(FILES_CACHE.STORYBOARDS.DIRECTORY, storyboard.filename)
|
||||
const remoteUrl = storyboard.getOriginFileUrl(storyboard.Video)
|
||||
|
||||
try {
|
||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||
|
||||
logger.debug('Fetched remote storyboard %s to %s.', remoteUrl, destPath)
|
||||
|
||||
return { isOwned: false, path: destPath }
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote storyboard file %s.', remoteUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VideoStoryboardsSimpleFileCache
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { join } from 'path'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { MVideo, MVideoFile } from '@server/types/models/index.js'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { FILES_CACHE } from '../../initializers/constants.js'
|
||||
import { VideoModel } from '../../models/video/video.js'
|
||||
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
|
||||
|
||||
class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache <string> {
|
||||
|
||||
private static instance: VideoTorrentsSimpleFileCache
|
||||
|
||||
private constructor () {
|
||||
super()
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
||||
async getFilePathImpl (filename: string) {
|
||||
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
|
||||
if (!file) return undefined
|
||||
|
||||
if (file.getVideo().isOwned()) {
|
||||
const downloadName = this.buildDownloadName(file.getVideo(), file)
|
||||
|
||||
return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName }
|
||||
}
|
||||
|
||||
return this.loadRemoteFile(filename)
|
||||
}
|
||||
|
||||
// Key is the torrent filename
|
||||
protected async loadRemoteFile (key: string) {
|
||||
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(key)
|
||||
if (!file) return undefined
|
||||
|
||||
if (file.getVideo().isOwned()) throw new Error('Cannot load remote file of owned video.')
|
||||
|
||||
// Used to fetch the path
|
||||
const video = await VideoModel.loadFull(file.getVideo().id)
|
||||
if (!video) return undefined
|
||||
|
||||
const remoteUrl = file.getRemoteTorrentUrl(video)
|
||||
const destPath = join(FILES_CACHE.TORRENTS.DIRECTORY, file.torrentFilename)
|
||||
|
||||
try {
|
||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||
|
||||
const downloadName = this.buildDownloadName(video, file)
|
||||
|
||||
return { isOwned: false, path: destPath, downloadName }
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote torrent file %s.', remoteUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private buildDownloadName (video: MVideo, file: MVideoFile) {
|
||||
return `${video.name}-${file.resolution}p.torrent`
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VideoTorrentsSimpleFileCache
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { uniqify, uuidRegex } from '@peertube/peertube-core-utils'
|
||||
import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||
import { FileStorage } from '@peertube/peertube-models'
|
||||
import { sha256 } from '@peertube/peertube-node-utils'
|
||||
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
|
||||
import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm'
|
||||
import { open, readFile, stat, writeFile } from 'fs/promises'
|
||||
import flatten from 'lodash-es/flatten.js'
|
||||
import PQueue from 'p-queue'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { getAudioStreamCodec, getVideoStreamCodec } from '../helpers/ffmpeg/index.js'
|
||||
import { logger, loggerTagsFactory } from '../helpers/logger.js'
|
||||
import { doRequest, doRequestAndSaveToFile } from '../helpers/requests.js'
|
||||
import { generateRandomString } from '../helpers/utils.js'
|
||||
import { CONFIG } from '../initializers/config.js'
|
||||
import { P2P_MEDIA_LOADER_PEER_VERSION, REQUEST_TIMEOUTS } from '../initializers/constants.js'
|
||||
import { sequelizeTypescript } from '../initializers/database.js'
|
||||
import { VideoFileModel } from '../models/video/video-file.js'
|
||||
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
|
||||
import { storeHLSFileFromFilename } from './object-storage/index.js'
|
||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths.js'
|
||||
import { VideoPathManager } from './video-path-manager.js'
|
||||
|
||||
const lTags = loggerTagsFactory('hls')
|
||||
|
||||
async function updateStreamingPlaylistsInfohashesIfNeeded () {
|
||||
const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
|
||||
|
||||
// Use separate SQL queries, because we could have many videos to update
|
||||
for (const playlist of playlistsToUpdate) {
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlist.id, t)
|
||||
|
||||
playlist.assignP2PMediaLoaderInfoHashes(playlist.Video, videoFiles)
|
||||
playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
|
||||
|
||||
await playlist.save({ transaction: t })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
|
||||
try {
|
||||
let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
|
||||
playlistWithFiles = await updateSha256VODSegments(video, playlist)
|
||||
|
||||
// Refresh playlist, operations can take some time
|
||||
playlistWithFiles = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlist.id)
|
||||
playlistWithFiles.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
|
||||
await playlistWithFiles.save()
|
||||
|
||||
video.setHLSPlaylist(playlistWithFiles)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Avoid concurrency issues when updating streaming playlist files
|
||||
const playlistFilesQueue = new PQueue({ concurrency: 1 })
|
||||
|
||||
function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
|
||||
return playlistFilesQueue.add(async () => {
|
||||
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
|
||||
|
||||
const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
|
||||
|
||||
for (const file of playlist.VideoFiles) {
|
||||
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
||||
const size = await getVideoStreamDimensionsInfo(videoFilePath)
|
||||
|
||||
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
|
||||
const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
|
||||
|
||||
let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
|
||||
if (file.fps) line += ',FRAME-RATE=' + file.fps
|
||||
|
||||
const codecs = await Promise.all([
|
||||
getVideoStreamCodec(videoFilePath),
|
||||
getAudioStreamCodec(videoFilePath)
|
||||
])
|
||||
|
||||
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
|
||||
|
||||
masterPlaylists.push(line)
|
||||
masterPlaylists.push(playlistFilename)
|
||||
})
|
||||
}
|
||||
|
||||
if (playlist.playlistFilename) {
|
||||
await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
|
||||
}
|
||||
playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
|
||||
|
||||
const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
|
||||
await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
|
||||
|
||||
logger.info('Updating %s master playlist file of video %s', masterPlaylistPath, video.uuid, lTags(video.uuid))
|
||||
|
||||
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
||||
playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename)
|
||||
await remove(masterPlaylistPath)
|
||||
}
|
||||
|
||||
return playlist.save()
|
||||
}, { throwOnTimeout: true })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
|
||||
return playlistFilesQueue.add(async () => {
|
||||
const json: { [filename: string]: { [range: string]: string } } = {}
|
||||
|
||||
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
|
||||
|
||||
// For all the resolutions available for this video
|
||||
for (const file of playlist.VideoFiles) {
|
||||
const rangeHashes: { [range: string]: string } = {}
|
||||
const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
|
||||
|
||||
return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
|
||||
const playlistContent = await readFile(resolutionPlaylistPath)
|
||||
const ranges = getRangesFromPlaylist(playlistContent.toString())
|
||||
|
||||
const fd = await open(videoPath, 'r')
|
||||
for (const range of ranges) {
|
||||
const buf = Buffer.alloc(range.length)
|
||||
await fd.read(buf, 0, range.length, range.offset)
|
||||
|
||||
rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
|
||||
}
|
||||
await fd.close()
|
||||
|
||||
const videoFilename = file.filename
|
||||
json[videoFilename] = rangeHashes
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (playlist.segmentsSha256Filename) {
|
||||
await video.removeStreamingPlaylistFile(playlist, playlist.segmentsSha256Filename)
|
||||
}
|
||||
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
|
||||
|
||||
const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
|
||||
await outputJSON(outputPath, json)
|
||||
|
||||
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
||||
playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlist, playlist.segmentsSha256Filename)
|
||||
await remove(outputPath)
|
||||
}
|
||||
|
||||
return playlist.save()
|
||||
}, { throwOnTimeout: true })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function buildSha256Segment (segmentPath: string) {
|
||||
const buf = await readFile(segmentPath)
|
||||
return sha256(buf)
|
||||
}
|
||||
|
||||
function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
|
||||
let timer
|
||||
let remainingBodyKBLimit = bodyKBLimit
|
||||
|
||||
logger.info('Importing HLS playlist %s', playlistUrl)
|
||||
|
||||
return new Promise<void>(async (res, rej) => {
|
||||
const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
|
||||
|
||||
await ensureDir(tmpDirectory)
|
||||
|
||||
timer = setTimeout(() => {
|
||||
deleteTmpDirectory(tmpDirectory)
|
||||
|
||||
return rej(new Error('HLS download timeout.'))
|
||||
}, timeout)
|
||||
|
||||
try {
|
||||
// Fetch master playlist
|
||||
const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
|
||||
|
||||
const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
|
||||
const fileUrls = uniqify(flatten(await Promise.all(subRequests)))
|
||||
|
||||
logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
|
||||
|
||||
for (const fileUrl of fileUrls) {
|
||||
const destPath = join(tmpDirectory, basename(fileUrl))
|
||||
|
||||
await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
|
||||
|
||||
const { size } = await stat(destPath)
|
||||
remainingBodyKBLimit -= (size / 1000)
|
||||
|
||||
logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit))
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
|
||||
await move(tmpDirectory, destinationDir, { overwrite: true })
|
||||
|
||||
return res()
|
||||
} catch (err) {
|
||||
deleteTmpDirectory(tmpDirectory)
|
||||
|
||||
return rej(err)
|
||||
}
|
||||
})
|
||||
|
||||
function deleteTmpDirectory (directory: string) {
|
||||
remove(directory)
|
||||
.catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
|
||||
}
|
||||
|
||||
async function fetchUniqUrls (playlistUrl: string) {
|
||||
const { body } = await doRequest(playlistUrl)
|
||||
|
||||
if (!body) return []
|
||||
|
||||
const urls = body.split('\n')
|
||||
.filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
|
||||
.map(url => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
|
||||
return `${dirname(playlistUrl)}/${url}`
|
||||
})
|
||||
|
||||
return uniqify(urls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) {
|
||||
const content = await readFile(playlistPath, 'utf8')
|
||||
|
||||
const newContent = content.replace(new RegExp(`${uuidRegex}-\\d+-fragmented.mp4`, 'g'), newVideoFilename)
|
||||
|
||||
await writeFile(playlistPath, newContent, 'utf8')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function injectQueryToPlaylistUrls (content: string, queryString: string) {
|
||||
return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
updateMasterHLSPlaylist,
|
||||
updateSha256VODSegments,
|
||||
buildSha256Segment,
|
||||
downloadPlaylistSegments,
|
||||
updateStreamingPlaylistsInfohashesIfNeeded,
|
||||
updatePlaylistAfterFileChange,
|
||||
injectQueryToPlaylistUrls,
|
||||
renameVideoFileInPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getRangesFromPlaylist (playlistContent: string) {
|
||||
const ranges: { offset: number, length: number }[] = []
|
||||
const lines = playlistContent.split('\n')
|
||||
const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
|
||||
|
||||
for (const line of lines) {
|
||||
const captured = regex.exec(line)
|
||||
|
||||
if (captured) {
|
||||
ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import express from 'express'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { ACCEPT_HEADERS } from '../../initializers/constants.js'
|
||||
import { VideoHtml } from './shared/video-html.js'
|
||||
import { PlaylistHtml } from './shared/playlist-html.js'
|
||||
import { ActorHtml } from './shared/actor-html.js'
|
||||
import { PageHtml } from './shared/page-html.js'
|
||||
|
||||
class ClientHtml {
|
||||
|
||||
static invalidateCache () {
|
||||
PageHtml.invalidateCache()
|
||||
}
|
||||
|
||||
static getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
|
||||
return PageHtml.getDefaultHTML(req, res, paramLang)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
|
||||
return VideoHtml.getWatchVideoHTML(videoIdArg, req, res)
|
||||
}
|
||||
|
||||
static getVideoEmbedHTML (videoIdArg: string) {
|
||||
return VideoHtml.getEmbedVideoHTML(videoIdArg)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
|
||||
return PlaylistHtml.getWatchPlaylistHTML(videoPlaylistIdArg, req, res)
|
||||
}
|
||||
|
||||
static getVideoPlaylistEmbedHTML (playlistIdArg: string) {
|
||||
return PlaylistHtml.getEmbedPlaylistHTML(playlistIdArg)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
||||
return ActorHtml.getAccountHTMLPage(nameWithHost, req, res)
|
||||
}
|
||||
|
||||
static getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
||||
return ActorHtml.getVideoChannelHTMLPage(nameWithHost, req, res)
|
||||
}
|
||||
|
||||
static getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
||||
return ActorHtml.getActorHTMLPage(nameWithHost, req, res)
|
||||
}
|
||||
}
|
||||
|
||||
function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
|
||||
res.set('Content-Type', 'text/html; charset=UTF-8')
|
||||
res.set('Cache-Control', 'max-age=0, no-cache, must-revalidate')
|
||||
|
||||
if (localizedHTML) {
|
||||
res.set('Vary', 'Accept-Language')
|
||||
}
|
||||
|
||||
return res.send(html)
|
||||
}
|
||||
|
||||
async function serveIndexHTML (req: express.Request, res: express.Response) {
|
||||
if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
|
||||
try {
|
||||
await generateHTMLPage(req, res, req.params.language)
|
||||
return
|
||||
} catch (err) {
|
||||
logger.error('Cannot generate HTML page.', { err })
|
||||
return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
ClientHtml,
|
||||
sendHTML,
|
||||
serveIndexHTML
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
|
||||
const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
|
||||
|
||||
return sendHTML(html, res, true)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { escapeHTML, maxBy } from '@peertube/peertube-core-utils'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
import { MAccountHost, MChannelHost } from '@server/types/models/index.js'
|
||||
import express from 'express'
|
||||
import { CONFIG } from '../../../initializers/config.js'
|
||||
import { PageHtml } from './page-html.js'
|
||||
import { TagsHtml } from './tags-html.js'
|
||||
|
||||
export class ActorHtml {
|
||||
|
||||
static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
||||
const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
|
||||
|
||||
return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
|
||||
}
|
||||
|
||||
static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
||||
const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
|
||||
|
||||
return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
|
||||
}
|
||||
|
||||
static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
|
||||
const [ account, channel ] = await Promise.all([
|
||||
AccountModel.loadByNameWithHost(nameWithHost),
|
||||
VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
|
||||
])
|
||||
|
||||
return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static async getAccountOrChannelHTMLPage (
|
||||
loader: () => Promise<MAccountHost | MChannelHost>,
|
||||
req: express.Request,
|
||||
res: express.Response
|
||||
) {
|
||||
const [ html, entity ] = await Promise.all([
|
||||
PageHtml.getIndexHTML(req, res),
|
||||
loader()
|
||||
])
|
||||
|
||||
// Let Angular application handle errors
|
||||
if (!entity) {
|
||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
||||
return PageHtml.getIndexHTML(req, res)
|
||||
}
|
||||
|
||||
const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(entity.description)
|
||||
|
||||
let customHTML = TagsHtml.addTitleTag(html, entity.getDisplayName())
|
||||
customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription)
|
||||
|
||||
const url = entity.getClientUrl()
|
||||
const siteName = CONFIG.INSTANCE.NAME
|
||||
const title = entity.getDisplayName()
|
||||
|
||||
const avatar = maxBy(entity.Actor.Avatars, 'width')
|
||||
const image = {
|
||||
url: ActorImageModel.getImageUrl(avatar),
|
||||
width: avatar?.width,
|
||||
height: avatar?.height
|
||||
}
|
||||
|
||||
const ogType = 'website'
|
||||
const twitterCard = 'summary'
|
||||
const schemaType = 'ProfilePage'
|
||||
|
||||
customHTML = await TagsHtml.addTags(customHTML, {
|
||||
url,
|
||||
escapedTitle: escapeHTML(title),
|
||||
escapedSiteName: escapeHTML(siteName),
|
||||
escapedTruncatedDescription,
|
||||
image,
|
||||
ogType,
|
||||
twitterCard,
|
||||
schemaType,
|
||||
jsonldProfile: {
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt
|
||||
},
|
||||
|
||||
indexationPolicy: entity.Actor.isOwned()
|
||||
? 'always'
|
||||
: 'never'
|
||||
}, {})
|
||||
|
||||
return customHTML
|
||||
}
|
||||
}
|
||||
変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示
新しい課題から参照
ユーザをブロックする