Init commit
This commit is contained in:
74
server/core/lib/activitypub/activity.ts
Normal file
74
server/core/lib/activitypub/activity.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { doJSONRequest, PeerTubeRequestOptions } from '@server/helpers/requests.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { ActivityObject, ActivityPubActor, ActivityType, APObjectId } from '@peertube/peertube-models'
|
||||
import { buildSignedRequestOptions } from './send/index.js'
|
||||
|
||||
export function getAPId (object: string | { id: string }) {
|
||||
if (typeof object === 'string') return object
|
||||
|
||||
return object.id
|
||||
}
|
||||
|
||||
export function getActivityStreamDuration (duration: number) {
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
|
||||
return 'PT' + duration + 'S'
|
||||
}
|
||||
|
||||
export function getDurationFromActivityStream (duration: string) {
|
||||
return parseInt(duration.replace(/[^\d]+/, ''))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildAvailableActivities (): ActivityType[] {
|
||||
return [
|
||||
'Create',
|
||||
'Update',
|
||||
'Delete',
|
||||
'Follow',
|
||||
'Accept',
|
||||
'Announce',
|
||||
'Undo',
|
||||
'Like',
|
||||
'Reject',
|
||||
'View',
|
||||
'Dislike',
|
||||
'Flag'
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchAP <T> (url: string, moreOptions: PeerTubeRequestOptions = {}) {
|
||||
const options = {
|
||||
activityPub: true,
|
||||
|
||||
httpSignature: CONFIG.FEDERATION.SIGN_FEDERATED_FETCHES
|
||||
? await buildSignedRequestOptions({ hasPayload: false })
|
||||
: undefined,
|
||||
|
||||
...moreOptions
|
||||
}
|
||||
|
||||
return doJSONRequest<T>(url, options)
|
||||
}
|
||||
|
||||
export async function fetchAPObjectIfNeeded <T extends (ActivityObject | ActivityPubActor)> (object: APObjectId) {
|
||||
if (typeof object === 'string') {
|
||||
const { body } = await fetchAP<Exclude<T, string>>(object)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
return object as Exclude<T, string>
|
||||
}
|
||||
|
||||
export async function findLatestAPRedirection (url: string, iteration = 1) {
|
||||
if (iteration > 10) throw new Error('Too much iterations to find final URL ' + url)
|
||||
|
||||
const { headers } = await fetchAP(url, { followRedirect: false })
|
||||
|
||||
if (headers.location) return findLatestAPRedirection(headers.location, iteration + 1)
|
||||
|
||||
return url
|
||||
}
|
||||
8
server/core/lib/activitypub/actors/check-actor.ts
Normal file
8
server/core/lib/activitypub/actors/check-actor.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { MActorHostOnly } from '@server/types/models/index.js'
|
||||
|
||||
export function haveActorsSameRemoteHost (base: MActorHostOnly, other: MActorHostOnly) {
|
||||
if (!base.serverId || !other.serverId) return false
|
||||
if (base.serverId !== other.serverId) return false
|
||||
|
||||
return true
|
||||
}
|
||||
140
server/core/lib/activitypub/actors/get.ts
Normal file
140
server/core/lib/activitypub/actors/get.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import { ActivityPubActor, APObjectId } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders/index.js'
|
||||
import {
|
||||
MActor,
|
||||
MActorAccountChannelId,
|
||||
MActorAccountChannelIdActor,
|
||||
MActorAccountId,
|
||||
MActorFullActor
|
||||
} from '@server/types/models/index.js'
|
||||
import { fetchAPObjectIfNeeded, getAPId } from '../activity.js'
|
||||
import { checkUrlsSameHost } from '../url.js'
|
||||
import { refreshActorIfNeeded } from './refresh.js'
|
||||
import { APActorCreator, fetchRemoteActor } from './shared/index.js'
|
||||
|
||||
// FIXME: use an object for params
|
||||
function getOrCreateAPActor (
|
||||
activityActor: string | ActivityPubActor,
|
||||
fetchType: 'all',
|
||||
recurseIfNeeded?: boolean,
|
||||
updateCollections?: boolean
|
||||
): Promise<MActorFullActor>
|
||||
|
||||
function getOrCreateAPActor (
|
||||
activityActor: string | ActivityPubActor,
|
||||
fetchType?: 'association-ids',
|
||||
recurseIfNeeded?: boolean,
|
||||
updateCollections?: boolean
|
||||
): Promise<MActorAccountChannelId>
|
||||
|
||||
async function getOrCreateAPActor (
|
||||
activityActor: string | ActivityPubActor,
|
||||
fetchType: ActorLoadByUrlType = 'association-ids',
|
||||
recurseIfNeeded = true,
|
||||
updateCollections = false
|
||||
): Promise<MActorFullActor | MActorAccountChannelId> {
|
||||
const actorUrl = getAPId(activityActor)
|
||||
let actor = await loadActorByUrl(actorUrl, fetchType)
|
||||
|
||||
let created = false
|
||||
let accountPlaylistsUrl: string
|
||||
|
||||
// We don't have this actor in our database, fetch it on remote
|
||||
if (!actor) {
|
||||
const { actorObject } = await fetchRemoteActor(actorUrl)
|
||||
if (actorObject === undefined) throw new Error(`Cannot fetch remote actor ${actorUrl}`)
|
||||
|
||||
// actorUrl is just an alias/redirection, so process object id instead
|
||||
if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
|
||||
|
||||
// Create the attributed to actor
|
||||
// In PeerTube a video channel is owned by an account
|
||||
let ownerActor: MActorFullActor
|
||||
if (recurseIfNeeded === true && actorObject.type === 'Group') {
|
||||
ownerActor = await getOrCreateAPOwner(actorObject, actorUrl)
|
||||
}
|
||||
|
||||
const creator = new APActorCreator(actorObject, ownerActor)
|
||||
actor = await retryTransactionWrapper(creator.create.bind(creator))
|
||||
created = true
|
||||
accountPlaylistsUrl = actorObject.playlists
|
||||
}
|
||||
|
||||
if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor
|
||||
if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
|
||||
|
||||
const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType })
|
||||
if (!actorRefreshed) throw new Error(`Actor ${actor.url} does not exist anymore.`)
|
||||
|
||||
await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections)
|
||||
await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
|
||||
|
||||
return actorRefreshed
|
||||
}
|
||||
|
||||
async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorId: string) {
|
||||
const accountAttributedTo = await findOwner(actorId, actorObject.attributedTo, 'Person')
|
||||
if (!accountAttributedTo) {
|
||||
throw new Error(`Cannot find account attributed to video channel ${actorId}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Don't recurse another time
|
||||
const recurseIfNeeded = false
|
||||
return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
|
||||
} catch (err) {
|
||||
logger.error(`Cannot get or create account attributed to video channel ${actorId}`)
|
||||
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') {
|
||||
for (const actorToCheck of arrayify(attributedTo)) {
|
||||
const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck))
|
||||
|
||||
if (!actorObject) {
|
||||
logger.warn(`Unknown attributed to actor ${actorToCheck} for owner ${rootUrl}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) {
|
||||
logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (actorObject.type === type) return actorObject
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
findOwner,
|
||||
getOrCreateAPActor,
|
||||
getOrCreateAPOwner
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, refreshed: boolean, updateCollections: boolean) {
|
||||
if ((created === true || refreshed === true) && updateCollections === true) {
|
||||
const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
}
|
||||
|
||||
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
|
||||
// We created a new account: fetch the playlists
|
||||
if (created === true && actor.Account && accountPlaylistsUrl) {
|
||||
const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists', accountId: actor.Account.id }
|
||||
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
}
|
||||
112
server/core/lib/activitypub/actors/image.ts
Normal file
112
server/core/lib/activitypub/actors/image.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ActorImageModel } from '@server/models/actor/actor-image.js'
|
||||
import { MActorImage, MActorImages } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
|
||||
type ImageInfo = {
|
||||
name: string
|
||||
fileUrl: string
|
||||
height: number
|
||||
width: number
|
||||
onDisk?: boolean
|
||||
}
|
||||
|
||||
async function updateActorImages (actor: MActorImages, type: ActorImageType_Type, imagesInfo: ImageInfo[], t: Transaction) {
|
||||
const getAvatarsOrBanners = () => {
|
||||
const result = type === ActorImageType.AVATAR
|
||||
? actor.Avatars
|
||||
: actor.Banners
|
||||
|
||||
return result || []
|
||||
}
|
||||
|
||||
if (imagesInfo.length === 0) {
|
||||
await deleteActorImages(actor, type, t)
|
||||
}
|
||||
|
||||
// Cleanup old images that did not have a width
|
||||
for (const oldImageModel of getAvatarsOrBanners()) {
|
||||
if (oldImageModel.width) continue
|
||||
|
||||
await safeDeleteActorImage(actor, oldImageModel, type, t)
|
||||
}
|
||||
|
||||
for (const imageInfo of imagesInfo) {
|
||||
const oldImageModel = getAvatarsOrBanners().find(i => imageInfo.width && i.width === imageInfo.width)
|
||||
|
||||
if (oldImageModel) {
|
||||
// Don't update the avatar if the file URL did not change
|
||||
if (imageInfo.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
await safeDeleteActorImage(actor, oldImageModel, type, t)
|
||||
}
|
||||
|
||||
const imageModel = await ActorImageModel.create({
|
||||
filename: imageInfo.name,
|
||||
onDisk: imageInfo.onDisk ?? false,
|
||||
fileUrl: imageInfo.fileUrl,
|
||||
height: imageInfo.height,
|
||||
width: imageInfo.width,
|
||||
type,
|
||||
actorId: actor.id
|
||||
}, { transaction: t })
|
||||
|
||||
addActorImage(actor, type, imageModel)
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
async function deleteActorImages (actor: MActorImages, type: ActorImageType_Type, t: Transaction) {
|
||||
try {
|
||||
const association = buildAssociationName(type)
|
||||
|
||||
for (const image of actor[association]) {
|
||||
await image.destroy({ transaction: t })
|
||||
}
|
||||
|
||||
actor[association] = []
|
||||
} catch (err) {
|
||||
logger.error('Cannot remove old image of actor %s.', actor.url, { err })
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType_Type, t: Transaction) {
|
||||
try {
|
||||
await toDelete.destroy({ transaction: t })
|
||||
|
||||
const association = buildAssociationName(type)
|
||||
actor[association] = actor[association].filter(image => image.id !== toDelete.id)
|
||||
} catch (err) {
|
||||
logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
type ImageInfo,
|
||||
|
||||
updateActorImages,
|
||||
deleteActorImages
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function addActorImage (actor: MActorImages, type: ActorImageType_Type, imageModel: MActorImage) {
|
||||
const association = buildAssociationName(type)
|
||||
if (!actor[association]) actor[association] = []
|
||||
|
||||
actor[association].push(imageModel)
|
||||
}
|
||||
|
||||
function buildAssociationName (type: ActorImageType_Type) {
|
||||
return type === ActorImageType.AVATAR
|
||||
? 'Avatars'
|
||||
: 'Banners'
|
||||
}
|
||||
7
server/core/lib/activitypub/actors/index.ts
Normal file
7
server/core/lib/activitypub/actors/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './check-actor.js'
|
||||
export * from './get.js'
|
||||
export * from './image.js'
|
||||
export * from './keys.js'
|
||||
export * from './refresh.js'
|
||||
export * from './updater.js'
|
||||
export * from './webfinger.js'
|
||||
16
server/core/lib/activitypub/actors/keys.ts
Normal file
16
server/core/lib/activitypub/actors/keys.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createPrivateAndPublicKeys } from '@server/helpers/peertube-crypto.js'
|
||||
import { MActor } from '@server/types/models/index.js'
|
||||
|
||||
// Set account keys, this could be long so process after the account creation and do not block the client
|
||||
async function generateAndSaveActorKeys <T extends MActor> (actor: T) {
|
||||
const { publicKey, privateKey } = await createPrivateAndPublicKeys()
|
||||
|
||||
actor.publicKey = publicKey
|
||||
actor.privateKey = privateKey
|
||||
|
||||
return actor.save()
|
||||
}
|
||||
|
||||
export {
|
||||
generateAndSaveActorKeys
|
||||
}
|
||||
83
server/core/lib/activitypub/actors/refresh.ts
Normal file
83
server/core/lib/activitypub/actors/refresh.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CachePromiseFactory } from '@server/helpers/promise-cache.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { ActorLoadByUrlType } from '@server/lib/model-loaders/index.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { MActorAccountChannelId, MActorFull } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { fetchRemoteActor } from './shared/index.js'
|
||||
import { APActorUpdater } from './updater.js'
|
||||
import { getUrlFromWebfinger } from './webfinger.js'
|
||||
|
||||
type RefreshResult<T> = Promise<{ actor: T | MActorFull, refreshed: boolean }>
|
||||
|
||||
type RefreshOptions<T> = {
|
||||
actor: T
|
||||
fetchedType: ActorLoadByUrlType
|
||||
}
|
||||
|
||||
const promiseCache = new CachePromiseFactory(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url)
|
||||
|
||||
function refreshActorIfNeeded<T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult<T> {
|
||||
const actorArg = options.actor
|
||||
if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false })
|
||||
|
||||
return promiseCache.run(options)
|
||||
}
|
||||
|
||||
export {
|
||||
refreshActorIfNeeded
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function doRefresh<T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult<MActorFull> {
|
||||
const { actor: actorArg, fetchedType } = options
|
||||
|
||||
// We need more attributes
|
||||
const actor = fetchedType === 'all'
|
||||
? actorArg as MActorFull
|
||||
: await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'actor', 'refresh', actor.url)
|
||||
|
||||
logger.info('Refreshing actor %s.', actor.url, lTags())
|
||||
|
||||
try {
|
||||
const actorUrl = await getActorUrl(actor)
|
||||
const { actorObject } = await fetchRemoteActor(actorUrl)
|
||||
|
||||
if (actorObject === undefined) {
|
||||
logger.info('Cannot fetch remote actor %s in refresh actor.', actorUrl)
|
||||
return { actor, refreshed: false }
|
||||
}
|
||||
|
||||
const updater = new APActorUpdater(actorObject, actor)
|
||||
await updater.update()
|
||||
|
||||
return { refreshed: true, actor }
|
||||
} catch (err) {
|
||||
const statusCode = (err as PeerTubeRequestError).statusCode
|
||||
|
||||
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.info('Deleting actor %s because there is a 404/410 in refresh actor.', actor.url, lTags())
|
||||
|
||||
actor.Account
|
||||
? await actor.Account.destroy()
|
||||
: await actor.VideoChannel.destroy()
|
||||
|
||||
return { actor: undefined, refreshed: false }
|
||||
}
|
||||
|
||||
logger.info('Cannot refresh actor %s.', actor.url, { err, ...lTags() })
|
||||
return { actor, refreshed: false }
|
||||
}
|
||||
}
|
||||
|
||||
function getActorUrl (actor: MActorFull) {
|
||||
return getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
|
||||
.catch(err => {
|
||||
logger.info('Cannot get actor URL from webfinger, keeping the old one.', { err })
|
||||
return actor.url
|
||||
})
|
||||
}
|
||||
140
server/core/lib/activitypub/actors/shared/creator.ts
Normal file
140
server/core/lib/activitypub/actors/shared/creator.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { ServerModel } from '@server/models/server/server.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
import { MAccount, MActor, MActorFull, MActorFullActor, MActorImages, MChannel, MServer } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { upsertAPPlayerSettings } from '../../player-settings.js'
|
||||
import { updateActorImages } from '../image.js'
|
||||
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js'
|
||||
import { fetchActorFollowsCount } from './url-to-object.js'
|
||||
|
||||
export class APActorCreator {
|
||||
private readonly lTags: LoggerTagsFn
|
||||
|
||||
constructor (
|
||||
private readonly actorObject: ActivityPubActor,
|
||||
private readonly ownerActor?: MActorFullActor
|
||||
) {
|
||||
this.lTags = loggerTagsFactory('ap', 'actor', 'create', this.actorObject.id)
|
||||
}
|
||||
|
||||
async create (): Promise<MActorFull> {
|
||||
logger.debug('Creating remote actor from object', { actorObject: this.actorObject, ...this.lTags() })
|
||||
|
||||
const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject)
|
||||
|
||||
const actor = await sequelizeTypescript.transaction(async t => {
|
||||
const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount)) as MActorFull
|
||||
|
||||
const server = await this.setServer(actorInstance, t)
|
||||
|
||||
const existingActor = await ActorModel.loadByUniqueKeys({
|
||||
preferredUsername: actorInstance.preferredUsername,
|
||||
url: actorInstance.url,
|
||||
serverId: actorInstance.serverId,
|
||||
transaction: t
|
||||
})
|
||||
|
||||
if (existingActor) {
|
||||
logger.debug(`Actor ${existingActor.id} already exists, updating existing one`, this.lTags())
|
||||
|
||||
// Re-init unique keys
|
||||
existingActor.preferredUsername = actorInstance.preferredUsername
|
||||
existingActor.url = actorInstance.url
|
||||
existingActor.serverId = actorInstance.serverId
|
||||
|
||||
await existingActor.save({ transaction: t })
|
||||
|
||||
return ActorModel.loadFull(existingActor.id, t)
|
||||
}
|
||||
|
||||
if (isAccountActor(this.actorObject.type)) {
|
||||
const account = await this.saveAccount(actorInstance, t)
|
||||
await actorInstance.save({ transaction: t })
|
||||
|
||||
actorInstance.Account = Object.assign(account, { Actor: actorInstance })
|
||||
} else if (isChannelActor(this.actorObject.type)) {
|
||||
const channel = await this.saveVideoChannel(actorInstance, t)
|
||||
await actorInstance.save({ transaction: t })
|
||||
|
||||
actorInstance.VideoChannel = Object.assign(channel, { Account: this.ownerActor.Account })
|
||||
}
|
||||
|
||||
await this.setImageIfNeeded(actorInstance, ActorImageType.AVATAR, t)
|
||||
await this.setImageIfNeeded(actorInstance, ActorImageType.BANNER, t)
|
||||
|
||||
actorInstance.Server = server
|
||||
|
||||
return actorInstance
|
||||
})
|
||||
|
||||
if (isChannelActor(actor.type) && typeof this.actorObject.playerSettings === 'string') {
|
||||
const channel = Object.assign(actor.VideoChannel, { Actor: actor })
|
||||
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject: this.actorObject.playerSettings,
|
||||
video: undefined,
|
||||
channel,
|
||||
contextUrl: actor.url
|
||||
})
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
private async setServer (actor: MActor, t: Transaction) {
|
||||
const actorHost = new URL(actor.url).host
|
||||
|
||||
const serverOptions = {
|
||||
where: {
|
||||
host: actorHost
|
||||
},
|
||||
defaults: {
|
||||
host: actorHost
|
||||
},
|
||||
transaction: t
|
||||
}
|
||||
const [ server ] = await ServerModel.findOrCreate(serverOptions)
|
||||
|
||||
// Save our new account in database
|
||||
actor.serverId = server.id
|
||||
|
||||
return server as MServer
|
||||
}
|
||||
|
||||
private async setImageIfNeeded (actor: MActor, type: ActorImageType_Type, t: Transaction) {
|
||||
const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
|
||||
if (imagesInfo.length === 0) return
|
||||
|
||||
return updateActorImages(actor as MActorImages, type, imagesInfo, t)
|
||||
}
|
||||
|
||||
private async saveAccount (actor: MActor, t: Transaction) {
|
||||
const accountCreated = await AccountModel.create({
|
||||
name: getActorDisplayNameFromObject(this.actorObject),
|
||||
description: this.actorObject.summary
|
||||
}, { transaction: t })
|
||||
|
||||
actor.accountId = accountCreated.id
|
||||
|
||||
return accountCreated as MAccount
|
||||
}
|
||||
|
||||
private async saveVideoChannel (actor: MActor, t: Transaction) {
|
||||
const videoChannelCreated = await VideoChannelModel.create({
|
||||
name: getActorDisplayNameFromObject(this.actorObject),
|
||||
description: this.actorObject.summary,
|
||||
support: this.actorObject.support,
|
||||
accountId: this.ownerActor.Account.id
|
||||
}, { transaction: t })
|
||||
|
||||
actor.videoChannelId = videoChannelCreated.id
|
||||
|
||||
return videoChannelCreated as MChannel
|
||||
}
|
||||
}
|
||||
3
server/core/lib/activitypub/actors/shared/index.ts
Normal file
3
server/core/lib/activitypub/actors/shared/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './creator.js'
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './url-to-object.js'
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ActivityIconObject, ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
import { MIMETYPES } from '@server/initializers/constants.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils'
|
||||
|
||||
function getActorAttributesFromObject (
|
||||
actorObject: ActivityPubActor,
|
||||
followersCount: number,
|
||||
followingCount: number
|
||||
): FilteredModelAttributes<ActorModel> {
|
||||
return {
|
||||
type: actorObject.type,
|
||||
preferredUsername: actorObject.preferredUsername,
|
||||
url: actorObject.id,
|
||||
publicKey: actorObject.publicKey.publicKeyPem,
|
||||
privateKey: null,
|
||||
followersCount,
|
||||
followingCount,
|
||||
inboxUrl: actorObject.inbox,
|
||||
outboxUrl: actorObject.outbox,
|
||||
followersUrl: actorObject.followers,
|
||||
followingUrl: actorObject.following,
|
||||
|
||||
sharedInboxUrl: actorObject.endpoints?.sharedInbox
|
||||
? actorObject.endpoints.sharedInbox
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType_Type) {
|
||||
const iconsOrImages = type === ActorImageType.AVATAR
|
||||
? actorObject.icon
|
||||
: actorObject.image
|
||||
|
||||
return normalizeIconOrImage(iconsOrImages)
|
||||
.map(iconOrImage => {
|
||||
const mimetypes = MIMETYPES.IMAGE
|
||||
|
||||
if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined
|
||||
|
||||
let extension: string
|
||||
|
||||
if (iconOrImage.mediaType) {
|
||||
extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType]
|
||||
} else {
|
||||
const tmp = getLowercaseExtension(iconOrImage.url)
|
||||
|
||||
if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
|
||||
}
|
||||
|
||||
if (!extension) return undefined
|
||||
|
||||
return {
|
||||
name: buildUUID() + extension,
|
||||
fileUrl: iconOrImage.url,
|
||||
height: iconOrImage.height,
|
||||
width: iconOrImage.width,
|
||||
type
|
||||
}
|
||||
})
|
||||
.filter(i => !!i)
|
||||
}
|
||||
|
||||
function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
|
||||
return actorObject.name || actorObject.preferredUsername
|
||||
}
|
||||
|
||||
export {
|
||||
getActorAttributesFromObject,
|
||||
getImagesInfoFromObject,
|
||||
getActorDisplayNameFromObject
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] {
|
||||
if (Array.isArray(icon)) return icon
|
||||
if (icon) return [ icon ]
|
||||
|
||||
return []
|
||||
}
|
||||
68
server/core/lib/activitypub/actors/shared/url-to-object.ts
Normal file
68
server/core/lib/activitypub/actors/shared/url-to-object.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ActivityPubActor, ActivityPubOrderedCollection } from '@peertube/peertube-models'
|
||||
import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor.js'
|
||||
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
export async function fetchRemoteActor (
|
||||
actorUrl: string,
|
||||
canRefetchPublicKeyOwner = true
|
||||
): Promise<{ statusCode: number, actorObject: ActivityPubActor }> {
|
||||
logger.info('Fetching remote actor %s.', actorUrl)
|
||||
|
||||
const { body, statusCode } = await fetchAP<ActivityPubActor>(actorUrl)
|
||||
|
||||
if (sanitizeAndCheckActorObject(body) === false) {
|
||||
logger.debug('Remote actor JSON is not valid.', { actorJSON: body })
|
||||
|
||||
// Retry with the public key owner
|
||||
if (canRefetchPublicKeyOwner && hasPublicKeyOwner(actorUrl, body)) {
|
||||
logger.debug('Retrying with public key owner ' + body.publicKey.owner)
|
||||
|
||||
return fetchRemoteActor(body.publicKey.owner, false)
|
||||
}
|
||||
|
||||
return { actorObject: undefined, statusCode }
|
||||
}
|
||||
|
||||
if (checkUrlsSameHost(body.id, actorUrl) !== true) {
|
||||
logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, body.id)
|
||||
return { actorObject: undefined, statusCode }
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
|
||||
actorObject: body
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchActorFollowsCount (actorObject: ActivityPubActor) {
|
||||
let followersCount = 0
|
||||
let followingCount = 0
|
||||
|
||||
if (actorObject.followers) followersCount = await fetchActorTotalItems(actorObject.followers)
|
||||
if (actorObject.following) followingCount = await fetchActorTotalItems(actorObject.following)
|
||||
|
||||
return { followersCount, followingCount }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchActorTotalItems (url: string) {
|
||||
try {
|
||||
const { body } = await fetchAP<ActivityPubOrderedCollection<unknown>>(url)
|
||||
|
||||
return body.totalItems || 0
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote actor count %s.', url, { err })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function hasPublicKeyOwner (actorUrl: string, actor: ActivityPubActor) {
|
||||
return isUrlValid(actor?.publicKey?.owner) && checkUrlsSameHost(actorUrl, actor.publicKey.owner)
|
||||
}
|
||||
113
server/core/lib/activitypub/actors/updater.ts
Normal file
113
server/core/lib/activitypub/actors/updater.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ActivityPubActor, ActorImageType } from '@peertube/peertube-models'
|
||||
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel.js'
|
||||
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js'
|
||||
import { upsertAPPlayerSettings } from '../player-settings.js'
|
||||
import { getOrCreateAPOwner } from './get.js'
|
||||
import { updateActorImages } from './image.js'
|
||||
import { fetchActorFollowsCount } from './shared/index.js'
|
||||
import { getImagesInfoFromObject } from './shared/object-to-model-attributes.js'
|
||||
|
||||
export class APActorUpdater {
|
||||
private readonly accountOrChannel: MAccount | MChannel
|
||||
|
||||
constructor (
|
||||
private readonly actorObject: ActivityPubActor,
|
||||
private readonly actor: MActorFull
|
||||
) {
|
||||
if (this.actorObject.type === 'Group') this.accountOrChannel = this.actor.VideoChannel
|
||||
else this.accountOrChannel = this.actor.Account
|
||||
}
|
||||
|
||||
async update () {
|
||||
const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
|
||||
const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
|
||||
|
||||
try {
|
||||
await this.updateActorInstance(this.actor, this.actorObject)
|
||||
|
||||
this.accountOrChannel.name = this.actorObject.name || this.actorObject.preferredUsername
|
||||
this.accountOrChannel.description = this.actorObject.summary
|
||||
|
||||
const accountOrChannel = this.accountOrChannel
|
||||
|
||||
if (accountOrChannel instanceof VideoChannelModel) {
|
||||
const channel = accountOrChannel as MChannel
|
||||
|
||||
const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.id)
|
||||
|
||||
channel.accountId = owner.Account.id
|
||||
channel.support = this.actorObject.support
|
||||
|
||||
if (typeof this.actorObject.playerSettings === 'string') {
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject: this.actorObject.playerSettings,
|
||||
video: undefined,
|
||||
channel: Object.assign(channel, { Account: owner.Account, Actor: this.actor }),
|
||||
contextUrl: this.actor.url
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await runInReadCommittedTransaction(async t => {
|
||||
await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
|
||||
await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
|
||||
})
|
||||
|
||||
await runInReadCommittedTransaction(async t => {
|
||||
await this.accountOrChannel.save({ transaction: t })
|
||||
|
||||
if (accountOrChannel instanceof VideoChannelModel) {
|
||||
this.actor.videoChannelId = accountOrChannel.id
|
||||
this.actor.accountId = null
|
||||
} else if (accountOrChannel instanceof AccountModel) {
|
||||
this.actor.accountId = accountOrChannel.id
|
||||
this.actor.videoChannelId = null
|
||||
}
|
||||
|
||||
await this.actor.save({ transaction: t })
|
||||
})
|
||||
|
||||
// Update the following line to template string
|
||||
logger.info(`Remote account ${this.actorObject.id} updated`)
|
||||
} catch (err) {
|
||||
if (this.actor !== undefined) {
|
||||
await resetSequelizeInstance(this.actor)
|
||||
}
|
||||
|
||||
if (this.accountOrChannel !== undefined) {
|
||||
await resetSequelizeInstance(this.accountOrChannel)
|
||||
}
|
||||
|
||||
// This is just a debug because we will retry the insert
|
||||
logger.debug('Cannot update the remote account.', { err })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async updateActorInstance (actorInstance: MActor, actorObject: ActivityPubActor) {
|
||||
const { followersCount, followingCount } = await fetchActorFollowsCount(actorObject)
|
||||
|
||||
actorInstance.type = actorObject.type
|
||||
actorInstance.preferredUsername = actorObject.preferredUsername
|
||||
actorInstance.url = actorObject.id
|
||||
actorInstance.publicKey = actorObject.publicKey.publicKeyPem
|
||||
actorInstance.followersCount = followersCount
|
||||
actorInstance.followingCount = followingCount
|
||||
actorInstance.inboxUrl = actorObject.inbox
|
||||
actorInstance.outboxUrl = actorObject.outbox
|
||||
actorInstance.followersUrl = actorObject.followers
|
||||
actorInstance.followingUrl = actorObject.following
|
||||
|
||||
if (actorObject.published) actorInstance.remoteCreatedAt = new Date(actorObject.published)
|
||||
|
||||
if (actorObject.endpoints?.sharedInbox) {
|
||||
actorInstance.sharedInboxUrl = actorObject.endpoints.sharedInbox
|
||||
}
|
||||
|
||||
// Force actor update
|
||||
actorInstance.changed('updatedAt', true)
|
||||
}
|
||||
}
|
||||
46
server/core/lib/activitypub/actors/webfinger.ts
Normal file
46
server/core/lib/activitypub/actors/webfinger.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { isProdInstance } from '@peertube/peertube-node-utils'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { REQUEST_TIMEOUTS, WEBSERVER } from '@server/initializers/constants.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { MActorFull } from '@server/types/models/index.js'
|
||||
import WebFinger from 'webfinger.js'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
const webfinger = new WebFinger({
|
||||
tls_only: isProdInstance(),
|
||||
uri_fallback: false,
|
||||
request_timeout: REQUEST_TIMEOUTS.DEFAULT,
|
||||
allow_private_addresses: CONFIG.FEDERATION.PREVENT_SSRF === false
|
||||
})
|
||||
|
||||
export async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
|
||||
// Handle strings like @toto@example.com
|
||||
const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg
|
||||
|
||||
const [ name, host ] = uri.split('@')
|
||||
let actor: MActorFull
|
||||
|
||||
if (!host || host === WEBSERVER.HOST) {
|
||||
actor = await ActorModel.loadLocalByName(name)
|
||||
} else {
|
||||
actor = await ActorModel.loadByNameAndHost(name, host)
|
||||
}
|
||||
|
||||
if (actor) return actor.url
|
||||
|
||||
return getUrlFromWebfinger(uri)
|
||||
}
|
||||
|
||||
export async function getUrlFromWebfinger (uri: string) {
|
||||
const { object } = await webfinger.lookup(uri)
|
||||
|
||||
if (Array.isArray(object.links) === false) throw new Error('WebFinger links is not an array.')
|
||||
|
||||
const selfLink = object.links.find(l => l.rel === 'self')
|
||||
if (selfLink === undefined || isActivityPubUrlValid(selfLink.href as string) === false) {
|
||||
throw new Error('Cannot find self link or href is not a valid URL.')
|
||||
}
|
||||
|
||||
return selfLink.href as string
|
||||
}
|
||||
107
server/core/lib/activitypub/audience.ts
Normal file
107
server/core/lib/activitypub/audience.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ActivityAudience, VideoPlaylistPrivacy, VideoPlaylistPrivacyType, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
|
||||
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
|
||||
import {
|
||||
MAccountAudience,
|
||||
MActorFollowersUrl,
|
||||
MActorUrl,
|
||||
MChannelAudience,
|
||||
MCommentOwner,
|
||||
MVideoAccountLight
|
||||
} from '../../types/models/index.js'
|
||||
|
||||
export function getPublicAudience (actorSender: MActorFollowersUrl) {
|
||||
return _buildPublicAudience({ cc: [ actorSender.followersUrl ] })
|
||||
}
|
||||
|
||||
export function getDirectAudience (targetActor: MActorUrl): ActivityAudience {
|
||||
return { to: [ targetActor.url ], cc: [] }
|
||||
}
|
||||
|
||||
export function getVideoAudience (options: {
|
||||
account: MAccountAudience
|
||||
channel: MChannelAudience
|
||||
privacy: VideoPrivacyType
|
||||
skipPrivacyCheck?: boolean // default false
|
||||
}) {
|
||||
const { account, channel, privacy, skipPrivacyCheck = false } = options
|
||||
|
||||
const followerUrls = [ account.Actor.followersUrl ]
|
||||
|
||||
if (privacy === VideoPrivacy.PUBLIC) {
|
||||
return _buildPublicAudience({
|
||||
to: [ channel.Actor.url ], // fep-1b12
|
||||
cc: followerUrls
|
||||
})
|
||||
}
|
||||
|
||||
if (privacy === VideoPrivacy.UNLISTED) {
|
||||
return _buildUnlistedAudience()
|
||||
}
|
||||
|
||||
if (skipPrivacyCheck) {
|
||||
return _buildPrivateAudience()
|
||||
}
|
||||
|
||||
throw new Error(`Cannot get audience of non public/unlisted video privacy type (${privacy})`)
|
||||
}
|
||||
|
||||
export function getCommentAudience (options: {
|
||||
comment: MCommentOwner
|
||||
video: MVideoAccountLight
|
||||
threadParentComments: MCommentOwner[]
|
||||
}): ActivityAudience {
|
||||
const { comment, video, threadParentComments } = options
|
||||
|
||||
const audience: ActivityAudience = {
|
||||
to: [ getAPPublicValue() ],
|
||||
|
||||
cc: [
|
||||
comment.Account.Actor.followersUrl, // Followers of the commenter
|
||||
video.VideoChannel.Account.Actor.url, // Owner of the video
|
||||
video.VideoChannel.Actor.url // fep-1b12
|
||||
]
|
||||
}
|
||||
|
||||
// Send to actors we reply to
|
||||
for (const parentComment of threadParentComments) {
|
||||
if (parentComment.isDeleted()) continue
|
||||
|
||||
audience.cc.push(parentComment.Account.Actor.url)
|
||||
}
|
||||
|
||||
return audience
|
||||
}
|
||||
|
||||
export function getPlaylistAudience (actorSender: MActorFollowersUrl, privacy: VideoPlaylistPrivacyType) {
|
||||
const followerUrls = [ actorSender.followersUrl ]
|
||||
|
||||
if (privacy === VideoPlaylistPrivacy.PUBLIC) return _buildPublicAudience({ cc: followerUrls })
|
||||
else if (privacy === VideoPlaylistPrivacy.UNLISTED) return _buildUnlistedAudience()
|
||||
|
||||
throw new Error(`Cannot get audience of non public/unlisted playlist privacy type (${privacy})`)
|
||||
}
|
||||
|
||||
export function audiencify<T> (object: T, audience: ActivityAudience) {
|
||||
return { ...audience, ...object }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _buildPublicAudience (options: {
|
||||
to?: string[]
|
||||
cc?: string[]
|
||||
}) {
|
||||
const { to = [], cc = [] } = options
|
||||
|
||||
return { to: [ getAPPublicValue(), ...to ], cc }
|
||||
}
|
||||
|
||||
function _buildUnlistedAudience () {
|
||||
return { to: [], cc: [ getAPPublicValue() ] }
|
||||
}
|
||||
|
||||
function _buildPrivateAudience () {
|
||||
return { to: [], cc: [] }
|
||||
}
|
||||
72
server/core/lib/activitypub/cache-file.ts
Normal file
72
server/core/lib/activitypub/cache-file.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js'
|
||||
|
||||
export async function createOrUpdateCacheFile (
|
||||
cacheFileObject: CacheFileObject,
|
||||
video: MVideoWithAllFiles,
|
||||
byActor: MActorId,
|
||||
t: Transaction
|
||||
) {
|
||||
const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
|
||||
|
||||
if (redundancyModel) {
|
||||
return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
|
||||
}
|
||||
|
||||
return createCacheFile(cacheFileObject, video, byActor, t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
|
||||
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
|
||||
if (!attributes) return
|
||||
|
||||
return VideoRedundancyModel.create(attributes, { transaction: t })
|
||||
}
|
||||
|
||||
function updateCacheFile (
|
||||
cacheFileObject: CacheFileObject,
|
||||
redundancyModel: MVideoRedundancy,
|
||||
video: MVideoWithAllFiles,
|
||||
byActor: MActorId,
|
||||
t: Transaction
|
||||
) {
|
||||
if (redundancyModel.actorId !== byActor.id) {
|
||||
throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.')
|
||||
}
|
||||
|
||||
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
|
||||
if (!attributes) return
|
||||
|
||||
redundancyModel.expiresOn = attributes.expiresOn
|
||||
redundancyModel.fileUrl = attributes.fileUrl
|
||||
|
||||
return redundancyModel.save({ transaction: t })
|
||||
}
|
||||
|
||||
function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
|
||||
if (cacheFileObject.url.mediaType !== 'application/x-mpegURL') {
|
||||
logger.debug('Do not create remote cache file of non application/x-mpegURL media type', { cacheFileObject })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const url = cacheFileObject.url
|
||||
|
||||
const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
|
||||
if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
|
||||
|
||||
return {
|
||||
expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
|
||||
url: cacheFileObject.id,
|
||||
fileUrl: url.href,
|
||||
strategy: null,
|
||||
videoStreamingPlaylistId: playlist.id,
|
||||
actorId: byActor.id
|
||||
}
|
||||
}
|
||||
66
server/core/lib/activitypub/collection.ts
Normal file
66
server/core/lib/activitypub/collection.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import Bluebird from 'bluebird'
|
||||
import validator from 'validator'
|
||||
import { pageToStartAndCount } from '@server/helpers/core-utils.js'
|
||||
import { ACTIVITY_PUB } from '@server/initializers/constants.js'
|
||||
import { ResultList } from '@peertube/peertube-models'
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
|
||||
type ActivityPubCollectionPaginationHandler = (start: number, count: number) => Bluebird<ResultList<any>> | Promise<ResultList<any>>
|
||||
|
||||
export async function activityPubCollectionPagination (
|
||||
baseUrl: string,
|
||||
handler: ActivityPubCollectionPaginationHandler,
|
||||
page?: any,
|
||||
size = ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE
|
||||
) {
|
||||
if (!page || !validator.default.isInt(page)) {
|
||||
// We just display the first page URL, we only need the total items
|
||||
const result = await handler(0, 1)
|
||||
|
||||
return {
|
||||
id: baseUrl,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: result.total,
|
||||
first: result.data.length === 0
|
||||
? undefined
|
||||
: baseUrl + '?page=1'
|
||||
}
|
||||
}
|
||||
|
||||
const { start, count } = pageToStartAndCount(page, size)
|
||||
const result = await handler(start, count)
|
||||
|
||||
let next: string | undefined
|
||||
let prev: string | undefined
|
||||
|
||||
// Assert page is a number
|
||||
page = forceNumber(page)
|
||||
|
||||
// There are more results
|
||||
if (result.total > page * size) {
|
||||
next = baseUrl + '?page=' + (page + 1)
|
||||
}
|
||||
|
||||
if (page > 1) {
|
||||
prev = baseUrl + '?page=' + (page - 1)
|
||||
}
|
||||
|
||||
return {
|
||||
id: baseUrl + '?page=' + page,
|
||||
type: 'OrderedCollectionPage',
|
||||
prev,
|
||||
next,
|
||||
partOf: baseUrl,
|
||||
orderedItems: result.data,
|
||||
totalItems: result.total
|
||||
}
|
||||
}
|
||||
|
||||
export function activityPubCollection <T> (baseUrl: string, items: T[]) {
|
||||
return {
|
||||
id: baseUrl,
|
||||
type: 'OrderedCollection' as 'OrderedCollection',
|
||||
totalItems: items.length,
|
||||
orderedItems: items
|
||||
}
|
||||
}
|
||||
10
server/core/lib/activitypub/context.ts
Normal file
10
server/core/lib/activitypub/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Hooks } from '../plugins/hooks.js'
|
||||
|
||||
export function getContextFilter <T> () {
|
||||
return (contextData: T) => {
|
||||
return Hooks.wrapObject(
|
||||
contextData,
|
||||
'filter:activity-pub.activity.context.build.result'
|
||||
)
|
||||
}
|
||||
}
|
||||
55
server/core/lib/activitypub/crawl.ts
Normal file
55
server/core/lib/activitypub/crawl.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Bluebird from 'bluebird'
|
||||
import { URL } from 'url'
|
||||
import { ActivityPubOrderedCollection } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants.js'
|
||||
import { fetchAP } from './activity.js'
|
||||
|
||||
type HandlerFunction<T> = (items: T[]) => Promise<any> | Bluebird<any>
|
||||
type CleanerFunction = (startedDate: Date) => Promise<any>
|
||||
|
||||
export async function crawlCollectionPage<T> (argUrl: string, handler: HandlerFunction<T>, cleaner?: CleanerFunction) {
|
||||
let url = argUrl
|
||||
|
||||
logger.info('Crawling ActivityPub data on %s.', url)
|
||||
|
||||
const startDate = new Date()
|
||||
|
||||
const response = await fetchAP<ActivityPubOrderedCollection<T>>(url)
|
||||
const firstBody = response.body
|
||||
|
||||
const limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT
|
||||
let i = 0
|
||||
let nextLink = firstBody.first
|
||||
while (nextLink && i < limit) {
|
||||
i++
|
||||
|
||||
let body: any
|
||||
|
||||
if (typeof nextLink === 'string') {
|
||||
// Don't crawl ourselves
|
||||
const remoteHost = new URL(nextLink).host
|
||||
if (remoteHost === WEBSERVER.HOST) continue
|
||||
|
||||
url = nextLink
|
||||
|
||||
const res = await fetchAP<ActivityPubOrderedCollection<T>>(url)
|
||||
body = res.body
|
||||
} else {
|
||||
// nextLink is already the object we want
|
||||
body = nextLink
|
||||
}
|
||||
|
||||
nextLink = body.next
|
||||
|
||||
if (Array.isArray(body.orderedItems)) {
|
||||
const items = body.orderedItems
|
||||
logger.info('Processing %i ActivityPub items for %s.', items.length, url)
|
||||
|
||||
await handler(items)
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaner) await retryTransactionWrapper(cleaner, startDate)
|
||||
}
|
||||
51
server/core/lib/activitypub/follow.ts
Normal file
51
server/core/lib/activitypub/follow.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { SERVER_ACTOR_NAME } from '../../initializers/constants.js'
|
||||
import { ServerModel } from '../../models/server/server.js'
|
||||
import { MActorFollowActors } from '../../types/models/index.js'
|
||||
import { JobQueue } from '../job-queue/index.js'
|
||||
|
||||
async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transaction?: Transaction) {
|
||||
if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return
|
||||
|
||||
const follower = actorFollow.ActorFollower
|
||||
|
||||
if (follower.type === 'Application' && follower.preferredUsername === SERVER_ACTOR_NAME) {
|
||||
logger.info('Auto follow back %s.', follower.url)
|
||||
|
||||
const me = await getServerActor()
|
||||
|
||||
const server = await ServerModel.load(follower.serverId, transaction)
|
||||
const host = server.host
|
||||
|
||||
const payload = {
|
||||
host,
|
||||
name: SERVER_ACTOR_NAME,
|
||||
followerActorId: me.id,
|
||||
isAutoFollow: true
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
|
||||
}
|
||||
}
|
||||
|
||||
// If we only have an host, use a default account handle
|
||||
function getRemoteNameAndHost (handleOrHost: string) {
|
||||
let name = SERVER_ACTOR_NAME
|
||||
let host = handleOrHost
|
||||
|
||||
const splitted = handleOrHost.split('@')
|
||||
if (splitted.length === 2) {
|
||||
name = splitted[0]
|
||||
host = splitted[1]
|
||||
}
|
||||
|
||||
return { name, host }
|
||||
}
|
||||
|
||||
export {
|
||||
autoFollowBackIfNeeded,
|
||||
getRemoteNameAndHost
|
||||
}
|
||||
41
server/core/lib/activitypub/inbox-manager.ts
Normal file
41
server/core/lib/activitypub/inbox-manager.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import PQueue from 'p-queue'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants.js'
|
||||
import { MActorDefault, MActorSignature } from '@server/types/models/index.js'
|
||||
import { Activity } from '@peertube/peertube-models'
|
||||
import { StatsManager } from '../stat-manager.js'
|
||||
import { processActivities } from './process/index.js'
|
||||
|
||||
export class InboxManager {
|
||||
|
||||
private static instance: InboxManager
|
||||
private readonly inboxQueue: PQueue
|
||||
|
||||
private constructor () {
|
||||
this.inboxQueue = new PQueue({ concurrency: 1 })
|
||||
|
||||
setInterval(() => {
|
||||
StatsManager.Instance.updateInboxWaiting(this.getActivityPubMessagesWaiting())
|
||||
}, SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS)
|
||||
}
|
||||
|
||||
addInboxMessage (param: {
|
||||
activities: Activity[]
|
||||
signatureActor?: MActorSignature
|
||||
inboxActor?: MActorDefault
|
||||
}) {
|
||||
this.inboxQueue.add(() => {
|
||||
const options = { signatureActor: param.signatureActor, inboxActor: param.inboxActor }
|
||||
|
||||
return processActivities(param.activities, options)
|
||||
}).catch(err => logger.error('Error with inbox queue.', { err }))
|
||||
}
|
||||
|
||||
getActivityPubMessagesWaiting () {
|
||||
return this.inboxQueue.size + this.inboxQueue.pending
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
43
server/core/lib/activitypub/local-video-viewer.ts
Normal file
43
server/core/lib/activitypub/local-video-viewer.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
||||
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { WatchActionObject } from '@peertube/peertube-models'
|
||||
import { getDurationFromActivityStream } from './activity.js'
|
||||
|
||||
async function createOrUpdateLocalVideoViewer (watchAction: WatchActionObject, video: MVideo, t: Transaction) {
|
||||
const stats = await LocalVideoViewerModel.loadByUrl(watchAction.id)
|
||||
if (stats) await stats.destroy({ transaction: t })
|
||||
|
||||
const localVideoViewer = await LocalVideoViewerModel.create({
|
||||
url: watchAction.id,
|
||||
uuid: watchAction.uuid,
|
||||
|
||||
watchTime: getDurationFromActivityStream(watchAction.duration),
|
||||
|
||||
startDate: new Date(watchAction.startTime),
|
||||
endDate: new Date(watchAction.endTime),
|
||||
|
||||
country: watchAction.location?.addressCountry || null,
|
||||
subdivisionName: watchAction.location?.addressRegion || null,
|
||||
|
||||
videoId: video.id
|
||||
}, { transaction: t })
|
||||
|
||||
await LocalVideoViewerWatchSectionModel.bulkCreateSections({
|
||||
localVideoViewerId: localVideoViewer.id,
|
||||
|
||||
watchSections: watchAction.watchSections.map(s => ({
|
||||
start: s.startTimestamp,
|
||||
end: s.endTimestamp
|
||||
})),
|
||||
|
||||
transaction: t
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createOrUpdateLocalVideoViewer
|
||||
}
|
||||
24
server/core/lib/activitypub/outbox.ts
Normal file
24
server/core/lib/activitypub/outbox.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { JobQueue } from '../job-queue/index.js'
|
||||
|
||||
async function addFetchOutboxJob (actor: Pick<ActorModel, 'id' | 'outboxUrl'>) {
|
||||
// Don't fetch ourselves
|
||||
const serverActor = await getServerActor()
|
||||
if (serverActor.id === actor.id) {
|
||||
logger.error('Cannot fetch our own outbox!')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = {
|
||||
uri: actor.outboxUrl,
|
||||
type: 'activity' as 'activity'
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
|
||||
export {
|
||||
addFetchOutboxJob
|
||||
}
|
||||
44
server/core/lib/activitypub/player-settings.ts
Normal file
44
server/core/lib/activitypub/player-settings.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { PlayerSettingsObject } from '@peertube/peertube-models'
|
||||
import { sanitizeAndCheckPlayerSettingsObject } from '@server/helpers/custom-validators/activitypub/player-settings.js'
|
||||
import { MChannelDefault, MVideoIdUrl } from '../../types/models/index.js'
|
||||
import { upsertPlayerSettings } from '../player-settings.js'
|
||||
import { fetchAPObjectIfNeeded } from './activity.js'
|
||||
import { checkUrlsSameHost } from './url.js'
|
||||
|
||||
export async function upsertAPPlayerSettings (options: {
|
||||
video: MVideoIdUrl
|
||||
channel: MChannelDefault
|
||||
settingsObject: PlayerSettingsObject | string
|
||||
contextUrl: string
|
||||
}) {
|
||||
const { video, channel, contextUrl } = options
|
||||
|
||||
if (!video && !channel) throw new Error('Video or channel must be specified')
|
||||
|
||||
const settingsObject = await fetchAPObjectIfNeeded<PlayerSettingsObject>(options.settingsObject)
|
||||
|
||||
if (!sanitizeAndCheckPlayerSettingsObject(settingsObject, video ? 'video' : 'channel')) {
|
||||
throw new Error(`Player settings ${settingsObject.id} object is not valid`)
|
||||
}
|
||||
|
||||
if (!checkUrlsSameHost(settingsObject.id, contextUrl)) {
|
||||
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
|
||||
}
|
||||
|
||||
const objectUrl = video?.url || channel?.Actor.url
|
||||
if (!checkUrlsSameHost(settingsObject.id, objectUrl)) {
|
||||
throw new Error(`Player settings ${settingsObject.id} object is not on the same host as context URL ${contextUrl}`)
|
||||
}
|
||||
|
||||
await upsertPlayerSettings({ user: null, settings: getPlayerSettingsAttributesFromObject(settingsObject), channel, video })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPlayerSettingsAttributesFromObject (settingsObject: PlayerSettingsObject) {
|
||||
return {
|
||||
theme: settingsObject.theme
|
||||
}
|
||||
}
|
||||
189
server/core/lib/activitypub/playlists/create-update.ts
Normal file
189
server/core/lib/activitypub/playlists/create-update.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { HttpStatusCode, PlaylistObject } from '@peertube/peertube-models'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail.js'
|
||||
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import { MAccountHost, MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js'
|
||||
import Bluebird from 'bluebird'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/index.js'
|
||||
import { crawlCollectionPage } from '../crawl.js'
|
||||
import { checkUrlsSameHost } from '../url.js'
|
||||
import { getOrCreateAPVideo } from '../videos/index.js'
|
||||
import {
|
||||
fetchRemotePlaylistElement,
|
||||
fetchRemoteVideoPlaylist,
|
||||
playlistElementObjectToDBAttributes,
|
||||
playlistObjectToDBAttributes
|
||||
} from './shared/index.js'
|
||||
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist')
|
||||
|
||||
export async function createAccountPlaylists (playlistUrls: string[], account: MAccountHost) {
|
||||
logger.info(
|
||||
`Creating or updating ${playlistUrls.length} playlists for account ${account.Actor.preferredUsername}`,
|
||||
lTags()
|
||||
)
|
||||
|
||||
await Bluebird.map(playlistUrls, async playlistUrl => {
|
||||
if (!checkUrlsSameHost(playlistUrl, account.Actor.url)) {
|
||||
logger.warn(`Playlist ${playlistUrl} is not on the same host as owner account ${account.Actor.url}`, lTags(playlistUrl))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
|
||||
if (exists === true) return
|
||||
|
||||
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
|
||||
|
||||
if (playlistObject === undefined) {
|
||||
throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
|
||||
}
|
||||
|
||||
return createOrUpdateVideoPlaylist({ playlistObject, contextUrl: playlistUrl })
|
||||
} catch (err) {
|
||||
logger.warn(`Cannot create or update playlist ${playlistUrl}`, { err, ...lTags(playlistUrl) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
export async function createOrUpdateVideoPlaylist (options: {
|
||||
playlistObject: PlaylistObject
|
||||
// Which is the context where we retrieved the playlist
|
||||
// Can be the actor that signed the activity URL or the playlist URL we fetched
|
||||
contextUrl: string
|
||||
to?: string[]
|
||||
}) {
|
||||
const { playlistObject, contextUrl, to } = options
|
||||
|
||||
if (!checkUrlsSameHost(playlistObject.id, contextUrl)) {
|
||||
throw new Error(`Playlist ${playlistObject.id} is not on the same host as context URL ${contextUrl}`)
|
||||
}
|
||||
|
||||
logger.debug(`Creating or updating playlist ${playlistObject.id}`, lTags(playlistObject.id))
|
||||
|
||||
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
|
||||
|
||||
const channel = await getRemotePlaylistChannel(playlistObject)
|
||||
playlistAttributes.videoChannelId = channel.id
|
||||
playlistAttributes.ownerAccountId = channel.accountId
|
||||
|
||||
const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
|
||||
|
||||
const playlistElementUrls = await fetchElementUrls(playlistObject)
|
||||
|
||||
// Refetch playlist from DB since elements fetching could be long in time
|
||||
const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null)
|
||||
|
||||
await updatePlaylistThumbnail(playlistObject, playlist)
|
||||
|
||||
const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
|
||||
playlist.setVideosLength(elementsLength)
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getRemotePlaylistChannel (playlistObject: PlaylistObject) {
|
||||
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
|
||||
throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
|
||||
}
|
||||
|
||||
const channelUrl = getAPId(playlistObject.attributedTo[0])
|
||||
if (!checkUrlsSameHost(channelUrl, playlistObject.id)) {
|
||||
throw new Error(`Playlist ${playlistObject.id} and "attributedTo" channel ${channelUrl} are not on the same host`)
|
||||
}
|
||||
|
||||
const actor = await getOrCreateAPActor(channelUrl, 'all')
|
||||
|
||||
if (!actor.VideoChannel) {
|
||||
throw new Error(`Playlist ${playlistObject.id} "attributedTo" is not a video channel.`)
|
||||
}
|
||||
|
||||
return actor.VideoChannel
|
||||
}
|
||||
|
||||
async function fetchElementUrls (playlistObject: PlaylistObject) {
|
||||
let accItems: string[] = []
|
||||
await crawlCollectionPage<string>(playlistObject.id, items => {
|
||||
accItems = accItems.concat(items)
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
return accItems.filter(i => isActivityPubUrlValid(i))
|
||||
}
|
||||
|
||||
async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
|
||||
if (playlistObject.icon) {
|
||||
let thumbnailModel: MThumbnail
|
||||
|
||||
try {
|
||||
thumbnailModel = await updateRemotePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
|
||||
await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
|
||||
|
||||
if (thumbnailModel) await thumbnailModel.removeThumbnail()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Playlist does not have an icon, destroy existing one
|
||||
if (playlist.hasThumbnail()) {
|
||||
await playlist.Thumbnail.destroy()
|
||||
playlist.Thumbnail = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
|
||||
const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist)
|
||||
|
||||
await retryTransactionWrapper(() =>
|
||||
sequelizeTypescript.transaction(async t => {
|
||||
await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
|
||||
|
||||
for (const element of elementsToCreate) {
|
||||
await VideoPlaylistElementModel.create(element, { transaction: t })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
|
||||
|
||||
return elementsToCreate.length
|
||||
}
|
||||
|
||||
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
|
||||
const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
|
||||
|
||||
await Bluebird.map(elementUrls, async elementUrl => {
|
||||
try {
|
||||
const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
|
||||
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video-and-blacklist' })
|
||||
|
||||
elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
|
||||
} catch (err) {
|
||||
const logLevel = (err as PeerTubeRequestError).statusCode === HttpStatusCode.UNAUTHORIZED_401
|
||||
? 'debug'
|
||||
: 'warn'
|
||||
|
||||
logger.log(logLevel, `Cannot add playlist element ${elementUrl}`, { err, ...lTags(playlist.uuid, playlist.url) })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
|
||||
return elementsToCreate
|
||||
}
|
||||
26
server/core/lib/activitypub/playlists/get.ts
Normal file
26
server/core/lib/activitypub/playlists/get.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { MVideoPlaylistFullSummary } from '@server/types/models/index.js'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { createOrUpdateVideoPlaylist } from './create-update.js'
|
||||
import { scheduleRefreshIfNeeded } from './refresh.js'
|
||||
import { fetchRemoteVideoPlaylist } from './shared/index.js'
|
||||
|
||||
export async function getOrCreateAPVideoPlaylist (playlistUrl: string): Promise<MVideoPlaylistFullSummary> {
|
||||
const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
|
||||
|
||||
if (playlistFromDatabase) {
|
||||
scheduleRefreshIfNeeded(playlistFromDatabase)
|
||||
|
||||
return playlistFromDatabase
|
||||
}
|
||||
|
||||
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
|
||||
if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
|
||||
|
||||
// playlistUrl is just an alias/redirection, so process object id instead
|
||||
if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(getAPId(playlistObject))
|
||||
|
||||
const playlistCreated = await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: playlistUrl })
|
||||
|
||||
return playlistCreated
|
||||
}
|
||||
3
server/core/lib/activitypub/playlists/index.ts
Normal file
3
server/core/lib/activitypub/playlists/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './get.js'
|
||||
export * from './create-update.js'
|
||||
export * from './refresh.js'
|
||||
55
server/core/lib/activitypub/playlists/refresh.ts
Normal file
55
server/core/lib/activitypub/playlists/refresh.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { MVideoPlaylist, MVideoPlaylistOwnerDefault } from '@server/types/models/index.js'
|
||||
import { createOrUpdateVideoPlaylist } from './create-update.js'
|
||||
import { fetchRemoteVideoPlaylist } from './shared/index.js'
|
||||
|
||||
function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
|
||||
if (!playlist.isOutdated()) return
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
|
||||
}
|
||||
|
||||
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwnerDefault): Promise<MVideoPlaylistOwnerDefault> {
|
||||
if (!videoPlaylist.isOutdated()) return videoPlaylist
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url)
|
||||
|
||||
logger.info('Refreshing playlist %s.', videoPlaylist.url, lTags())
|
||||
|
||||
try {
|
||||
const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
|
||||
|
||||
if (playlistObject === undefined) {
|
||||
logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags())
|
||||
|
||||
await videoPlaylist.setAsRefreshed()
|
||||
return videoPlaylist
|
||||
}
|
||||
|
||||
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: videoPlaylist.url })
|
||||
|
||||
return videoPlaylist
|
||||
} catch (err) {
|
||||
const statusCode = (err as PeerTubeRequestError).statusCode
|
||||
|
||||
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.info('Cannot refresh not existing playlist (404/410 error code) %s. Deleting it.', videoPlaylist.url, lTags())
|
||||
|
||||
await videoPlaylist.destroy()
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() })
|
||||
|
||||
await videoPlaylist.setAsRefreshed()
|
||||
return videoPlaylist
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
refreshVideoPlaylistIfNeeded,
|
||||
scheduleRefreshIfNeeded
|
||||
}
|
||||
2
server/core/lib/activitypub/playlists/shared/index.ts
Normal file
2
server/core/lib/activitypub/playlists/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './url-to-object.js'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
|
||||
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
|
||||
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
|
||||
import { MVideoId, MVideoPlaylistId } from '@server/types/models/index.js'
|
||||
|
||||
export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
|
||||
const privacy = hasAPPublic(to)
|
||||
? VideoPlaylistPrivacy.PUBLIC
|
||||
: VideoPlaylistPrivacy.UNLISTED
|
||||
|
||||
return {
|
||||
name: playlistObject.name,
|
||||
description: playlistObject.content,
|
||||
privacy,
|
||||
url: playlistObject.id,
|
||||
uuid: playlistObject.uuid,
|
||||
videoChannelPosition: playlistObject.videoChannelPosition,
|
||||
videoChannelId: null,
|
||||
ownerAccountId: null,
|
||||
createdAt: new Date(playlistObject.published),
|
||||
updatedAt: new Date(playlistObject.updated)
|
||||
} as AttributesOnly<VideoPlaylistModel>
|
||||
}
|
||||
|
||||
export function playlistElementObjectToDBAttributes (
|
||||
elementObject: PlaylistElementObject,
|
||||
videoPlaylist: MVideoPlaylistId,
|
||||
video: MVideoId
|
||||
) {
|
||||
return {
|
||||
position: elementObject.position,
|
||||
url: elementObject.id,
|
||||
startTimestamp: elementObject.startTimestamp || null,
|
||||
stopTimestamp: elementObject.stopTimestamp || null,
|
||||
videoPlaylistId: videoPlaylist.id,
|
||||
videoId: video.id
|
||||
} as AttributesOnly<VideoPlaylistElementModel>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist.js'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PlaylistElementObject, PlaylistObject } from '@peertube/peertube-models'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl)
|
||||
|
||||
logger.info('Fetching remote playlist %s.', playlistUrl, lTags())
|
||||
|
||||
const { body, statusCode } = await fetchAP<any>(playlistUrl)
|
||||
|
||||
if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
|
||||
logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() })
|
||||
return { statusCode, playlistObject: undefined }
|
||||
}
|
||||
|
||||
if (!isArray(body.to)) {
|
||||
logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() })
|
||||
return { statusCode, playlistObject: undefined }
|
||||
}
|
||||
|
||||
return { statusCode, playlistObject: body }
|
||||
}
|
||||
|
||||
async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> {
|
||||
const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl)
|
||||
|
||||
logger.debug('Fetching remote playlist element %s.', elementUrl, lTags())
|
||||
|
||||
const { body, statusCode } = await fetchAP<PlaylistElementObject>(elementUrl)
|
||||
|
||||
if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`)
|
||||
|
||||
if (checkUrlsSameHost(body.id, elementUrl) !== true) {
|
||||
throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
|
||||
}
|
||||
|
||||
return { statusCode, elementObject: body }
|
||||
}
|
||||
|
||||
export {
|
||||
fetchRemoteVideoPlaylist,
|
||||
fetchRemotePlaylistElement
|
||||
}
|
||||
1
server/core/lib/activitypub/process/index.ts
Normal file
1
server/core/lib/activitypub/process/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './process.js'
|
||||
32
server/core/lib/activitypub/process/process-accept.ts
Normal file
32
server/core/lib/activitypub/process/process-accept.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ActivityAccept } from '@peertube/peertube-models'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorDefault, MActorSignature } from '../../../types/models/index.js'
|
||||
import { addFetchOutboxJob } from '../outbox.js'
|
||||
|
||||
async function processAcceptActivity (options: APProcessorOptions<ActivityAccept>) {
|
||||
const { byActor: targetActor, inboxActor } = options
|
||||
if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
|
||||
|
||||
return processAccept(inboxActor, targetActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processAcceptActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processAccept (actor: MActorDefault, targetActor: MActorSignature) {
|
||||
const follow = await ActorFollowModel.loadByActorAndTarget(actor.id, targetActor.id)
|
||||
if (!follow) throw new Error('Cannot find associated follow.')
|
||||
|
||||
if (follow.state !== 'accepted') {
|
||||
follow.state = 'accepted'
|
||||
await follow.save()
|
||||
|
||||
await addFetchOutboxJob(targetActor)
|
||||
}
|
||||
}
|
||||
65
server/core/lib/activitypub/process/process-announce.ts
Normal file
65
server/core/lib/activitypub/process/process-announce.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ActivityAnnounce } from '@peertube/peertube-models'
|
||||
import { getAPId } from '@server/lib/activitypub/activity.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { VideoShareModel } from '../../../models/video/video-share.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { Notifier } from '../../notifier/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processAnnounceActivity (options: APProcessorOptions<ActivityAnnounce>) {
|
||||
const { activity, byActor: actorAnnouncer } = options
|
||||
// Only notify if it is not from a fetcher job
|
||||
const notify = options.fromFetch !== true
|
||||
|
||||
// Announces by accounts are not supported
|
||||
if (actorAnnouncer.type !== 'Application' && actorAnnouncer.type !== 'Group') return
|
||||
|
||||
return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity, notify)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processAnnounceActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processVideoShare (actorAnnouncer: MActorSignature, activity: ActivityAnnounce, notify: boolean) {
|
||||
const objectUri = getAPId(activity.object)
|
||||
|
||||
const { video, created: videoCreated } = await maybeGetOrCreateAPVideo({ videoObject: objectUri })
|
||||
if (!video) return
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
// Add share entry
|
||||
|
||||
const share = {
|
||||
actorId: actorAnnouncer.id,
|
||||
videoId: video.id,
|
||||
url: activity.id
|
||||
}
|
||||
|
||||
const [ , created ] = await VideoShareModel.findOrCreate({
|
||||
where: {
|
||||
url: activity.id
|
||||
},
|
||||
defaults: share,
|
||||
transaction: t
|
||||
})
|
||||
|
||||
if (video.isLocal() && created === true) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ actorAnnouncer ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: t, followersException: exceptions, video })
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (videoCreated && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
|
||||
}
|
||||
193
server/core/lib/activitypub/process/process-create.ts
Normal file
193
server/core/lib/activitypub/process/process-create.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
AbuseObject,
|
||||
ActivityCreate,
|
||||
ActivityCreateObject,
|
||||
ActivityObject,
|
||||
CacheFileObject,
|
||||
PlaylistObject,
|
||||
VideoCommentObject,
|
||||
VideoObject,
|
||||
WatchActionObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
|
||||
import { isRedundancyAccepted } from '@server/lib/redundancy.js'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models/index.js'
|
||||
import { Notifier } from '../../notifier/index.js'
|
||||
import { fetchAPObjectIfNeeded } from '../activity.js'
|
||||
import { createOrUpdateCacheFile } from '../cache-file.js'
|
||||
import { createOrUpdateLocalVideoViewer } from '../local-video-viewer.js'
|
||||
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
|
||||
import { sendReplyApproval } from '../send/send-reply-approval.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { resolveThread } from '../video-comments.js'
|
||||
import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
// Only notify if it is not from a fetcher job
|
||||
const notify = options.fromFetch !== true
|
||||
const activityObject = await fetchAPObjectIfNeeded<Exclude<ActivityObject, AbuseObject>>(activity.object)
|
||||
const activityType = activityObject.type
|
||||
|
||||
if (activityType === 'Video') {
|
||||
return processCreateVideo(activityObject, notify)
|
||||
}
|
||||
|
||||
if (activityType === 'Note') {
|
||||
// Comments will be fetched from videos
|
||||
if (options.fromFetch) return
|
||||
|
||||
return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, options.fromFetch)
|
||||
}
|
||||
|
||||
if (activityType === 'WatchAction') {
|
||||
return retryTransactionWrapper(processCreateWatchAction, activityObject)
|
||||
}
|
||||
|
||||
if (activityType === 'CacheFile') {
|
||||
return retryTransactionWrapper(processCreateCacheFile, activity, activityObject, byActor)
|
||||
}
|
||||
|
||||
if (activityType === 'Playlist') {
|
||||
return retryTransactionWrapper(processCreatePlaylist, activity, activityObject, byActor)
|
||||
}
|
||||
|
||||
logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processCreateActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processCreateVideo (videoToCreateData: VideoObject, notify: boolean) {
|
||||
const syncParam = { rates: false, shares: false, comments: false, refreshVideo: false }
|
||||
const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam })
|
||||
|
||||
if (created && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
|
||||
|
||||
return video
|
||||
}
|
||||
|
||||
async function processCreateCacheFile (
|
||||
activity: ActivityCreate<CacheFileObject | string>,
|
||||
cacheFile: CacheFileObject,
|
||||
byActor: MActorSignature
|
||||
) {
|
||||
if (await isRedundancyAccepted(activity, byActor) !== true) return
|
||||
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
|
||||
|
||||
if (video.isLocal() && !canVideoBeFederated(video)) {
|
||||
logger.warn(`Do not process create cache file ${cacheFile.object} on a video that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
return createOrUpdateCacheFile(cacheFile, video, byActor, t)
|
||||
})
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
async function processCreateWatchAction (watchAction: WatchActionObject) {
|
||||
if (watchAction.actionStatus !== 'CompletedActionStatus') return
|
||||
|
||||
const video = await VideoModel.loadByUrl(watchAction.object)
|
||||
if (video.remote) return
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
return createOrUpdateLocalVideoViewer(watchAction, video, t)
|
||||
})
|
||||
}
|
||||
|
||||
async function processCreateVideoComment (
|
||||
activity: ActivityCreate<VideoCommentObject | string>,
|
||||
commentObject: VideoCommentObject,
|
||||
byActor: MActorSignature,
|
||||
fromFetch: false
|
||||
) {
|
||||
if (CONFIG.VIDEO_COMMENTS.ACCEPT_REMOTE_COMMENTS !== true) return
|
||||
|
||||
if (fromFetch) throw new Error('Processing create video comment from fetch is not supported')
|
||||
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url)
|
||||
|
||||
let video: MVideoAccountLightBlacklistAllFiles
|
||||
let created: boolean
|
||||
let comment: MCommentOwnerVideo
|
||||
|
||||
try {
|
||||
const resolveThreadResult = await resolveThread({ url: commentObject.id, isVideo: false })
|
||||
if (!resolveThreadResult) return // Comment not accepted
|
||||
|
||||
video = resolveThreadResult.video
|
||||
created = resolveThreadResult.commentCreated
|
||||
comment = resolveThreadResult.comment
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
'Cannot process video comment because we could not resolve thread %s. Maybe it was not a video thread, so skip it.',
|
||||
commentObject.inReplyTo,
|
||||
{ err }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to not forward unwanted comments on our videos
|
||||
if (video.isLocal()) {
|
||||
if (!canVideoBeFederated(video)) {
|
||||
logger.info('Skip comment forward on non federated video' + video.url)
|
||||
return
|
||||
}
|
||||
|
||||
if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) {
|
||||
logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url)
|
||||
return
|
||||
}
|
||||
|
||||
// New non-moderated comment -> auto approve reply
|
||||
if (comment.heldForReview === false && created) {
|
||||
const reply = await VideoCommentModel.loadById(comment.inReplyToCommentId)
|
||||
sendReplyApproval(Object.assign(comment, { InReplyToVideoComment: reply }), 'ApproveReply')
|
||||
}
|
||||
|
||||
// New comment or re-sent after an approval -> forward comment
|
||||
if (comment.heldForReview === false && (created || commentObject.replyApproval)) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
if (created) Notifier.Instance.notifyOnNewComment(comment)
|
||||
}
|
||||
|
||||
async function processCreatePlaylist (
|
||||
activity: ActivityCreate<PlaylistObject | string>,
|
||||
playlistObject: PlaylistObject,
|
||||
byActor: MActorSignature
|
||||
) {
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
|
||||
|
||||
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: arrayify(activity.to) })
|
||||
}
|
||||
155
server/core/lib/activitypub/process/process-delete.ts
Normal file
155
server/core/lib/activitypub/process/process-delete.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ActivityDelete } from '@peertube/peertube-models'
|
||||
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import {
|
||||
MAccountActor,
|
||||
MActor,
|
||||
MActorFull,
|
||||
MActorSignature,
|
||||
MChannelAccountActor,
|
||||
MChannelActor,
|
||||
MCommentOwnerVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
|
||||
async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id
|
||||
|
||||
if (activity.actor === objectUrl) {
|
||||
// We need more attributes (all the account and channel)
|
||||
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
|
||||
if (!byActorFull) return
|
||||
|
||||
if (isAccountActor(byActorFull.type)) {
|
||||
if (!byActorFull.Account) throw new Error(`Actor ${byActorFull.url} is a person but we cannot find the account in database.`)
|
||||
|
||||
const accountToDelete = byActorFull.Account as MAccountActor
|
||||
accountToDelete.Actor = byActorFull
|
||||
|
||||
return retryTransactionWrapper(processDeleteAccount, accountToDelete)
|
||||
} else if (isChannelActor(byActorFull.type)) {
|
||||
if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
|
||||
|
||||
const channelToDelete = byActorFull.VideoChannel as MChannelAccountActor & { Actor: MActorFull }
|
||||
channelToDelete.Actor = byActorFull
|
||||
return retryTransactionWrapper(processDeleteVideoChannel, channelToDelete)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(objectUrl)
|
||||
if (videoCommentInstance) {
|
||||
return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const videoInstance = await VideoModel.loadByUrlAndPopulateAccountAndFiles(objectUrl)
|
||||
if (videoInstance) {
|
||||
if (videoInstance.isLocal()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`)
|
||||
|
||||
return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const videoPlaylist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(objectUrl)
|
||||
if (videoPlaylist) {
|
||||
if (videoPlaylist.isLocal()) throw new Error(`Remote instance cannot delete owned playlist ${videoPlaylist.url}.`)
|
||||
|
||||
return retryTransactionWrapper(processDeleteVideoPlaylist, byActor, videoPlaylist)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processDeleteActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processDeleteVideo (actor: MActor, videoToDelete: VideoModel) {
|
||||
logger.debug('Removing remote video "%s".', videoToDelete.uuid)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
if (videoToDelete.VideoChannel.Account.Actor.id !== actor.id) {
|
||||
throw new Error('Account ' + actor.url + ' does not own video channel ' + videoToDelete.VideoChannel.Actor.url)
|
||||
}
|
||||
|
||||
await videoToDelete.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote video with uuid %s removed.', videoToDelete.uuid)
|
||||
}
|
||||
|
||||
async function processDeleteVideoPlaylist (actor: MActor, playlistToDelete: VideoPlaylistModel) {
|
||||
logger.debug('Removing remote video playlist "%s".', playlistToDelete.uuid)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
if (playlistToDelete.OwnerAccount.Actor.id !== actor.id) {
|
||||
throw new Error('Account ' + actor.url + ' does not own video playlist ' + playlistToDelete.url)
|
||||
}
|
||||
|
||||
await playlistToDelete.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote video playlist with uuid %s removed.', playlistToDelete.uuid)
|
||||
}
|
||||
|
||||
async function processDeleteAccount (accountToRemove: MAccountActor) {
|
||||
logger.debug('Removing remote account "%s".', accountToRemove.Actor.url)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await accountToRemove.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote account %s removed.', accountToRemove.Actor.url)
|
||||
}
|
||||
|
||||
async function processDeleteVideoChannel (videoChannelToRemove: MChannelActor) {
|
||||
logger.debug('Removing remote video channel "%s".', videoChannelToRemove.Actor.url)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await videoChannelToRemove.destroy({ transaction: t })
|
||||
})
|
||||
|
||||
logger.info('Remote video channel %s removed.', videoChannelToRemove.Actor.url)
|
||||
}
|
||||
|
||||
function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) {
|
||||
// Already deleted
|
||||
if (videoComment.isDeleted()) return Promise.resolve()
|
||||
|
||||
logger.debug('Removing remote video comment "%s".', videoComment.url)
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
if (byActor.Account.id !== videoComment.Account.id && byActor.Account.id !== videoComment.Video.VideoChannel.accountId) {
|
||||
throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
|
||||
}
|
||||
|
||||
videoComment.markAsDeleted()
|
||||
|
||||
await videoComment.save({ transaction: t })
|
||||
|
||||
if (videoComment.Video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity({ activity, transaction: t, followersException: exceptions, video: videoComment.Video })
|
||||
}
|
||||
|
||||
logger.info('Remote video comment %s removed.', videoComment.url)
|
||||
})
|
||||
}
|
||||
62
server/core/lib/activitypub/process/process-dislike.ts
Normal file
62
server/core/lib/activitypub/process/process-dislike.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ActivityDislike } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) {
|
||||
const { activity, byActor } = options
|
||||
return retryTransactionWrapper(processDislike, activity, byActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processDislikeActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processDislike (activity: ActivityDislike, byActor: MActorSignature) {
|
||||
const videoUrl = activity.object
|
||||
const byAccount = byActor.Account
|
||||
|
||||
if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
if (!canVideoBeFederated(onlyVideo)) {
|
||||
logger.warn(`Do not process dislike on video ${videoUrl} that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
|
||||
const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
|
||||
if (existingRate?.type === 'dislike') return
|
||||
|
||||
await video.increment('dislikes', { transaction: t })
|
||||
video.dislikes++
|
||||
|
||||
if (existingRate?.type === 'like') {
|
||||
await video.decrement('likes', { transaction: t })
|
||||
video.likes--
|
||||
}
|
||||
|
||||
const rate = existingRate || new AccountVideoRateModel()
|
||||
rate.type = 'dislike'
|
||||
rate.videoId = video.id
|
||||
rate.accountId = byAccount.id
|
||||
rate.url = activity.id
|
||||
|
||||
await rate.save({ transaction: t })
|
||||
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
103
server/core/lib/activitypub/process/process-flag.ts
Normal file
103
server/core/lib/activitypub/process/process-flag.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { abusePredefinedReasonsMap } from '@peertube/peertube-core-utils'
|
||||
import { AbuseState, ActivityFlag } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { getAPId } from '../../../lib/activitypub/activity.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models/index.js'
|
||||
|
||||
async function processFlagActivity (options: APProcessorOptions<ActivityFlag>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
return retryTransactionWrapper(processCreateAbuse, activity, byActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processFlagActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature) {
|
||||
const account = byActor.Account
|
||||
if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url)
|
||||
|
||||
const reporterAccount = await AccountModel.load(account.id)
|
||||
|
||||
const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ]
|
||||
|
||||
const tags = Array.isArray(flag.tag) ? flag.tag : []
|
||||
const predefinedReasons = tags.map(tag => abusePredefinedReasonsMap[tag.name])
|
||||
.filter(v => !isNaN(v))
|
||||
|
||||
const startAt = flag.startAt
|
||||
const endAt = flag.endAt
|
||||
|
||||
for (const object of objects) {
|
||||
try {
|
||||
const uri = getAPId(object)
|
||||
|
||||
logger.debug('Reporting remote abuse for object %s.', uri)
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccountAndFiles(uri, t)
|
||||
let videoComment: MCommentOwnerVideo
|
||||
let flaggedAccount: MAccountDefault
|
||||
|
||||
if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(uri, t)
|
||||
if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri, t)
|
||||
|
||||
if (!video && !videoComment && !flaggedAccount) {
|
||||
logger.warn('Cannot flag unknown entity %s.', object)
|
||||
return
|
||||
}
|
||||
|
||||
const baseAbuse = {
|
||||
reporterAccountId: reporterAccount.id,
|
||||
reason: flag.content,
|
||||
state: AbuseState.PENDING,
|
||||
predefinedReasons
|
||||
}
|
||||
|
||||
if (video) {
|
||||
return createVideoAbuse({
|
||||
baseAbuse,
|
||||
startAt,
|
||||
endAt,
|
||||
reporterAccount,
|
||||
transaction: t,
|
||||
videoInstance: video,
|
||||
skipNotification: false
|
||||
})
|
||||
}
|
||||
|
||||
if (videoComment) {
|
||||
return createVideoCommentAbuse({
|
||||
baseAbuse,
|
||||
reporterAccount,
|
||||
transaction: t,
|
||||
commentInstance: videoComment,
|
||||
skipNotification: false
|
||||
})
|
||||
}
|
||||
|
||||
return await createAccountAbuse({
|
||||
baseAbuse,
|
||||
reporterAccount,
|
||||
transaction: t,
|
||||
accountInstance: flaggedAccount,
|
||||
skipNotification: false
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
logger.debug('Cannot process report of %s', getAPId(object), { err })
|
||||
}
|
||||
}
|
||||
}
|
||||
164
server/core/lib/activitypub/process/process-follow.ts
Normal file
164
server/core/lib/activitypub/process/process-follow.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityFollow } from '@peertube/peertube-models'
|
||||
import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { CONFIG } from '../../../initializers/config.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { getAPId } from '../../../lib/activitypub/activity.js'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorFollow, MActorFull, MActorId, MActorSignature } from '../../../types/models/index.js'
|
||||
import { Notifier } from '../../notifier/index.js'
|
||||
import { autoFollowBackIfNeeded } from '../follow.js'
|
||||
import { sendAccept, sendReject } from '../send/index.js'
|
||||
|
||||
async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
const activityId = activity.id
|
||||
const objectId = getAPId(activity.object)
|
||||
|
||||
return retryTransactionWrapper(processFollow, byActor, activityId, objectId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processFollowActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processFollow (byActor: MActorSignature, activityId: string, targetActorURL: string) {
|
||||
const { actorFollow, created, targetActor } = await sequelizeTypescript.transaction(async t => {
|
||||
const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
|
||||
|
||||
if (!targetActor) throw new Error('Unknown actor')
|
||||
if (targetActor.isLocal() === false) throw new Error('This is not a local actor.')
|
||||
|
||||
if (await rejectIfInstanceFollowDisabled(byActor, activityId, targetActor)) return { actorFollow: undefined }
|
||||
if (await rejectIfMuted(byActor, activityId, targetActor)) return { actorFollow: undefined }
|
||||
|
||||
const [ actorFollow, created ] = await ActorFollowModel.findOrCreateCustom({
|
||||
byActor,
|
||||
targetActor,
|
||||
activityId,
|
||||
state: await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
|
||||
? 'pending'
|
||||
: 'accepted',
|
||||
transaction: t
|
||||
})
|
||||
|
||||
if (rejectIfAlreadyRejected(actorFollow, byActor, activityId, targetActor)) return { actorFollow: undefined }
|
||||
|
||||
await acceptIfNeeded(actorFollow, targetActor, t)
|
||||
|
||||
await fixFollowURLIfNeeded(actorFollow, activityId, t)
|
||||
|
||||
actorFollow.ActorFollower = byActor
|
||||
actorFollow.ActorFollowing = targetActor
|
||||
|
||||
// Target sends to actor he accepted the follow request
|
||||
if (actorFollow.state === 'accepted') {
|
||||
sendAccept(actorFollow)
|
||||
|
||||
await autoFollowBackIfNeeded(actorFollow, t)
|
||||
}
|
||||
|
||||
return { actorFollow, created, targetActor }
|
||||
})
|
||||
|
||||
// Rejected
|
||||
if (!actorFollow) return
|
||||
|
||||
if (created) {
|
||||
const follower = await ActorModel.loadFull(byActor.id)
|
||||
const actorFollowFull = Object.assign(actorFollow, { ActorFollowing: targetActor, ActorFollower: follower })
|
||||
|
||||
if (await isFollowingInstance(targetActor)) {
|
||||
Notifier.Instance.notifyOfNewInstanceFollow(actorFollowFull)
|
||||
} else {
|
||||
Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Actor %s is followed by actor %s.', targetActorURL, byActor.url)
|
||||
}
|
||||
|
||||
async function rejectIfInstanceFollowDisabled (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
|
||||
if (await isFollowingInstance(targetActor)) {
|
||||
if (CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
|
||||
logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
} else if (CONFIG.FOLLOWERS.CHANNELS.ENABLED === false) {
|
||||
logger.info('Rejecting %s because channel followers are disabled.', targetActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function rejectIfMuted (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
|
||||
const followerAccount = await AccountModel.load(byActor.Account.id)
|
||||
const followingAccountId = targetActor.Account
|
||||
|
||||
if (followerAccount && await isBlockedByServerOrAccount(followerAccount, followingAccountId)) {
|
||||
logger.info('Rejecting %s because follower is muted.', byActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function rejectIfAlreadyRejected (actorFollow: MActorFollow, byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
|
||||
// Already rejected
|
||||
if (actorFollow.state === 'rejected') {
|
||||
logger.info('Rejecting %s because follow is already rejected.', byActor.url)
|
||||
|
||||
sendReject(activityId, byActor, targetActor)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function acceptIfNeeded (actorFollow: MActorFollow, targetActor: MActorFull, transaction: Transaction) {
|
||||
// Set the follow as accepted if the remote actor follows a channel or account
|
||||
// Or if the instance automatically accepts followers
|
||||
if (actorFollow.state === 'accepted') return
|
||||
if (!await isFollowingInstance(targetActor)) return
|
||||
if (CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL === true && await isFollowingInstance(targetActor)) return
|
||||
|
||||
actorFollow.state = 'accepted'
|
||||
|
||||
await actorFollow.save({ transaction })
|
||||
}
|
||||
|
||||
async function fixFollowURLIfNeeded (actorFollow: MActorFollow, activityId: string, transaction: Transaction) {
|
||||
// Before PeerTube V3 we did not save the follow ID. Try to fix these old follows
|
||||
if (!actorFollow.url) {
|
||||
actorFollow.url = activityId
|
||||
await actorFollow.save({ transaction })
|
||||
}
|
||||
}
|
||||
|
||||
async function isFollowingInstance (targetActor: MActorId) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
return targetActor.id === serverActor.id
|
||||
}
|
||||
64
server/core/lib/activitypub/process/process-like.ts
Normal file
64
server/core/lib/activitypub/process/process-like.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ActivityLike } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { getAPId } from '../../../lib/activitypub/activity.js'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processLikeActivity (options: APProcessorOptions<ActivityLike>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
return retryTransactionWrapper(processLikeVideo, byActor, activity)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processLikeActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processLikeVideo (byActor: MActorSignature, activity: ActivityLike) {
|
||||
const videoUrl = getAPId(activity.object)
|
||||
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
if (!canVideoBeFederated(onlyVideo)) {
|
||||
logger.warn(`Do not process like on video ${videoUrl} that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
|
||||
const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
|
||||
if (existingRate?.type === 'like') return
|
||||
|
||||
if (existingRate?.type === 'dislike') {
|
||||
await video.decrement('dislikes', { transaction: t })
|
||||
video.dislikes--
|
||||
}
|
||||
|
||||
await video.increment('likes', { transaction: t })
|
||||
video.likes++
|
||||
|
||||
const rate = existingRate || new AccountVideoRateModel()
|
||||
rate.type = 'like'
|
||||
rate.videoId = video.id
|
||||
rate.accountId = byAccount.id
|
||||
rate.url = activity.id
|
||||
|
||||
await rate.save({ transaction: t })
|
||||
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
33
server/core/lib/activitypub/process/process-reject.ts
Normal file
33
server/core/lib/activitypub/process/process-reject.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ActivityReject } from '@peertube/peertube-models'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActor } from '../../../types/models/index.js'
|
||||
|
||||
async function processRejectActivity (options: APProcessorOptions<ActivityReject>) {
|
||||
const { byActor: targetActor, inboxActor } = options
|
||||
if (inboxActor === undefined) throw new Error('Need to reject on explicit inbox.')
|
||||
|
||||
return processReject(inboxActor, targetActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processRejectActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processReject (follower: MActor, targetActor: MActor) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, targetActor.id, t)
|
||||
|
||||
if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${targetActor.id}.`)
|
||||
|
||||
actorFollow.state = 'rejected'
|
||||
await actorFollow.save({ transaction: t })
|
||||
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ActivityApproveReply, ActivityRejectReply, ActivityType } from '@peertube/peertube-models'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
|
||||
import { sendCreateVideoCommentIfNeeded } from '../send/send-create.js'
|
||||
|
||||
export function processReplyApprovalFactory (type: Extract<ActivityType, 'ApproveReply' | 'RejectReply'>) {
|
||||
return async (options: APProcessorOptions<ActivityApproveReply | ActivityRejectReply>) => {
|
||||
if (type === 'RejectReply') return // Not yet implemented
|
||||
|
||||
const { activity, byActor } = options
|
||||
const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(activity.object)
|
||||
|
||||
if (!comment || comment.isDeleted()) {
|
||||
throw new Error(`Cannot process reply approval on comment ${comment.url} that doesn't exist`)
|
||||
}
|
||||
|
||||
if (comment.isLocal() !== true) {
|
||||
throw new Error(`Cannot process reply approval on non-owned comment ${comment.url}`)
|
||||
}
|
||||
|
||||
if (byActor.id !== comment.Video.VideoChannel.Account.Actor.id) {
|
||||
throw new Error(`Cannot process reply approval on ${comment.url} by non video owner`)
|
||||
}
|
||||
|
||||
return processApproveReply(activity.id, comment)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processApproveReply (replyApproval: string, comment: MCommentOwnerVideoReply) {
|
||||
if (comment.heldForReview === false || comment.replyApproval === replyApproval) return
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
comment.heldForReview = false
|
||||
comment.replyApproval = replyApproval
|
||||
await comment.save({ transaction: t })
|
||||
|
||||
await sendCreateVideoCommentIfNeeded(comment, t)
|
||||
})
|
||||
}
|
||||
181
server/core/lib/activitypub/process/process-undo.ts
Normal file
181
server/core/lib/activitypub/process/process-undo.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
ActivityAnnounce,
|
||||
ActivityCreate,
|
||||
ActivityDislike,
|
||||
ActivityFollow,
|
||||
ActivityLike,
|
||||
ActivityUndo,
|
||||
ActivityUndoObject,
|
||||
CacheFileObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
|
||||
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy.js'
|
||||
import { VideoShareModel } from '../../../models/video/video-share.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { fetchAPObjectIfNeeded } from '../activity.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { federateVideoIfNeeded, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processUndoActivity (options: APProcessorOptions<ActivityUndo<ActivityUndoObject>>) {
|
||||
const { activity, byActor } = options
|
||||
const activityToUndo = activity.object
|
||||
|
||||
if (activityToUndo.type === 'Like') {
|
||||
return retryTransactionWrapper(processUndoLike, byActor, activity)
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Create') {
|
||||
const objectToUndo = await fetchAPObjectIfNeeded<CacheFileObject>(activityToUndo.object)
|
||||
|
||||
if (objectToUndo.type === 'CacheFile') {
|
||||
return retryTransactionWrapper(processUndoCacheFile, byActor, activity, objectToUndo)
|
||||
}
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Dislike') {
|
||||
return retryTransactionWrapper(processUndoDislike, byActor, activity)
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Follow') {
|
||||
return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
|
||||
}
|
||||
|
||||
if (activityToUndo.type === 'Announce') {
|
||||
return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo)
|
||||
}
|
||||
|
||||
logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processUndoActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo<ActivityLike>) {
|
||||
const likeActivity = activity.object
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: likeActivity.object })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
|
||||
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, likeActivity.id, t)
|
||||
if (rate?.type !== 'like') {
|
||||
logger.warn('Unknown like by account %d for video %d.', byActor.Account.id, video.id)
|
||||
return
|
||||
}
|
||||
|
||||
await rate.destroy({ transaction: t })
|
||||
await video.decrement('likes', { transaction: t })
|
||||
|
||||
video.likes--
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
|
||||
async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo<ActivityDislike>) {
|
||||
const dislikeActivity = activity.object
|
||||
|
||||
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: dislikeActivity.object })
|
||||
if (!onlyVideo?.isLocal()) return
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
|
||||
|
||||
const video = await VideoModel.loadFull(onlyVideo.id, t)
|
||||
const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislikeActivity.id, t)
|
||||
if (rate?.type !== 'dislike') {
|
||||
logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id)
|
||||
return
|
||||
}
|
||||
|
||||
await rate.destroy({ transaction: t })
|
||||
await video.decrement('dislikes', { transaction: t })
|
||||
video.dislikes--
|
||||
|
||||
await federateVideoIfNeeded(video, false, t)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUndoCacheFile (
|
||||
byActor: MActorSignature,
|
||||
activity: ActivityUndo<ActivityCreate<CacheFileObject>>,
|
||||
cacheFileObject: CacheFileObject
|
||||
) {
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
|
||||
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
|
||||
if (!cacheFile) {
|
||||
logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
|
||||
|
||||
await cacheFile.destroy({ transaction: t })
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: t, followersException: exceptions, video })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function processUndoAnnounce (byActor: MActorSignature, announceActivity: ActivityAnnounce) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
|
||||
if (!share) {
|
||||
logger.warn(`Unknown video share ${announceActivity.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (share.actorId !== byActor.id) throw new Error(`${share.url} is not shared by ${byActor.url}.`)
|
||||
|
||||
await share.destroy({ transaction: t })
|
||||
|
||||
if (share.Video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity: announceActivity, transaction: t, followersException: exceptions, video: share.Video })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function processUndoFollow (follower: MActorSignature, followActivity: ActivityFollow) {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
|
||||
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
|
||||
|
||||
if (!actorFollow) {
|
||||
logger.warn('Unknown actor follow %d -> %d.', follower.id, following.id)
|
||||
return
|
||||
}
|
||||
|
||||
await actorFollow.destroy({ transaction: t })
|
||||
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
170
server/core/lib/activitypub/process/process-update.ts
Normal file
170
server/core/lib/activitypub/process/process-update.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ActivityPubActor,
|
||||
ActivityPubActorType,
|
||||
ActivityUpdate,
|
||||
ActivityUpdateObject,
|
||||
CacheFileObject,
|
||||
PlayerSettingsObject,
|
||||
PlaylistObject,
|
||||
VideoObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { isActorTypeValid } from '@server/helpers/custom-validators/activitypub/actor.js'
|
||||
import { isRedundancyAccepted } from '@server/lib/redundancy.js'
|
||||
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file.js'
|
||||
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos.js'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { sequelizeTypescript } from '../../../initializers/database.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorFull, MActorSignature } from '../../../types/models/index.js'
|
||||
import { fetchAPObjectIfNeeded } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/get.js'
|
||||
import { APActorUpdater } from '../actors/updater.js'
|
||||
import { createOrUpdateCacheFile } from '../cache-file.js'
|
||||
import { upsertAPPlayerSettings } from '../player-settings.js'
|
||||
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
const object = await fetchAPObjectIfNeeded(activity.object)
|
||||
const objectType = object.type
|
||||
|
||||
if (objectType === 'Video') {
|
||||
return retryTransactionWrapper(processUpdateVideo, activity)
|
||||
}
|
||||
|
||||
if (isActorTypeValid(objectType as ActivityPubActorType)) {
|
||||
// We need more attributes
|
||||
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
|
||||
return retryTransactionWrapper(processUpdateActor, byActorFull, object)
|
||||
}
|
||||
|
||||
if (objectType === 'CacheFile') {
|
||||
// We need more attributes
|
||||
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
|
||||
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity, object)
|
||||
}
|
||||
|
||||
if (objectType === 'Playlist') {
|
||||
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
|
||||
}
|
||||
|
||||
if (objectType === 'PlayerSettings') {
|
||||
return retryTransactionWrapper(processUpdatePlayerSettings, byActor, object)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processUpdateActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUpdateVideo (activity: ActivityUpdate<VideoObject | string>) {
|
||||
const videoObject = activity.object as VideoObject
|
||||
|
||||
if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
|
||||
logger.debug('Video sent by update is not valid.', { videoObject })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { video, created } = await getOrCreateAPVideo({
|
||||
videoObject: videoObject.id,
|
||||
allowRefresh: false,
|
||||
fetchType: 'all'
|
||||
})
|
||||
// We did not have this video, it has been created so no need to update
|
||||
if (created) return
|
||||
|
||||
const updater = new APVideoUpdater(videoObject, video)
|
||||
return updater.update(arrayify(activity.to))
|
||||
}
|
||||
|
||||
async function processUpdateCacheFile (
|
||||
byActor: MActorSignature,
|
||||
activity: ActivityUpdate<CacheFileObject | string>,
|
||||
cacheFileObject: CacheFileObject
|
||||
) {
|
||||
if (await isRedundancyAccepted(activity, byActor) !== true) return
|
||||
|
||||
if (!isCacheFileObjectValid(cacheFileObject)) {
|
||||
logger.debug('Cache file object sent by update is not valid.', { cacheFileObject })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
|
||||
|
||||
if (video.isLocal() && !canVideoBeFederated(video)) {
|
||||
logger.warn(`Do not process update cache file on video ${activity.object} that cannot be federated`)
|
||||
return
|
||||
}
|
||||
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
await createOrUpdateCacheFile(cacheFileObject, video, byActor, t)
|
||||
})
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
|
||||
logger.debug(`Updating remote account "${actorObject.id}".`)
|
||||
|
||||
const updater = new APActorUpdater(actorObject, actor)
|
||||
return updater.update()
|
||||
}
|
||||
|
||||
async function processUpdatePlaylist (
|
||||
byActor: MActorSignature,
|
||||
activity: ActivityUpdate<PlaylistObject | string>,
|
||||
playlistObject: PlaylistObject
|
||||
) {
|
||||
const byAccount = byActor.Account
|
||||
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
|
||||
|
||||
await createOrUpdateVideoPlaylist({ playlistObject, contextUrl: byActor.url, to: arrayify(activity.to) })
|
||||
}
|
||||
|
||||
async function processUpdatePlayerSettings (
|
||||
byActor: MActorSignature,
|
||||
settingsObject: PlayerSettingsObject
|
||||
) {
|
||||
let actor: MActorFull
|
||||
|
||||
const { video } = await maybeGetOrCreateAPVideo({ videoObject: settingsObject.object })
|
||||
|
||||
if (!video) {
|
||||
try {
|
||||
actor = await getOrCreateAPActor(settingsObject.object, 'all')
|
||||
} catch {
|
||||
actor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!video && !actor?.VideoChannel) {
|
||||
logger.warn(`Do not process update player settings on unknown video/channel`)
|
||||
return
|
||||
}
|
||||
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject,
|
||||
contextUrl: byActor.url,
|
||||
video,
|
||||
channel: actor
|
||||
? Object.assign(actor.VideoChannel, { Actor: actor })
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
57
server/core/lib/activitypub/process/process-view.ts
Normal file
57
server/core/lib/activitypub/process/process-view.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ActivityView } from '@peertube/peertube-models'
|
||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorSignature } from '../../../types/models/index.js'
|
||||
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
|
||||
import { getOrCreateAPVideo } from '../videos/index.js'
|
||||
|
||||
async function processViewActivity (options: APProcessorOptions<ActivityView>) {
|
||||
const { activity, byActor } = options
|
||||
|
||||
return processCreateView(activity, byActor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processViewActivity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processCreateView (activity: ActivityView, byActor: MActorSignature) {
|
||||
const videoObject = activity.object
|
||||
|
||||
const { video } = await getOrCreateAPVideo({
|
||||
videoObject,
|
||||
fetchType: 'only-video-and-blacklist',
|
||||
allowRefresh: false
|
||||
})
|
||||
|
||||
await VideoViewsManager.Instance.processRemoteView({
|
||||
video,
|
||||
viewerId: activity.id,
|
||||
|
||||
viewerExpires: activity.expires
|
||||
? new Date(activity.expires)
|
||||
: undefined,
|
||||
viewerResultCounter: getViewerResultCounter(activity)
|
||||
})
|
||||
|
||||
if (video.isLocal()) {
|
||||
// Forward the view but don't resend the activity to the sender
|
||||
const exceptions = [ byActor ]
|
||||
await forwardVideoRelatedActivity({ activity, transaction: undefined, followersException: exceptions, video })
|
||||
}
|
||||
}
|
||||
|
||||
function getViewerResultCounter (activity: ActivityView) {
|
||||
const result = activity.result
|
||||
|
||||
if (!activity.expires || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined
|
||||
|
||||
const counter = parseInt(result.userInteractionCount + '')
|
||||
if (isNaN(counter)) return undefined
|
||||
|
||||
return counter
|
||||
}
|
||||
91
server/core/lib/activitypub/process/process.ts
Normal file
91
server/core/lib/activitypub/process/process.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Activity, ActivityType } from '@peertube/peertube-models'
|
||||
import { StatsManager } from '@server/lib/stat-manager.js'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
|
||||
import { MActorDefault, MActorSignature } from '../../../types/models/index.js'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { getOrCreateAPActor } from '../actors/index.js'
|
||||
import { checkUrlsSameHost } from '../url.js'
|
||||
import { processAcceptActivity } from './process-accept.js'
|
||||
import { processAnnounceActivity } from './process-announce.js'
|
||||
import { processCreateActivity } from './process-create.js'
|
||||
import { processDeleteActivity } from './process-delete.js'
|
||||
import { processDislikeActivity } from './process-dislike.js'
|
||||
import { processFlagActivity } from './process-flag.js'
|
||||
import { processFollowActivity } from './process-follow.js'
|
||||
import { processLikeActivity } from './process-like.js'
|
||||
import { processRejectActivity } from './process-reject.js'
|
||||
import { processReplyApprovalFactory } from './process-reply-approval.js'
|
||||
import { processUndoActivity } from './process-undo.js'
|
||||
import { processUpdateActivity } from './process-update.js'
|
||||
import { processViewActivity } from './process-view.js'
|
||||
|
||||
const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Activity>) => Promise<any> } = {
|
||||
Create: processCreateActivity,
|
||||
Update: processUpdateActivity,
|
||||
Delete: processDeleteActivity,
|
||||
Follow: processFollowActivity,
|
||||
Accept: processAcceptActivity,
|
||||
Reject: processRejectActivity,
|
||||
Announce: processAnnounceActivity,
|
||||
Undo: processUndoActivity,
|
||||
Like: processLikeActivity,
|
||||
Dislike: processDislikeActivity,
|
||||
Flag: processFlagActivity,
|
||||
View: processViewActivity,
|
||||
ApproveReply: processReplyApprovalFactory('ApproveReply'),
|
||||
RejectReply: processReplyApprovalFactory('RejectReply')
|
||||
}
|
||||
|
||||
export async function processActivities (
|
||||
activities: Activity[],
|
||||
options: {
|
||||
signatureActor?: MActorSignature
|
||||
inboxActor?: MActorDefault
|
||||
outboxUrl?: string
|
||||
fromFetch?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { outboxUrl, signatureActor, inboxActor, fromFetch = false } = options
|
||||
|
||||
const actorsCache: { [ url: string ]: MActorSignature } = {}
|
||||
|
||||
for (const activity of activities) {
|
||||
if (!signatureActor && [ 'Create', 'Announce', 'Like' ].includes(activity.type) === false) {
|
||||
logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
|
||||
continue
|
||||
}
|
||||
|
||||
const actorUrl = getAPId(activity.actor)
|
||||
|
||||
// When we fetch remote data, we don't have signature
|
||||
if (signatureActor && actorUrl !== signatureActor.url) {
|
||||
logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, signatureActor.url)
|
||||
continue
|
||||
}
|
||||
|
||||
if (outboxUrl && checkUrlsSameHost(outboxUrl, actorUrl) !== true) {
|
||||
logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', outboxUrl, actorUrl)
|
||||
continue
|
||||
}
|
||||
|
||||
const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl)
|
||||
actorsCache[actorUrl] = byActor
|
||||
|
||||
const activityProcessor = processActivity[activity.type]
|
||||
if (activityProcessor === undefined) {
|
||||
logger.warn('Unknown activity type %s.', activity.type, { activityId: activity.id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await activityProcessor({ activity, byActor, inboxActor, fromFetch })
|
||||
|
||||
StatsManager.Instance.addInboxProcessedSuccess(activity.type)
|
||||
} catch (err) {
|
||||
logger.warn('Cannot process activity %s.', activity.type, { err })
|
||||
|
||||
StatsManager.Instance.addInboxProcessedError(activity.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
server/core/lib/activitypub/send/http.ts
Normal file
67
server/core/lib/activitypub/send/http.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ContextType } from '@peertube/peertube-models'
|
||||
import { signAndContextify } from '@server/helpers/activity-pub-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@server/initializers/constants.js'
|
||||
import { buildDigestFromWorker, signJsonLDObjectFromWorker } from '@server/lib/worker/parent-process.js'
|
||||
import { ActorReservedModel } from '@server/models/actor/actor-reserved.js'
|
||||
import { ActorModel } from '@server/models/actor/actor.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { getContextFilter } from '../context.js'
|
||||
|
||||
type Payload<T> = { body: T, contextType: ContextType, signatureActorId?: number }
|
||||
|
||||
export async function computeBody<T> (
|
||||
payload: Payload<T>
|
||||
): Promise<T | T & { type: 'RsaSignature2017', creator: string, created: string }> {
|
||||
let body = payload.body
|
||||
|
||||
if (payload.signatureActorId) {
|
||||
const actorSignature = await loadActorForSignature(payload.signatureActorId)
|
||||
|
||||
try {
|
||||
body = await signAndContextify({
|
||||
byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey },
|
||||
data: payload.body,
|
||||
contextType: payload.contextType,
|
||||
contextFilter: getContextFilter(),
|
||||
signerFunction: signJsonLDObjectFromWorker
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Cannot sign and contextify body', { body, err })
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
export async function buildGlobalHTTPHeaders (body: any) {
|
||||
return {
|
||||
'digest': await buildDigestFromWorker(body),
|
||||
'content-type': 'application/activity+json',
|
||||
'accept': ACTIVITY_PUB.ACCEPT_HEADER
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSignedRequestOptions (options: {
|
||||
signatureActorId?: number
|
||||
hasPayload: boolean
|
||||
}) {
|
||||
const actor = options.signatureActorId
|
||||
? await loadActorForSignature(options.signatureActorId)
|
||||
: await getServerActor() // We need to sign the request, so use the server
|
||||
|
||||
return {
|
||||
keyId: ActorModel.getPublicKeyUrl(actor.url),
|
||||
key: actor.privateKey,
|
||||
headers: options.hasPayload
|
||||
? HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD
|
||||
: HTTP_SIGNATURE.HEADERS_TO_SIGN_WITHOUT_PAYLOAD
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActorForSignature (actorId: number) {
|
||||
const actor = await ActorModel.load(actorId) ?? await ActorReservedModel.loadByActorId(actorId)
|
||||
if (!actor) throw new Error('Unknown signature actor id.')
|
||||
|
||||
return actor
|
||||
}
|
||||
11
server/core/lib/activitypub/send/index.ts
Normal file
11
server/core/lib/activitypub/send/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from './http.js'
|
||||
export * from './send-accept.js'
|
||||
export * from './send-announce.js'
|
||||
export * from './send-create.js'
|
||||
export * from './send-delete.js'
|
||||
export * from './send-follow.js'
|
||||
export * from './send-like.js'
|
||||
export * from './send-reject.js'
|
||||
export * from './send-reply-approval.js'
|
||||
export * from './send-undo.js'
|
||||
export * from './send-update.js'
|
||||
47
server/core/lib/activitypub/send/send-accept.ts
Normal file
47
server/core/lib/activitypub/send/send-accept.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ActivityAccept, ActivityFollow } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorFollowActors } from '../../../types/models/index.js'
|
||||
import { getLocalActorFollowAcceptActivityPubUrl } from '../url.js'
|
||||
import { buildFollowActivity } from './send-follow.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendAccept (actorFollow: MActorFollowActors) {
|
||||
const follower = actorFollow.ActorFollower
|
||||
const me = actorFollow.ActorFollowing
|
||||
|
||||
if (!follower.serverId) { // This should never happen
|
||||
logger.warn('Do not sending accept to local follower.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Creating job to accept follower %s.', follower.url)
|
||||
|
||||
const followData = buildFollowActivity(actorFollow.url, follower, me)
|
||||
|
||||
const url = getLocalActorFollowAcceptActivityPubUrl(actorFollow)
|
||||
const data = buildAcceptActivity(url, me, followData)
|
||||
|
||||
return unicastTo({
|
||||
data,
|
||||
byActor: me,
|
||||
toActorUrl: follower.inboxUrl,
|
||||
contextType: 'Accept'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendAccept
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildAcceptActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityAccept {
|
||||
return {
|
||||
type: 'Accept',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: followActivityData
|
||||
}
|
||||
}
|
||||
47
server/core/lib/activitypub/send/send-announce.ts
Normal file
47
server/core/lib/activitypub/send/send-announce.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ActivityAnnounce, ActivityAudience } from '@peertube/peertube-models'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActorLight, MVideo } from '../../../types/models/index.js'
|
||||
import { MVideoShare } from '../../../types/models/video/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { broadcastToFollowers, getActorsInvolvedInVideo } from './shared/send-utils.js'
|
||||
|
||||
export async function sendVideoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
|
||||
const activity = buildAnnounceWithVideoAudience(byActor, videoShare, video)
|
||||
|
||||
logger.info('Creating job to send announce %s.', videoShare.url)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: await getActorsInvolvedInVideo(video, transaction),
|
||||
transaction,
|
||||
actorsException: [ byActor ],
|
||||
contextType: 'Announce'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAnnounceWithVideoAudience (
|
||||
byActor: MActorLight,
|
||||
videoShare: MVideoShare,
|
||||
video: MVideo
|
||||
) {
|
||||
const announcedObject = video.url
|
||||
|
||||
const audience = getPublicAudience(byActor)
|
||||
|
||||
const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
|
||||
|
||||
return activity
|
||||
}
|
||||
|
||||
export function buildAnnounceActivity (url: string, byActor: MActorLight, object: string, audience?: ActivityAudience): ActivityAnnounce {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify({
|
||||
type: 'Announce' as 'Announce',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
}, audience)
|
||||
}
|
||||
226
server/core/lib/activitypub/send/send-create.ts
Normal file
226
server/core/lib/activitypub/send/send-create.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
ActivityAudience,
|
||||
ActivityCreate,
|
||||
ActivityCreateObject,
|
||||
ContextType,
|
||||
VideoCommentObject,
|
||||
VideoPlaylistPrivacy
|
||||
} from '@peertube/peertube-models'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||
import {
|
||||
MActorLight,
|
||||
MCommentOwnerVideoReply,
|
||||
MLocalVideoViewerWithWatchSections,
|
||||
MVideoAP,
|
||||
MVideoAccountLight,
|
||||
MVideoPlaylistFull,
|
||||
MVideoRedundancyStreamingPlaylistVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getCommentAudience, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../audience.js'
|
||||
import { canVideoBeFederated } from '../videos/federate.js'
|
||||
import {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
sendVideoRelatedActivity,
|
||||
sendVideoRelatedActivityToOrigin,
|
||||
unicastTo
|
||||
} from './shared/send-utils.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'create')
|
||||
|
||||
export async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
|
||||
if (!canVideoBeFederated(video)) return undefined
|
||||
|
||||
logger.info('Creating job to send video creation of %s.', video.url, lTags(video.uuid))
|
||||
|
||||
const byActor = video.VideoChannel.Account.Actor
|
||||
const videoObject = await video.toActivityPubObject()
|
||||
|
||||
const audience = getVideoAudience({ account: video.VideoChannel.Account, channel: video.VideoChannel, privacy: video.privacy })
|
||||
const createActivity = buildCreateActivity(video.url, byActor, videoObject, audience)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf: [ byActor ],
|
||||
transaction,
|
||||
contextType: 'Video'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendCreateCacheFile (
|
||||
byActor: MActorLight,
|
||||
video: MVideoAccountLight,
|
||||
fileRedundancy: MVideoRedundancyStreamingPlaylistVideo
|
||||
) {
|
||||
logger.info('Creating job to send file cache of %s.', fileRedundancy.url, lTags(video.uuid))
|
||||
|
||||
return sendVideoRelatedCreateActivity({
|
||||
byActor,
|
||||
video,
|
||||
url: fileRedundancy.url,
|
||||
object: fileRedundancy.toActivityPubObject(),
|
||||
contextType: 'CacheFile'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
|
||||
logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
|
||||
|
||||
const byActor = await getServerActor()
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
|
||||
}
|
||||
|
||||
export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
|
||||
if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
|
||||
|
||||
logger.info('Creating job to send create video playlist of %s.', playlist.url, lTags(playlist.uuid))
|
||||
|
||||
const byActor = playlist.OwnerAccount.Actor
|
||||
const audience = getPlaylistAudience(byActor, playlist.privacy)
|
||||
|
||||
const object = await playlist.toActivityPubObject(null, transaction)
|
||||
const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
transaction,
|
||||
contextType: 'Playlist'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendCreateVideoCommentIfNeeded (comment: MCommentOwnerVideoReply, transaction: Transaction) {
|
||||
if (comment.Video.isLocal()) {
|
||||
const videoWithBlacklist = await VideoModel.loadWithBlacklist(comment.Video.id)
|
||||
|
||||
if (!canVideoBeFederated(videoWithBlacklist)) {
|
||||
logger.debug(`Do not send comment ${comment.url} on a video that cannot be federated`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (comment.heldForReview) {
|
||||
logger.debug(`Do not send comment ${comment.url} that requires approval`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Creating job to send comment %s.', comment.url)
|
||||
|
||||
const byActor = comment.Account.Actor
|
||||
const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
|
||||
|
||||
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction })
|
||||
const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
|
||||
|
||||
const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
|
||||
.map(c => c.Account.Actor)
|
||||
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccount(comment.Video.url, transaction)
|
||||
const audience = getCommentAudience({ comment, video, threadParentComments })
|
||||
|
||||
const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience)
|
||||
|
||||
// This was a reply, send it to the parent actors
|
||||
const actorsException = [ byActor ]
|
||||
await broadcastToActors({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toActors: parentsCommentActors,
|
||||
transaction,
|
||||
actorsException,
|
||||
contextType: 'Comment'
|
||||
})
|
||||
|
||||
// Broadcast to our followers
|
||||
await broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf: [ byActor ],
|
||||
transaction,
|
||||
contextType: 'Comment'
|
||||
})
|
||||
|
||||
// Send to actors involved in the comment
|
||||
if (comment.Video.isLocal()) {
|
||||
const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
|
||||
// Add the actor that commented too
|
||||
actorsInvolvedInComment.push(byActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInComment,
|
||||
transaction,
|
||||
actorsException,
|
||||
contextType: 'Comment'
|
||||
})
|
||||
}
|
||||
|
||||
// Send to origin
|
||||
return transaction.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: createActivity,
|
||||
byActor,
|
||||
toActorUrl: videoAccount.Actor.getSharedInbox(),
|
||||
contextType: 'Comment'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function buildCreateActivity<T extends ActivityCreateObject> (
|
||||
url: string,
|
||||
byActor: MActorLight,
|
||||
object: T,
|
||||
audience?: ActivityAudience
|
||||
): ActivityCreate<T> {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Create' as 'Create',
|
||||
id: url + '/activity',
|
||||
actor: byActor.url,
|
||||
object: typeof object === 'string'
|
||||
? object
|
||||
: audiencify(object, audience)
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendVideoRelatedCreateActivity (options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoAccountLight
|
||||
url: string
|
||||
object: any
|
||||
contextType: ContextType
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
return buildCreateActivity(options.url, options.byActor, options.object, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, options)
|
||||
}
|
||||
181
server/core/lib/activitypub/send/send-delete.ts
Normal file
181
server/core/lib/activitypub/send/send-delete.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
|
||||
import { AccountModel } from '@server/models/account/account.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { ActorModel } from '../../../models/actor/actor.js'
|
||||
import { VideoCommentModel } from '../../../models/video/video-comment.js'
|
||||
import { VideoShareModel } from '../../../models/video/video-share.js'
|
||||
import { MActorUrl } from '../../../types/models/index.js'
|
||||
import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../types/models/video/index.js'
|
||||
import { audiencify, getCommentAudience } from '../audience.js'
|
||||
import { getDeleteActivityPubUrl } from '../url.js'
|
||||
import {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
sendVideoRelatedActivity,
|
||||
unicastTo
|
||||
} from './shared/send-utils.js'
|
||||
|
||||
async function sendDeleteVideo (options: {
|
||||
video: MVideoAccountLight
|
||||
transaction: Transaction
|
||||
deleteForPrivacyChange?: boolean // default false
|
||||
}) {
|
||||
const { video, transaction, deleteForPrivacyChange = false } = options
|
||||
|
||||
logger.info('Creating job to broadcast delete of video %s.', video.url)
|
||||
|
||||
const byActor = video.VideoChannel.Account.Actor
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getDeleteActivityPubUrl(video.url)
|
||||
|
||||
return buildDeleteActivity(url, video.url, byActor, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, {
|
||||
byActor,
|
||||
video,
|
||||
contextType: 'Delete',
|
||||
transaction,
|
||||
skipPrivacyCheck: deleteForPrivacyChange
|
||||
})
|
||||
}
|
||||
|
||||
async function sendDeleteActor (byActor: ActorModel, transaction: Transaction) {
|
||||
logger.info('Creating job to broadcast delete of actor %s.', byActor.url)
|
||||
|
||||
const url = getDeleteActivityPubUrl(byActor.url)
|
||||
const activity = buildDeleteActivity(url, byActor.url, byActor)
|
||||
|
||||
const actorsInvolved = await VideoShareModel.listActorsWhoSharedVideosOf({ actorOwnerId: byActor.id, transaction })
|
||||
|
||||
// In case the actor did not have any videos
|
||||
const serverActor = await getServerActor()
|
||||
actorsInvolved.push(serverActor)
|
||||
|
||||
actorsInvolved.push(byActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolved,
|
||||
contextType: 'Delete',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
async function sendDeleteVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to send delete of comment %s.', comment.url)
|
||||
|
||||
const isVideoOrigin = comment.Video.isLocal()
|
||||
|
||||
const url = getDeleteActivityPubUrl(comment.url)
|
||||
|
||||
const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
|
||||
|
||||
const byActor = comment.isLocal()
|
||||
? comment.Account.Actor
|
||||
: videoAccount.Actor
|
||||
|
||||
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction })
|
||||
const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
|
||||
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccount(comment.Video.url, transaction)
|
||||
const audience = getCommentAudience({ video, comment, threadParentComments: threadParentCommentsFiltered })
|
||||
const activity = buildDeleteActivity(url, comment.url, byActor, audience)
|
||||
|
||||
// This was a reply, send it to the parent actors
|
||||
const actorsException = [ byActor ]
|
||||
await broadcastToActors({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActors: threadParentCommentsFiltered.map(c => c.Account.Actor),
|
||||
transaction,
|
||||
contextType: 'Delete',
|
||||
actorsException
|
||||
})
|
||||
|
||||
// Broadcast to our followers
|
||||
await broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: [ byActor ],
|
||||
contextType: 'Delete',
|
||||
transaction
|
||||
})
|
||||
|
||||
// Send to actors involved in the comment
|
||||
if (isVideoOrigin) {
|
||||
const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
|
||||
actorsInvolvedInComment.push(byActor) // Add the actor that commented the video
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInComment,
|
||||
transaction,
|
||||
contextType: 'Delete',
|
||||
actorsException
|
||||
})
|
||||
}
|
||||
|
||||
// Send to origin
|
||||
return transaction.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActorUrl: videoAccount.Actor.getSharedInbox(),
|
||||
contextType: 'Delete'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary, transaction: Transaction) {
|
||||
logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
|
||||
|
||||
const byActor = videoPlaylist.OwnerAccount.Actor
|
||||
|
||||
const url = getDeleteActivityPubUrl(videoPlaylist.url)
|
||||
const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
contextType: 'Delete',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendDeleteActor,
|
||||
sendDeleteVideo,
|
||||
sendDeleteVideoComment,
|
||||
sendDeleteVideoPlaylist
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDeleteActivity (url: string, object: string, byActor: MActorUrl, audience?: ActivityAudience): ActivityDelete {
|
||||
const activity = {
|
||||
type: 'Delete' as 'Delete',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
}
|
||||
|
||||
if (audience) return audiencify(activity, audience)
|
||||
|
||||
return activity
|
||||
}
|
||||
40
server/core/lib/activitypub/send/send-dislike.ts
Normal file
40
server/core/lib/activitypub/send/send-dislike.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityDislike } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getVideoDislikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { sendVideoRelatedActivityToOrigin } from './shared/send-utils.js'
|
||||
|
||||
function sendDislike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
|
||||
logger.info('Creating job to dislike %s.', video.url)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
|
||||
|
||||
return buildDislikeActivity(url, byActor, video, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
|
||||
}
|
||||
|
||||
function buildDislikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityDislike {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
id: url,
|
||||
type: 'Dislike' as 'Dislike',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendDislike,
|
||||
buildDislikeActivity
|
||||
}
|
||||
42
server/core/lib/activitypub/send/send-flag.ts
Normal file
42
server/core/lib/activitypub/send/send-flag.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityFlag } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MAbuseAP, MAccountLight, MActor } from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getLocalAbuseActivityPubUrl } from '../url.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
|
||||
if (!flaggedAccount.Actor.serverId) return // Local user
|
||||
|
||||
const url = getLocalAbuseActivityPubUrl(abuse)
|
||||
|
||||
logger.info('Creating job to send abuse %s.', url)
|
||||
|
||||
// Custom audience, we only send the abuse to the origin instance
|
||||
const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
|
||||
const flagActivity = buildFlagActivity(url, byActor, abuse, audience)
|
||||
|
||||
return t.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: flagActivity,
|
||||
byActor,
|
||||
toActorUrl: flaggedAccount.Actor.getSharedInbox(),
|
||||
contextType: 'Flag'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
const activity = { id: url, actor: byActor.url, ...abuse.toActivityPubObject() }
|
||||
|
||||
return audiencify(activity, audience)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendAbuse
|
||||
}
|
||||
37
server/core/lib/activitypub/send/send-follow.ts
Normal file
37
server/core/lib/activitypub/send/send-follow.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityFollow } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorFollowActors } from '../../../types/models/index.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendFollow (actorFollow: MActorFollowActors, t: Transaction) {
|
||||
const me = actorFollow.ActorFollower
|
||||
const following = actorFollow.ActorFollowing
|
||||
|
||||
// Same server as ours
|
||||
if (!following.serverId) return
|
||||
|
||||
logger.info('Creating job to send follow request to %s.', following.url)
|
||||
|
||||
const data = buildFollowActivity(actorFollow.url, me, following)
|
||||
|
||||
return t.afterCommit(() => {
|
||||
return unicastTo({ data, byActor: me, toActorUrl: following.inboxUrl, contextType: 'Follow' })
|
||||
})
|
||||
}
|
||||
|
||||
function buildFollowActivity (url: string, byActor: MActor, targetActor: MActor): ActivityFollow {
|
||||
return {
|
||||
type: 'Follow',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: targetActor.url
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendFollow,
|
||||
buildFollowActivity
|
||||
}
|
||||
40
server/core/lib/activitypub/send/send-like.ts
Normal file
40
server/core/lib/activitypub/send/send-like.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { ActivityAudience, ActivityLike } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { sendVideoRelatedActivityToOrigin } from './shared/send-utils.js'
|
||||
|
||||
function sendLike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
|
||||
logger.info('Creating job to like %s.', video.url)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
|
||||
|
||||
return buildLikeActivity(url, byActor, video, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
|
||||
}
|
||||
|
||||
function buildLikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityLike {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
id: url,
|
||||
type: 'Like' as 'Like',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendLike,
|
||||
buildLikeActivity
|
||||
}
|
||||
39
server/core/lib/activitypub/send/send-reject.ts
Normal file
39
server/core/lib/activitypub/send/send-reject.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ActivityFollow, ActivityReject } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MActor } from '../../../types/models/index.js'
|
||||
import { getLocalActorFollowRejectActivityPubUrl } from '../url.js'
|
||||
import { buildFollowActivity } from './send-follow.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
function sendReject (followUrl: string, follower: MActor, following: MActor) {
|
||||
if (!follower.serverId) { // This should never happen
|
||||
logger.warn('Do not sending reject to local follower.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Creating job to reject follower %s.', follower.url)
|
||||
|
||||
const followData = buildFollowActivity(followUrl, follower, following)
|
||||
|
||||
const url = getLocalActorFollowRejectActivityPubUrl()
|
||||
const data = buildRejectActivity(url, following, followData)
|
||||
|
||||
return unicastTo({ data, byActor: following, toActorUrl: follower.inboxUrl, contextType: 'Reject' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendReject
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRejectActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityReject {
|
||||
return {
|
||||
type: 'Reject',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: followActivityData
|
||||
}
|
||||
}
|
||||
36
server/core/lib/activitypub/send/send-reply-approval.ts
Normal file
36
server/core/lib/activitypub/send/send-reply-approval.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ActivityApproveReply, ActivityRejectReply } from '@peertube/peertube-models'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
|
||||
import { getLocalApproveReplyActivityPubUrl } from '../url.js'
|
||||
import { unicastTo } from './shared/send-utils.js'
|
||||
|
||||
// We can support type: 'RejectReply' in the future
|
||||
export function sendReplyApproval (comment: MCommentOwnerVideoReply, type: 'ApproveReply') {
|
||||
logger.info('Creating job to approve reply %s.', comment.url)
|
||||
|
||||
const data = buildApprovalActivity({ comment, type })
|
||||
|
||||
return unicastTo({
|
||||
data,
|
||||
byActor: comment.Video.VideoChannel.Account.Actor,
|
||||
toActorUrl: comment.Account.Actor.inboxUrl,
|
||||
contextType: type
|
||||
})
|
||||
}
|
||||
|
||||
export function buildApprovalActivity (options: {
|
||||
comment: MCommentOwnerVideoReply
|
||||
type: 'ApproveReply'
|
||||
}): ActivityApproveReply | ActivityRejectReply {
|
||||
const { comment, type } = options
|
||||
|
||||
return {
|
||||
type,
|
||||
id: type === 'ApproveReply'
|
||||
? getLocalApproveReplyActivityPubUrl(comment.Video, comment)
|
||||
: undefined, // 'RejectReply' Not implemented yet
|
||||
actor: comment.Video.VideoChannel.Account.Actor.url,
|
||||
inReplyTo: comment.InReplyToVideoComment?.url ?? comment.Video.url,
|
||||
object: comment.url
|
||||
}
|
||||
}
|
||||
178
server/core/lib/activitypub/send/send-undo.ts
Normal file
178
server/core/lib/activitypub/send/send-undo.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@peertube/peertube-models'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
import {
|
||||
MActor,
|
||||
MActorAudience,
|
||||
MActorFollowActors,
|
||||
MActorLight,
|
||||
MVideo,
|
||||
MVideoAccountLight,
|
||||
MVideoRedundancyVideo,
|
||||
MVideoShare
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getUndoActivityPubUrl, getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
|
||||
import { buildAnnounceWithVideoAudience } from './send-announce.js'
|
||||
import { buildCreateActivity } from './send-create.js'
|
||||
import { buildDislikeActivity } from './send-dislike.js'
|
||||
import { buildFollowActivity } from './send-follow.js'
|
||||
import { buildLikeActivity } from './send-like.js'
|
||||
import {
|
||||
broadcastToFollowers,
|
||||
getActorsInvolvedInVideo,
|
||||
sendVideoRelatedActivity,
|
||||
sendVideoRelatedActivityToOrigin,
|
||||
unicastTo
|
||||
} from './shared/send-utils.js'
|
||||
|
||||
function sendUndoFollow (actorFollow: MActorFollowActors, t: Transaction) {
|
||||
const me = actorFollow.ActorFollower
|
||||
const following = actorFollow.ActorFollowing
|
||||
|
||||
// Same server as ours
|
||||
if (!following.serverId) return
|
||||
|
||||
logger.info('Creating job to send an unfollow request to %s.', following.url)
|
||||
|
||||
const undoUrl = getUndoActivityPubUrl(actorFollow.url)
|
||||
|
||||
const followActivity = buildFollowActivity(actorFollow.url, me, following)
|
||||
const undoActivity = undoActivityData(undoUrl, me, followActivity)
|
||||
|
||||
t.afterCommit(() => {
|
||||
return unicastTo({
|
||||
data: undoActivity,
|
||||
byActor: me,
|
||||
toActorUrl: following.inboxUrl,
|
||||
contextType: 'Follow'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendUndoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to undo announce %s.', videoShare.url)
|
||||
|
||||
const undoUrl = getUndoActivityPubUrl(videoShare.url)
|
||||
|
||||
const announce = buildAnnounceWithVideoAudience(byActor, videoShare, video)
|
||||
const undoActivity = undoActivityData(undoUrl, byActor, announce)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: undoActivity,
|
||||
byActor,
|
||||
toFollowersOf: await getActorsInvolvedInVideo(video, transaction),
|
||||
transaction,
|
||||
actorsException: [ byActor ],
|
||||
contextType: 'Announce'
|
||||
})
|
||||
}
|
||||
|
||||
async function sendUndoCacheFile (byActor: MActor, redundancyModel: MVideoRedundancyVideo, transaction: Transaction) {
|
||||
logger.info('Creating job to undo cache file %s.', redundancyModel.url)
|
||||
|
||||
const associatedVideo = redundancyModel.getVideo()
|
||||
if (!associatedVideo) {
|
||||
logger.warn('Cannot send undo activity for redundancy %s: no video files associated.', redundancyModel.url)
|
||||
return
|
||||
}
|
||||
|
||||
const video = await VideoModel.loadFull(associatedVideo.id)
|
||||
const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
|
||||
|
||||
return sendUndoVideoRelatedActivity({
|
||||
byActor,
|
||||
video,
|
||||
url: redundancyModel.url,
|
||||
activity: createActivity,
|
||||
contextType: 'CacheFile',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendUndoLike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
|
||||
logger.info('Creating job to undo a like of video %s.', video.url)
|
||||
|
||||
const likeUrl = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
|
||||
const likeActivity = buildLikeActivity(likeUrl, byActor, video)
|
||||
|
||||
return sendUndoVideoRateToOriginActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t })
|
||||
}
|
||||
|
||||
async function sendUndoDislike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
|
||||
logger.info('Creating job to undo a dislike of video %s.', video.url)
|
||||
|
||||
const dislikeUrl = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
|
||||
const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
|
||||
|
||||
return sendUndoVideoRateToOriginActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendUndoAnnounce,
|
||||
sendUndoCacheFile,
|
||||
sendUndoDislike,
|
||||
sendUndoFollow,
|
||||
sendUndoLike
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function undoActivityData<T extends ActivityUndoObject> (
|
||||
url: string,
|
||||
byActor: MActorAudience,
|
||||
object: T,
|
||||
audience?: ActivityAudience
|
||||
): ActivityUndo<T> {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Undo' as 'Undo',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
async function sendUndoVideoRelatedActivity (options: {
|
||||
byActor: MActor
|
||||
video: MVideoAccountLight
|
||||
url: string
|
||||
activity: ActivityUndoObject
|
||||
contextType: ContextType
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const undoUrl = getUndoActivityPubUrl(options.url)
|
||||
|
||||
return undoActivityData(undoUrl, options.byActor, options.activity, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, options)
|
||||
}
|
||||
|
||||
async function sendUndoVideoRateToOriginActivity (options: {
|
||||
byActor: MActor
|
||||
video: MVideoAccountLight
|
||||
url: string
|
||||
activity: ActivityLike | ActivityDislike
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const undoUrl = getUndoActivityPubUrl(options.url)
|
||||
|
||||
return undoActivityData(undoUrl, options.byActor, options.activity, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, { ...options, contextType: 'Rate' })
|
||||
}
|
||||
204
server/core/lib/activitypub/send/send-update.ts
Normal file
204
server/core/lib/activitypub/send/send-update.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
|
||||
import { MPlayerSetting } from '@server/types/models/video/player-setting.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { AccountModel } from '../../../models/account/account.js'
|
||||
import { VideoShareModel } from '../../../models/video/video-share.js'
|
||||
import { VideoModel } from '../../../models/video/video.js'
|
||||
import {
|
||||
MAccountDefault,
|
||||
MActor,
|
||||
MActorLight,
|
||||
MChannelDefault,
|
||||
MVideoAPLight,
|
||||
MVideoFullLight,
|
||||
MVideoPlaylistFull,
|
||||
MVideoRedundancyVideo
|
||||
} from '../../../types/models/index.js'
|
||||
import { audiencify, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../audience.js'
|
||||
import { getLocalChannelPlayerSettingsActivityPubUrl, getLocalVideoPlayerSettingsActivityPubUrl, getUpdateActivityPubUrl } from '../url.js'
|
||||
import { canVideoBeFederated } from '../videos/federate.js'
|
||||
import { broadcastToFollowers, getActorsInvolvedInVideo, sendVideoRelatedActivity } from './shared/send-utils.js'
|
||||
|
||||
export async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
|
||||
if (!canVideoBeFederated(videoArg)) return undefined
|
||||
|
||||
const video = await videoArg.lightAPToFullAP(transaction)
|
||||
|
||||
logger.info('Creating job to update video %s.', video.url)
|
||||
|
||||
const byActor = overriddenByActor || video.VideoChannel.Account.Actor
|
||||
|
||||
const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
|
||||
|
||||
const videoObject = await video.toActivityPubObject()
|
||||
const audience = getVideoAudience({ account: video.VideoChannel.Account, channel: video.VideoChannel, privacy: video.privacy })
|
||||
|
||||
const updateActivity = buildUpdateActivity(url, byActor, videoObject, audience)
|
||||
|
||||
const actorsInvolved = await getActorsInvolvedInVideo(video, transaction)
|
||||
if (overriddenByActor) actorsInvolved.push(overriddenByActor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolved,
|
||||
contextType: 'Video',
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) {
|
||||
const byActor = accountOrChannel.Actor
|
||||
|
||||
logger.info('Creating job to update actor %s.', byActor.url)
|
||||
|
||||
const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
|
||||
const accountOrChannelObject = await (accountOrChannel as any).toActivityPubObject() // FIXME: typescript bug?
|
||||
const audience = getPublicAudience(byActor)
|
||||
const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: await getToFollowersOfForActor(accountOrChannel, transaction),
|
||||
transaction,
|
||||
contextType: 'Actor'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) {
|
||||
logger.info('Creating job to update cache file %s.', redundancyModel.url)
|
||||
|
||||
const associatedVideo = redundancyModel.getVideo()
|
||||
if (!associatedVideo) {
|
||||
logger.warn('Cannot send update activity for redundancy %s: no video files associated.', redundancyModel.url)
|
||||
return
|
||||
}
|
||||
|
||||
const video = await VideoModel.loadFull(associatedVideo.id)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const redundancyObject = redundancyModel.toActivityPubObject()
|
||||
const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
|
||||
|
||||
return buildUpdateActivity(url, byActor, redundancyObject, audience)
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'CacheFile' })
|
||||
}
|
||||
|
||||
export async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) {
|
||||
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
|
||||
|
||||
const byActor = videoPlaylist.OwnerAccount.Actor
|
||||
|
||||
logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
|
||||
|
||||
const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
|
||||
|
||||
const object = await videoPlaylist.toActivityPubObject(null, transaction)
|
||||
const audience = getPlaylistAudience(byActor, videoPlaylist.privacy)
|
||||
|
||||
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
|
||||
|
||||
const serverActor = await getServerActor()
|
||||
const toFollowersOf = [ byActor, serverActor ]
|
||||
|
||||
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
transaction,
|
||||
contextType: 'Playlist'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateVideoPlayerSettings (video: MVideoFullLight, settings: MPlayerSetting, transaction: Transaction) {
|
||||
if (!canVideoBeFederated(video, false)) return
|
||||
|
||||
const byActor = video.VideoChannel.Account.Actor
|
||||
const settingsUrl = getLocalVideoPlayerSettingsActivityPubUrl(video)
|
||||
|
||||
logger.info('Creating job to update video player settings ' + settingsUrl)
|
||||
|
||||
const updateUrl = getUpdateActivityPubUrl(settingsUrl, settings.updatedAt.toISOString())
|
||||
|
||||
const object = PlayerSettingModel.formatAPPlayerSetting({ settings, video, channel: undefined })
|
||||
const audience = getVideoAudience({ account: video.VideoChannel.Account, channel: video.VideoChannel, privacy: video.privacy })
|
||||
|
||||
const updateActivity = buildUpdateActivity(updateUrl, byActor, object, audience)
|
||||
|
||||
const toFollowersOf = await getActorsInvolvedInVideo(video, transaction)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf,
|
||||
transaction,
|
||||
contextType: 'PlayerSettings'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUpdateChannelPlayerSettings (channel: MChannelDefault, settings: MPlayerSetting, transaction: Transaction) {
|
||||
const byActor = channel.Actor
|
||||
const settingsUrl = getLocalChannelPlayerSettingsActivityPubUrl(channel.Actor.preferredUsername)
|
||||
|
||||
logger.info('Creating job to update channel player settings actor ' + settingsUrl)
|
||||
|
||||
const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
|
||||
const object = PlayerSettingModel.formatAPPlayerSetting({ settings, video: undefined, channel })
|
||||
|
||||
const audience = getPublicAudience(byActor)
|
||||
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: updateActivity,
|
||||
byActor,
|
||||
toFollowersOf: await getToFollowersOfForActor(channel, transaction),
|
||||
transaction,
|
||||
contextType: 'PlayerSettings'
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildUpdateActivity (
|
||||
url: string,
|
||||
byActor: MActorLight,
|
||||
object: ActivityUpdateObject,
|
||||
audience?: ActivityAudience
|
||||
): ActivityUpdate<ActivityUpdateObject> {
|
||||
if (!audience) audience = getPublicAudience(byActor)
|
||||
|
||||
return audiencify(
|
||||
{
|
||||
type: 'Update' as 'Update',
|
||||
id: url,
|
||||
actor: byActor.url,
|
||||
object: audiencify(object, audience)
|
||||
},
|
||||
audience
|
||||
)
|
||||
}
|
||||
|
||||
async function getToFollowersOfForActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction?: Transaction) {
|
||||
let actorsInvolved: MActor[]
|
||||
if (accountOrChannel instanceof AccountModel) {
|
||||
// Actors that shared my videos are involved too
|
||||
actorsInvolved = await VideoShareModel.listActorsWhoSharedVideosOf({ actorOwnerId: accountOrChannel.Actor.id, transaction })
|
||||
} else {
|
||||
// Actors that shared videos of my channel are involved too
|
||||
actorsInvolved = await VideoShareModel.listActorsByVideoChannel({ channelId: accountOrChannel.id, transaction })
|
||||
}
|
||||
|
||||
actorsInvolved.push(accountOrChannel.Actor)
|
||||
|
||||
return actorsInvolved
|
||||
}
|
||||
69
server/core/lib/activitypub/send/send-view.ts
Normal file
69
server/core/lib/activitypub/send/send-view.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ActivityAudience, ActivityView } from '@peertube/peertube-models'
|
||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||
import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import { audiencify, getPublicAudience } from '../audience.js'
|
||||
import { getLocalVideoViewActivityPubUrl } from '../url.js'
|
||||
import { sendVideoRelatedActivity } from './shared/send-utils.js'
|
||||
|
||||
async function sendView (options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable
|
||||
viewerIdentifier: string
|
||||
viewersCount?: number
|
||||
transaction?: Transaction
|
||||
}) {
|
||||
const { byActor, viewersCount, video, viewerIdentifier, transaction } = options
|
||||
|
||||
logger.info('Creating job to send %s of %s.', viewersCount !== undefined ? 'viewer' : 'view', video.url)
|
||||
|
||||
const activityBuilder = (audience: ActivityAudience) => {
|
||||
const url = getLocalVideoViewActivityPubUrl(byActor, video, viewerIdentifier)
|
||||
|
||||
return buildViewActivity({ url, byActor, video, audience, viewersCount })
|
||||
}
|
||||
|
||||
return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View', parallelizable: true })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
sendView
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildViewActivity (options: {
|
||||
url: string
|
||||
byActor: MActorAudience
|
||||
video: MVideoUrl
|
||||
viewersCount?: number
|
||||
audience?: ActivityAudience
|
||||
}): ActivityView {
|
||||
const { url, byActor, viewersCount, video, audience = getPublicAudience(byActor) } = options
|
||||
|
||||
const base = {
|
||||
id: url,
|
||||
type: 'View' as 'View',
|
||||
actor: byActor.url,
|
||||
object: video.url
|
||||
}
|
||||
|
||||
if (viewersCount === undefined) {
|
||||
return audiencify(base, audience)
|
||||
}
|
||||
|
||||
return audiencify({
|
||||
...base,
|
||||
|
||||
expires: new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString(),
|
||||
|
||||
result: {
|
||||
interactionType: 'WatchAction',
|
||||
type: 'InteractionCounter',
|
||||
userInteractionCount: viewersCount
|
||||
}
|
||||
}, audience)
|
||||
}
|
||||
308
server/core/lib/activitypub/send/shared/send-utils.ts
Normal file
308
server/core/lib/activitypub/send/shared/send-utils.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Activity, ActivityAudience, ActivitypubHttpBroadcastPayload, ContextType } from '@peertube/peertube-models'
|
||||
import { ActorFollowHealthCache } from '@server/lib/actor-follow-health-cache.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { VideoShareModel } from '@server/models/video/video-share.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { afterCommitIfTransaction } from '../../../../helpers/database-utils.js'
|
||||
import { logger } from '../../../../helpers/logger.js'
|
||||
import { ActorFollowModel } from '../../../../models/actor/actor-follow.js'
|
||||
import { ActorModel } from '../../../../models/actor/actor.js'
|
||||
import {
|
||||
MActor,
|
||||
MActorId,
|
||||
MActorLight,
|
||||
MActorWithInboxes,
|
||||
MVideoAccountLight,
|
||||
MVideoId,
|
||||
MVideoImmutable
|
||||
} from '../../../../types/models/index.js'
|
||||
import { JobQueue } from '../../../job-queue/index.js'
|
||||
import { getDirectAudience, getVideoAudience } from '../../audience.js'
|
||||
|
||||
async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable | MVideoAccountLight
|
||||
contextType: ContextType
|
||||
parallelizable?: boolean
|
||||
transaction?: Transaction
|
||||
skipPrivacyCheck?: boolean
|
||||
}) {
|
||||
const { byActor, transaction, contextType, parallelizable, skipPrivacyCheck } = options
|
||||
|
||||
// Send to origin
|
||||
if (options.video.isLocal() === false) {
|
||||
return sendVideoRelatedActivityToOrigin(activityBuilder, options)
|
||||
}
|
||||
|
||||
const video = await VideoModel.loadByUrlAndPopulateAccount(options.video.url, transaction)
|
||||
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, transaction)
|
||||
|
||||
const audience = getVideoAudience({
|
||||
account: video.VideoChannel.Account,
|
||||
channel: video.VideoChannel,
|
||||
privacy: video.privacy,
|
||||
skipPrivacyCheck
|
||||
})
|
||||
const activity = activityBuilder(audience)
|
||||
|
||||
const actorsException = [ byActor ]
|
||||
|
||||
return broadcastToFollowers({
|
||||
data: activity,
|
||||
byActor,
|
||||
toFollowersOf: actorsInvolvedInVideo,
|
||||
transaction,
|
||||
actorsException,
|
||||
parallelizable,
|
||||
contextType
|
||||
})
|
||||
}
|
||||
|
||||
async function sendVideoRelatedActivityToOrigin (
|
||||
activityBuilder: (audience: ActivityAudience) => Activity,
|
||||
options: {
|
||||
byActor: MActorLight
|
||||
video: MVideoImmutable | MVideoAccountLight
|
||||
contextType: ContextType
|
||||
transaction?: Transaction
|
||||
}
|
||||
) {
|
||||
const { byActor, video, transaction, contextType } = options
|
||||
|
||||
if (video.isLocal()) throw new Error('Cannot send activity to owned video origin ' + video.url)
|
||||
|
||||
let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor
|
||||
if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction)
|
||||
|
||||
const audience = getDirectAudience(accountActor)
|
||||
const activity = activityBuilder(audience)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return unicastTo({
|
||||
data: activity,
|
||||
byActor,
|
||||
toActorUrl: accountActor.getSharedInbox(),
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function forwardVideoRelatedActivity (options: {
|
||||
activity: Activity
|
||||
transaction: Transaction
|
||||
followersException: MActorWithInboxes[]
|
||||
video: MVideoId
|
||||
}) {
|
||||
const { activity, transaction, followersException, video } = options
|
||||
|
||||
// Mastodon does not add our announces in audience, so we forward to them manually
|
||||
const additionalActors = await getActorsInvolvedInVideo(video, transaction)
|
||||
const additionalFollowerUrls = additionalActors.map(a => a.followersUrl)
|
||||
|
||||
logger.info('Forwarding activity %s.', activity.id)
|
||||
|
||||
const followersUrls = additionalFollowerUrls
|
||||
|
||||
const toActorFollowers = await ActorModel.listByFollowersUrls(followersUrls, transaction)
|
||||
const uris = await computeFollowerUris(toActorFollowers, followersException, transaction)
|
||||
|
||||
if (uris.length === 0) {
|
||||
logger.info('0 followers for %s, no forwarding.', toActorFollowers.map(a => a.id).join(', '))
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.debug('Creating forwarding job.', { uris })
|
||||
|
||||
const payload: ActivitypubHttpBroadcastPayload = {
|
||||
uris,
|
||||
body: activity,
|
||||
contextType: null
|
||||
}
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => JobQueue.Instance.createJobAsync({ type: 'activitypub-http-broadcast', payload }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function broadcastToFollowers (options: {
|
||||
data: any
|
||||
byActor: MActorId
|
||||
toFollowersOf: MActorId[]
|
||||
transaction: Transaction
|
||||
contextType: ContextType
|
||||
|
||||
parallelizable?: boolean
|
||||
actorsException?: MActorWithInboxes[]
|
||||
}) {
|
||||
const { data, byActor, toFollowersOf, transaction, contextType, actorsException = [], parallelizable } = options
|
||||
|
||||
const uris = await computeFollowerUris(toFollowersOf, actorsException, transaction)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return broadcastTo({
|
||||
uris,
|
||||
data,
|
||||
byActor,
|
||||
parallelizable,
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function broadcastToActors (options: {
|
||||
data: any
|
||||
byActor: MActorId
|
||||
toActors: MActor[]
|
||||
transaction: Transaction
|
||||
contextType: ContextType
|
||||
actorsException?: MActorWithInboxes[]
|
||||
}) {
|
||||
const { data, byActor, toActors, transaction, contextType, actorsException = [] } = options
|
||||
|
||||
const uris = await computeUris(toActors, actorsException)
|
||||
|
||||
return afterCommitIfTransaction(transaction, () => {
|
||||
return broadcastTo({
|
||||
uris,
|
||||
data,
|
||||
byActor,
|
||||
contextType
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function broadcastTo (options: {
|
||||
uris: string[]
|
||||
data: any
|
||||
byActor: MActorId
|
||||
contextType: ContextType
|
||||
parallelizable?: boolean // default to false
|
||||
}) {
|
||||
const { uris, data, byActor, contextType, parallelizable } = options
|
||||
|
||||
if (uris.length === 0) return undefined
|
||||
|
||||
const broadcastUris: string[] = []
|
||||
const unicastUris: string[] = []
|
||||
|
||||
// Bad URIs could be slow to respond, prefer to process them in a dedicated queue
|
||||
for (const uri of uris) {
|
||||
if (ActorFollowHealthCache.Instance.isBadInbox(uri)) {
|
||||
unicastUris.push(uri)
|
||||
} else {
|
||||
broadcastUris.push(uri)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Creating broadcast job.', { broadcastUris, unicastUris })
|
||||
|
||||
if (broadcastUris.length !== 0) {
|
||||
const payload = {
|
||||
uris: broadcastUris,
|
||||
signatureActorId: byActor.id,
|
||||
body: data,
|
||||
contextType
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({
|
||||
type: parallelizable
|
||||
? 'activitypub-http-broadcast-parallel'
|
||||
: 'activitypub-http-broadcast',
|
||||
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
for (const unicastUri of unicastUris) {
|
||||
const payload = {
|
||||
uri: unicastUri,
|
||||
signatureActorId: byActor.id,
|
||||
body: data,
|
||||
contextType
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
|
||||
}
|
||||
}
|
||||
|
||||
function unicastTo (options: {
|
||||
data: any
|
||||
byActor: MActorId
|
||||
toActorUrl: string
|
||||
contextType: ContextType
|
||||
}) {
|
||||
const { data, byActor, toActorUrl, contextType } = options
|
||||
|
||||
logger.debug('Creating unicast job.', { uri: toActorUrl })
|
||||
|
||||
const payload = {
|
||||
uri: toActorUrl,
|
||||
signatureActorId: byActor.id,
|
||||
body: data,
|
||||
contextType
|
||||
}
|
||||
|
||||
JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) {
|
||||
const actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t)
|
||||
|
||||
const alreadyLoadedActor = (video as VideoModel).VideoChannel?.Account?.Actor
|
||||
|
||||
const videoActor = alreadyLoadedActor?.url && alreadyLoadedActor?.followersUrl
|
||||
? alreadyLoadedActor
|
||||
: await ActorModel.loadAccountActorFollowerUrlByVideoId(video.id, t)
|
||||
|
||||
if (videoActor) actors.push(videoActor)
|
||||
|
||||
return actors
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
broadcastToActors,
|
||||
broadcastToFollowers,
|
||||
forwardVideoRelatedActivity,
|
||||
sendVideoRelatedActivity,
|
||||
sendVideoRelatedActivityToOrigin,
|
||||
unicastTo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function computeFollowerUris (toFollowersOf: MActorId[], actorsException: MActorWithInboxes[], t: Transaction) {
|
||||
const toActorFollowerIds = toFollowersOf.map(a => a.id)
|
||||
|
||||
const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t)
|
||||
const sharedInboxesException = await buildSharedInboxesException(actorsException)
|
||||
|
||||
return result.data.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
|
||||
}
|
||||
|
||||
async function computeUris (toActors: MActor[], actorsException: MActorWithInboxes[] = []) {
|
||||
const serverActor = await getServerActor()
|
||||
const targetUrls = toActors
|
||||
.filter(a => a.id !== serverActor.id) // Don't send to ourselves
|
||||
.map(a => a.getSharedInbox())
|
||||
|
||||
const toActorSharedInboxesSet = new Set(targetUrls)
|
||||
|
||||
const sharedInboxesException = await buildSharedInboxesException(actorsException)
|
||||
return Array.from(toActorSharedInboxesSet)
|
||||
.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
|
||||
}
|
||||
|
||||
async function buildSharedInboxesException (actorsException: MActorWithInboxes[]) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
return actorsException
|
||||
.map(f => f.getSharedInbox())
|
||||
.concat([ serverActor.sharedInboxUrl ])
|
||||
}
|
||||
116
server/core/lib/activitypub/share.ts
Normal file
116
server/core/lib/activitypub/share.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import Bluebird from 'bluebird'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { logger, loggerTagsFactory } from '../../helpers/logger.js'
|
||||
import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
|
||||
import { VideoShareModel } from '../../models/video/video-share.js'
|
||||
import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video/index.js'
|
||||
import { fetchAP, getAPId } from './activity.js'
|
||||
import { getOrCreateAPActor } from './actors/index.js'
|
||||
import { sendUndoAnnounce, sendVideoAnnounce } from './send/index.js'
|
||||
import { checkUrlsSameHost, getLocalVideoAnnounceActivityPubUrl } from './url.js'
|
||||
|
||||
const lTags = loggerTagsFactory('share')
|
||||
|
||||
export async function changeVideoChannelShare (
|
||||
video: MVideoAccountLight,
|
||||
oldVideoChannel: MChannelActorLight,
|
||||
t: Transaction
|
||||
) {
|
||||
logger.info(
|
||||
'Updating video channel of video %s: %s -> %s.',
|
||||
video.uuid,
|
||||
oldVideoChannel.name,
|
||||
video.VideoChannel.name,
|
||||
lTags(video.uuid)
|
||||
)
|
||||
|
||||
await undoShareByVideoChannel(video, oldVideoChannel, t)
|
||||
|
||||
await shareByVideoChannel(video, t)
|
||||
}
|
||||
|
||||
export async function addVideoShares (shareUrls: string[], video: MVideoId) {
|
||||
await Bluebird.map(shareUrls, async shareUrl => {
|
||||
try {
|
||||
await addVideoShare(shareUrl, video)
|
||||
} catch (err) {
|
||||
if (err.statusCode === HttpStatusCode.NOT_FOUND_404 || err.statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.debug(`Cannot add share ${shareUrl} that does not exist anymore`, { err })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Cannot add share ${shareUrl}`, { err })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
export async function shareByServer (video: MVideo, t: Transaction) {
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
const serverShareUrl = getLocalVideoAnnounceActivityPubUrl(serverActor, video)
|
||||
const [ serverShare ] = await VideoShareModel.findOrCreate({
|
||||
defaults: {
|
||||
actorId: serverActor.id,
|
||||
videoId: video.id,
|
||||
url: serverShareUrl
|
||||
},
|
||||
where: {
|
||||
url: serverShareUrl
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return sendVideoAnnounce(serverActor, serverShare, video, t)
|
||||
}
|
||||
|
||||
export async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
|
||||
const videoChannelShareUrl = getLocalVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
|
||||
const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
|
||||
defaults: {
|
||||
actorId: video.VideoChannel.Actor.id,
|
||||
videoId: video.id,
|
||||
url: videoChannelShareUrl
|
||||
},
|
||||
where: {
|
||||
url: videoChannelShareUrl
|
||||
},
|
||||
transaction: t
|
||||
})
|
||||
|
||||
return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function addVideoShare (shareUrl: string, video: MVideoId) {
|
||||
const { body } = await fetchAP<any>(shareUrl)
|
||||
if (!body?.actor) throw new Error('Body or body actor is invalid')
|
||||
|
||||
const actorUrl = getAPId(body.actor)
|
||||
if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
|
||||
throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
|
||||
}
|
||||
|
||||
const actor = await getOrCreateAPActor(actorUrl)
|
||||
|
||||
const entry = {
|
||||
actorId: actor.id,
|
||||
videoId: video.id,
|
||||
url: shareUrl
|
||||
}
|
||||
|
||||
await VideoShareModel.upsert(entry)
|
||||
}
|
||||
|
||||
async function undoShareByVideoChannel (video: MVideo, oldVideoChannel: MChannelActorLight, t: Transaction) {
|
||||
// Load old share
|
||||
const oldShare = await VideoShareModel.load(oldVideoChannel.Actor.id, video.id, t)
|
||||
if (!oldShare) return new Error(`Cannot find old video channel share ${oldVideoChannel.Actor.id} for video ${video.id}`)
|
||||
|
||||
await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t)
|
||||
await oldShare.destroy({ transaction: t })
|
||||
}
|
||||
160
server/core/lib/activitypub/url.ts
Normal file
160
server/core/lib/activitypub/url.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { REMOTE_SCHEME, WEBSERVER } from '../../initializers/constants.js'
|
||||
import {
|
||||
MAbuseFull,
|
||||
MAbuseId,
|
||||
MActor,
|
||||
MActorFollow,
|
||||
MActorId,
|
||||
MActorUrl,
|
||||
MCommentId,
|
||||
MLocalVideoViewer,
|
||||
MVideoId,
|
||||
MVideoPlaylistElement,
|
||||
MVideoUUID,
|
||||
MVideoUrl,
|
||||
MVideoWithHost
|
||||
} from '../../types/models/index.js'
|
||||
import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist.js'
|
||||
import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist.js'
|
||||
|
||||
export function getLocalVideoActivityPubUrl (video: MVideoUUID) {
|
||||
return WEBSERVER.URL + '/videos/watch/' + video.uuid
|
||||
}
|
||||
|
||||
export function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) {
|
||||
return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
|
||||
}
|
||||
|
||||
export function getLocalVideoPlaylistElementActivityPubUrl (playlist: MVideoPlaylistUUID, element: MVideoPlaylistElement) {
|
||||
return WEBSERVER.URL + '/video-playlists/' + playlist.uuid + '/videos/' + element.id
|
||||
}
|
||||
|
||||
export function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) {
|
||||
return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}`
|
||||
}
|
||||
|
||||
export function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) {
|
||||
return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
|
||||
}
|
||||
|
||||
export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
|
||||
return WEBSERVER.URL + '/video-channels/' + videoChannelName
|
||||
}
|
||||
|
||||
export function getLocalChannelPlayerSettingsActivityPubUrl (videoChannelName: string) {
|
||||
return WEBSERVER.URL + '/video-channels/' + videoChannelName + '/player-settings'
|
||||
}
|
||||
|
||||
export function getLocalAccountActivityPubUrl (accountName: string) {
|
||||
return WEBSERVER.URL + '/accounts/' + accountName
|
||||
}
|
||||
|
||||
export function getLocalAbuseActivityPubUrl (abuse: MAbuseId) {
|
||||
return WEBSERVER.URL + '/admin/abuses/' + abuse.id
|
||||
}
|
||||
|
||||
export function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) {
|
||||
return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier
|
||||
}
|
||||
|
||||
export function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
|
||||
return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
|
||||
}
|
||||
|
||||
export function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
|
||||
return byActor.url + '/likes/' + video.id
|
||||
}
|
||||
|
||||
export function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
|
||||
return byActor.url + '/dislikes/' + video.id
|
||||
}
|
||||
|
||||
export function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/announces'
|
||||
}
|
||||
|
||||
export function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/comments'
|
||||
}
|
||||
|
||||
export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/chapters'
|
||||
}
|
||||
|
||||
export function getLocalVideoPlayerSettingsActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/player-settings'
|
||||
}
|
||||
|
||||
export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/likes'
|
||||
}
|
||||
|
||||
export function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) {
|
||||
return video.url + '/dislikes'
|
||||
}
|
||||
|
||||
export function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) {
|
||||
return follower.url + '/follows/' + following.id
|
||||
}
|
||||
|
||||
export function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) {
|
||||
return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id
|
||||
}
|
||||
|
||||
export function getLocalActorFollowRejectActivityPubUrl () {
|
||||
return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString()
|
||||
}
|
||||
|
||||
export function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) {
|
||||
return video.url + '/announces/' + byActor.id
|
||||
}
|
||||
|
||||
export function getDeleteActivityPubUrl (originalUrl: string) {
|
||||
return originalUrl + '/delete'
|
||||
}
|
||||
|
||||
export function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) {
|
||||
return originalUrl + '/updates/' + updatedAt
|
||||
}
|
||||
|
||||
export function getUndoActivityPubUrl (originalUrl: string) {
|
||||
return originalUrl + '/undo'
|
||||
}
|
||||
|
||||
export function getLocalActorPlayerSettingsActivityPubUrl (actor: MActorUrl) {
|
||||
return actor.url + '/player-settings'
|
||||
}
|
||||
|
||||
export function getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: MCommentId) {
|
||||
return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Try to fetch target URL
|
||||
// If it doesn't exist anymore use abuse URL
|
||||
export function getAbuseIdentifier (abuse: MAbuseFull) {
|
||||
return abuse.VideoAbuse?.Video?.url ||
|
||||
abuse.VideoCommentAbuse?.VideoComment?.url ||
|
||||
abuse.FlaggedAccount?.Actor?.url ||
|
||||
abuse.id + ''
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) {
|
||||
if (!scheme) scheme = REMOTE_SCHEME.HTTP
|
||||
|
||||
const host = video.VideoChannel.Actor.Server.host
|
||||
|
||||
return scheme + '://' + host + path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function checkUrlsSameHost (url1: string, url2: string) {
|
||||
const idHost = new URL(url1).host
|
||||
const actorHost = new URL(url2).host
|
||||
|
||||
return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
|
||||
}
|
||||
16
server/core/lib/activitypub/video-chapters.ts
Normal file
16
server/core/lib/activitypub/video-chapters.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { VideoChapterObject } from '@peertube/peertube-models'
|
||||
import { MVideo, MVideoChapter } from '@server/types/models/index.js'
|
||||
|
||||
export function buildChaptersAPHasPart (video: MVideo, chapters: MVideoChapter[]) {
|
||||
const hasPart: VideoChapterObject[] = []
|
||||
|
||||
if (chapters.length !== 0) {
|
||||
for (let i = 0; i < chapters.length - 1; i++) {
|
||||
hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
|
||||
}
|
||||
|
||||
hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video, nextChapter: null }))
|
||||
}
|
||||
|
||||
return hasPart
|
||||
}
|
||||
258
server/core/lib/activitypub/video-comments.ts
Normal file
258
server/core/lib/activitypub/video-comments.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { HttpStatusCode, VideoCommentPolicy } from '@peertube/peertube-models'
|
||||
import Bluebird from 'bluebird'
|
||||
import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments.js'
|
||||
import { logger } from '../../helpers/logger.js'
|
||||
import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
|
||||
import { VideoCommentModel } from '../../models/video/video-comment.js'
|
||||
import {
|
||||
MComment,
|
||||
MCommentOwner,
|
||||
MCommentOwnerVideo,
|
||||
MVideoAccountLight,
|
||||
MVideoAccountLightBlacklistAllFiles
|
||||
} from '../../types/models/video/index.js'
|
||||
import { AutomaticTagger } from '../automatic-tags/automatic-tagger.js'
|
||||
import { setAndSaveCommentAutomaticTags } from '../automatic-tags/automatic-tags.js'
|
||||
import { isRemoteVideoCommentAccepted } from '../moderation.js'
|
||||
import { Hooks } from '../plugins/hooks.js'
|
||||
import { shouldCommentBeHeldForReview } from '../video-comment.js'
|
||||
import { fetchAP } from './activity.js'
|
||||
import { getOrCreateAPActor } from './actors/index.js'
|
||||
import { checkUrlsSameHost } from './url.js'
|
||||
import { canVideoBeFederated, getOrCreateAPVideo } from './videos/index.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
|
||||
type ResolveThreadParams = {
|
||||
url: string
|
||||
comments?: MCommentOwner[]
|
||||
isVideo?: boolean
|
||||
commentCreated?: boolean
|
||||
}
|
||||
type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }>
|
||||
|
||||
export async function addVideoComments (commentUrls: string[]) {
|
||||
if (CONFIG.VIDEO_COMMENTS.ACCEPT_REMOTE_COMMENTS !== true) return
|
||||
|
||||
return Bluebird.map(commentUrls, async commentUrl => {
|
||||
try {
|
||||
await resolveThread({ url: commentUrl, isVideo: false })
|
||||
} catch (err) {
|
||||
if (err.statusCode === HttpStatusCode.NOT_FOUND_404 || err.statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.debug(`Cannot resolve thread ${commentUrl} that does not exist anymore`, { err })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Cannot resolve thread ${commentUrl}`, { err })
|
||||
}
|
||||
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
|
||||
}
|
||||
|
||||
export async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
|
||||
const { url, isVideo } = params
|
||||
|
||||
if (params.commentCreated === undefined) params.commentCreated = false
|
||||
if (params.comments === undefined) params.comments = []
|
||||
|
||||
// If it is not a video, or if we don't know if it's a video, try to get the thread from DB
|
||||
if (isVideo === false || isVideo === undefined) {
|
||||
const result = await resolveCommentFromDB(params)
|
||||
if (result) return result
|
||||
}
|
||||
|
||||
try {
|
||||
// If it is a video, or if we don't know if it's a video
|
||||
if (isVideo === true || isVideo === undefined) {
|
||||
// Keep await so we catch the exception
|
||||
return await tryToResolveThreadFromVideo(params)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err })
|
||||
}
|
||||
|
||||
return resolveRemoteParentComment(params)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveCommentFromDB (params: ResolveThreadParams) {
|
||||
const { url, comments, commentCreated } = params
|
||||
|
||||
const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url)
|
||||
if (!commentFromDatabase) return undefined
|
||||
|
||||
let parentComments = comments.concat([ commentFromDatabase ])
|
||||
|
||||
// Speed up things and resolve directly the thread
|
||||
if (commentFromDatabase.InReplyToVideoComment) {
|
||||
const data = await VideoCommentModel.listThreadParentComments({ comment: commentFromDatabase, order: 'DESC' })
|
||||
|
||||
parentComments = parentComments.concat(data)
|
||||
}
|
||||
|
||||
return resolveThread({
|
||||
url: commentFromDatabase.Video.url,
|
||||
comments: parentComments,
|
||||
isVideo: true,
|
||||
commentCreated
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
|
||||
const { url, comments, commentCreated } = params
|
||||
|
||||
// Maybe it's a reply to a video?
|
||||
// If yes, it's done: we resolved all the thread
|
||||
const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false }
|
||||
const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam })
|
||||
|
||||
if (video.isLocal() && !canVideoBeFederated(video)) {
|
||||
throw new Error('Cannot resolve thread of video that is not compatible with federation')
|
||||
}
|
||||
|
||||
if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let resultComment: MCommentOwnerVideo
|
||||
if (comments.length !== 0) {
|
||||
const firstReply = comments[comments.length - 1] as MCommentOwnerVideo
|
||||
firstReply.inReplyToCommentId = null
|
||||
firstReply.originCommentId = null
|
||||
firstReply.videoId = video.id
|
||||
firstReply.changed('updatedAt', true)
|
||||
firstReply.Video = video
|
||||
|
||||
if (await isRemoteCommentAccepted(firstReply) !== true) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const firstReplyAutomaticTags = await getAutomaticTagsAndAssignReview(firstReply, video)
|
||||
comments[comments.length - 1] = await firstReply.save()
|
||||
|
||||
await setAndSaveCommentAutomaticTags({ comment: firstReply, automaticTags: firstReplyAutomaticTags })
|
||||
|
||||
for (let i = comments.length - 2; i >= 0; i--) {
|
||||
const comment = comments[i] as MCommentOwnerVideo
|
||||
comment.originCommentId = firstReply.id
|
||||
comment.inReplyToCommentId = comments[i + 1].id
|
||||
comment.videoId = video.id
|
||||
comment.changed('updatedAt', true)
|
||||
comment.Video = video
|
||||
|
||||
if (await isRemoteCommentAccepted(comment) !== true) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const automaticTags = await getAutomaticTagsAndAssignReview(comment, video)
|
||||
|
||||
comments[i] = await comment.save()
|
||||
|
||||
await setAndSaveCommentAutomaticTags({ comment, automaticTags })
|
||||
}
|
||||
|
||||
resultComment = comments[0] as MCommentOwnerVideo
|
||||
}
|
||||
|
||||
return { video, comment: resultComment, commentCreated }
|
||||
}
|
||||
|
||||
async function getAutomaticTagsAndAssignReview (comment: MComment, video: MVideoAccountLight) {
|
||||
// Remote comment already exists in database or remote video -> we don't need to rebuild automatic tags
|
||||
if (comment.id) return []
|
||||
|
||||
const ownerAccount = video.VideoChannel.Account
|
||||
|
||||
const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ ownerAccount, text: comment.text })
|
||||
|
||||
// Third parties rely on origin, so if origin has the comment it's not held for review
|
||||
if (video.isLocal() || comment.isLocal()) {
|
||||
comment.heldForReview = await shouldCommentBeHeldForReview({ user: null, video, automaticTags })
|
||||
} else {
|
||||
comment.heldForReview = false
|
||||
}
|
||||
|
||||
return automaticTags
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveRemoteParentComment (params: ResolveThreadParams) {
|
||||
const { url, comments } = params
|
||||
|
||||
if (comments.length > ACTIVITY_PUB.MAX_RECURSION_COMMENTS) {
|
||||
throw new Error('Recursion limit reached when resolving a thread')
|
||||
}
|
||||
|
||||
const { body } = await fetchAP<any>(url)
|
||||
|
||||
if (sanitizeAndCheckVideoCommentObject(body) === false) {
|
||||
throw new Error(`Remote video comment JSON ${url} is not valid:` + JSON.stringify(body))
|
||||
}
|
||||
|
||||
const actorUrl = body.attributedTo
|
||||
if (!actorUrl && body.type !== 'Tombstone') throw new Error('Miss attributed to in comment')
|
||||
|
||||
if (actorUrl && checkUrlsSameHost(url, actorUrl) !== true) {
|
||||
throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`)
|
||||
}
|
||||
|
||||
if (checkUrlsSameHost(body.id, url) !== true) {
|
||||
throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`)
|
||||
}
|
||||
|
||||
const actor = actorUrl
|
||||
? await getOrCreateAPActor(actorUrl, 'all')
|
||||
: null
|
||||
|
||||
const comment = new VideoCommentModel({
|
||||
url: body.id,
|
||||
text: body.content ? body.content : '',
|
||||
videoId: null,
|
||||
accountId: actor ? actor.Account.id : null,
|
||||
inReplyToCommentId: null,
|
||||
originCommentId: null,
|
||||
createdAt: new Date(body.published),
|
||||
updatedAt: new Date(body.updated),
|
||||
replyApproval: body.replyApproval,
|
||||
|
||||
deletedAt: body.deleted
|
||||
? new Date(body.deleted)
|
||||
: null
|
||||
}) as MCommentOwner
|
||||
comment.Account = actor ? actor.Account : null
|
||||
|
||||
logger.debug('Created remote comment %s', comment.url, { comment })
|
||||
|
||||
return resolveThread({
|
||||
url: body.inReplyTo,
|
||||
comments: comments.concat([ comment ]),
|
||||
commentCreated: true
|
||||
})
|
||||
}
|
||||
|
||||
async function isRemoteCommentAccepted (comment: MComment) {
|
||||
// Already created
|
||||
if (comment.id) return true
|
||||
|
||||
const acceptParameters = {
|
||||
comment
|
||||
}
|
||||
|
||||
const acceptedResult = await Hooks.wrapFun(
|
||||
isRemoteVideoCommentAccepted,
|
||||
acceptParameters,
|
||||
'filter:activity-pub.remote-video-comment.create.accept.result'
|
||||
)
|
||||
|
||||
if (acceptedResult?.accepted !== true) {
|
||||
logger.info('Refused to create a remote comment.', { acceptedResult, acceptParameters })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
59
server/core/lib/activitypub/video-rates.ts
Normal file
59
server/core/lib/activitypub/video-rates.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { VideoRateType } from '@peertube/peertube-models'
|
||||
import { MAccountActor, MActorUrl, MVideoAccountLight, MVideoFullLight, MVideoId } from '../../types/models/index.js'
|
||||
import { sendLike, sendUndoDislike, sendUndoLike } from './send/index.js'
|
||||
import { sendDislike } from './send/send-dislike.js'
|
||||
import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url.js'
|
||||
import { federateVideoIfNeeded } from './videos/index.js'
|
||||
|
||||
async function sendVideoRateChange (
|
||||
account: MAccountActor,
|
||||
video: MVideoFullLight,
|
||||
likes: number,
|
||||
dislikes: number,
|
||||
t: Transaction
|
||||
) {
|
||||
if (video.isLocal()) return federateVideoIfNeeded(video, false, t)
|
||||
|
||||
return sendVideoRateChangeToOrigin(account, video, likes, dislikes, t)
|
||||
}
|
||||
|
||||
function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVideoId) {
|
||||
return rateType === 'like'
|
||||
? getVideoLikeActivityPubUrlByLocalActor(actor, video)
|
||||
: getVideoDislikeActivityPubUrlByLocalActor(actor, video)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
getLocalRateUrl,
|
||||
sendVideoRateChange
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendVideoRateChangeToOrigin (
|
||||
account: MAccountActor,
|
||||
video: MVideoAccountLight,
|
||||
likes: number,
|
||||
dislikes: number,
|
||||
t: Transaction
|
||||
) {
|
||||
// Local video, we don't need to send like
|
||||
if (video.isLocal()) return
|
||||
|
||||
const actor = account.Actor
|
||||
|
||||
// Keep the order: first we undo and then we create
|
||||
|
||||
// Undo Like
|
||||
if (likes < 0) await sendUndoLike(actor, video, t)
|
||||
// Undo Dislike
|
||||
if (dislikes < 0) await sendUndoDislike(actor, video, t)
|
||||
|
||||
// Like
|
||||
if (likes > 0) await sendLike(actor, video, t)
|
||||
// Dislike
|
||||
if (dislikes > 0) await sendDislike(actor, video, t)
|
||||
}
|
||||
53
server/core/lib/activitypub/videos/federate.ts
Normal file
53
server/core/lib/activitypub/videos/federate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { VideoPrivacy, VideoPrivacyType, VideoState, VideoStateType } from '@peertube/peertube-models'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { MVideoAPLight, MVideoWithBlacklistRights } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { sendCreateVideo, sendUpdateVideo } from '../send/index.js'
|
||||
import { shareByServer, shareByVideoChannel } from '../share.js'
|
||||
|
||||
export async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
|
||||
if (!canVideoBeFederated(videoArg, isNewVideo)) return
|
||||
|
||||
const video = await videoArg.lightAPToFullAP(transaction)
|
||||
|
||||
if (isNewVideo) {
|
||||
// Now we'll add the video's meta data to our followers
|
||||
await sendCreateVideo(video, transaction)
|
||||
|
||||
await Promise.all([
|
||||
shareByServer(video, transaction),
|
||||
shareByVideoChannel(video, transaction)
|
||||
])
|
||||
} else {
|
||||
await sendUpdateVideo(video, transaction)
|
||||
}
|
||||
}
|
||||
|
||||
export function canVideoBeFederated (video: MVideoWithBlacklistRights, isNewVideo = false) {
|
||||
// Check this is not a blacklisted video
|
||||
if (video.isBlacklisted() === true) {
|
||||
if (isNewVideo === false) return false
|
||||
if (video.VideoBlacklist.unfederated === true) return false
|
||||
}
|
||||
|
||||
// Check the video is public/unlisted and published
|
||||
return isPrivacyForFederation(video.privacy) && isStateForFederation(video.state)
|
||||
}
|
||||
|
||||
export function isNewVideoPrivacyForFederation (currentPrivacy: VideoPrivacyType, newPrivacy: VideoPrivacyType) {
|
||||
return !isPrivacyForFederation(currentPrivacy) && isPrivacyForFederation(newPrivacy)
|
||||
}
|
||||
|
||||
export function isPrivacyForFederation (privacy: VideoPrivacyType) {
|
||||
const castedPrivacy = forceNumber(privacy)
|
||||
|
||||
return castedPrivacy === VideoPrivacy.PUBLIC ||
|
||||
(CONFIG.FEDERATION.VIDEOS.FEDERATE_UNLISTED === true && castedPrivacy === VideoPrivacy.UNLISTED)
|
||||
}
|
||||
|
||||
export function isStateForFederation (state: VideoStateType) {
|
||||
const castedState = forceNumber(state)
|
||||
|
||||
return castedState === VideoState.PUBLISHED || castedState === VideoState.WAITING_FOR_LIVE || castedState === VideoState.LIVE_ENDED
|
||||
}
|
||||
134
server/core/lib/activitypub/videos/get.ts
Normal file
134
server/core/lib/activitypub/videos/get.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { APObjectId } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
|
||||
import {
|
||||
MVideoAccountLightBlacklistAllFiles,
|
||||
MVideoImmutable,
|
||||
MVideoThumbnail,
|
||||
MVideoThumbnailBlacklist
|
||||
} from '@server/types/models/index.js'
|
||||
import { getAPId } from '../activity.js'
|
||||
import { refreshVideoIfNeeded } from './refresh.js'
|
||||
import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
|
||||
|
||||
type GetVideoResult <T> = Promise<{
|
||||
video: T
|
||||
created: boolean
|
||||
autoBlacklisted?: boolean
|
||||
}>
|
||||
|
||||
type GetVideoParamAll = {
|
||||
videoObject: APObjectId
|
||||
syncParam?: SyncParam
|
||||
fetchType?: 'all'
|
||||
allowRefresh?: boolean
|
||||
}
|
||||
|
||||
type GetVideoParamImmutable = {
|
||||
videoObject: APObjectId
|
||||
syncParam?: SyncParam
|
||||
fetchType: 'unsafe-only-immutable-attributes'
|
||||
allowRefresh: false
|
||||
}
|
||||
|
||||
type GetVideoParamOther = {
|
||||
videoObject: APObjectId
|
||||
syncParam?: SyncParam
|
||||
fetchType?: 'all' | 'only-video-and-blacklist'
|
||||
allowRefresh?: boolean
|
||||
}
|
||||
|
||||
export function getOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
|
||||
export function getOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
|
||||
export function getOrCreateAPVideo (
|
||||
options: GetVideoParamOther
|
||||
): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist>
|
||||
export async function getOrCreateAPVideo (
|
||||
options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
|
||||
): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist | MVideoImmutable> {
|
||||
// Default params
|
||||
const syncParam = options.syncParam || { rates: true, shares: true, comments: true, refreshVideo: false }
|
||||
const fetchType = options.fetchType || 'all'
|
||||
const allowRefresh = options.allowRefresh !== false
|
||||
|
||||
// Get video url
|
||||
const videoUrl = getAPId(options.videoObject)
|
||||
let videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
|
||||
|
||||
if (videoFromDatabase) {
|
||||
if (allowRefresh === true) {
|
||||
// Typings ensure allowRefresh === false in unsafe-only-immutable-attributes fetch type
|
||||
videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam)
|
||||
}
|
||||
|
||||
return { video: videoFromDatabase, created: false }
|
||||
}
|
||||
|
||||
const { videoObject } = await fetchRemoteVideo(videoUrl)
|
||||
if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
|
||||
|
||||
// videoUrl is just an alias/redirection, so process object id instead
|
||||
if (videoObject.id !== videoUrl) return getOrCreateAPVideo({ ...options, fetchType: 'all', videoObject })
|
||||
|
||||
try {
|
||||
const creator = new APVideoCreator(videoObject)
|
||||
const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator))
|
||||
|
||||
await syncVideoExternalAttributes(videoCreated, videoObject, syncParam)
|
||||
|
||||
return { video: videoCreated, created: true, autoBlacklisted }
|
||||
} catch (err) {
|
||||
// Maybe a concurrent getOrCreateAPVideo call created this video
|
||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||
const alreadyCreatedVideo = await loadVideoByUrl(videoUrl, fetchType)
|
||||
if (alreadyCreatedVideo) return { video: alreadyCreatedVideo, created: false }
|
||||
|
||||
logger.error('Cannot create video %s because of SequelizeUniqueConstraintError error, but cannot find it in database.', videoUrl)
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function maybeGetOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
|
||||
export function maybeGetOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
|
||||
export function maybeGetOrCreateAPVideo (
|
||||
options: GetVideoParamOther
|
||||
): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnailBlacklist>
|
||||
export async function maybeGetOrCreateAPVideo (options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther) {
|
||||
try {
|
||||
const result = await getOrCreateAPVideo(options as any)
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
logger.debug('Cannot fetch remote video ' + options.videoObject + ': maybe not a video object?', { err })
|
||||
return { video: undefined, created: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scheduleRefresh (video: MVideoThumbnail, fetchType: VideoLoadByUrlType, syncParam: SyncParam) {
|
||||
if (!video.isOutdated()) return video
|
||||
|
||||
const refreshOptions = {
|
||||
video,
|
||||
fetchedType: fetchType,
|
||||
syncParam
|
||||
}
|
||||
|
||||
if (syncParam.refreshVideo === true) {
|
||||
return refreshVideoIfNeeded(refreshOptions)
|
||||
}
|
||||
|
||||
await JobQueue.Instance.createJob({
|
||||
type: 'activitypub-refresher',
|
||||
payload: { type: 'video', url: video.url }
|
||||
})
|
||||
|
||||
return video
|
||||
}
|
||||
4
server/core/lib/activitypub/videos/index.ts
Normal file
4
server/core/lib/activitypub/videos/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './federate.js'
|
||||
export * from './get.js'
|
||||
export * from './refresh.js'
|
||||
export * from './updater.js'
|
||||
70
server/core/lib/activitypub/videos/refresh.ts
Normal file
70
server/core/lib/activitypub/videos/refresh.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { PeerTubeRequestError } from '@server/helpers/requests.js'
|
||||
import { VideoLoadByUrlType } from '@server/lib/model-loaders/index.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideoAccountLightBlacklistAllFiles, MVideoThumbnail } from '@server/types/models/index.js'
|
||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { ActorFollowHealthCache } from '../../actor-follow-health-cache.js'
|
||||
import { fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared/index.js'
|
||||
import { APVideoUpdater } from './updater.js'
|
||||
|
||||
async function refreshVideoIfNeeded (options: {
|
||||
video: MVideoThumbnail
|
||||
fetchedType: VideoLoadByUrlType
|
||||
syncParam: SyncParam
|
||||
}): Promise<MVideoThumbnail> {
|
||||
if (!options.video.isOutdated()) return options.video
|
||||
|
||||
// We need more attributes if the argument video was fetched with not enough joints
|
||||
const video = options.fetchedType === 'all'
|
||||
? options.video as MVideoAccountLightBlacklistAllFiles
|
||||
: await VideoModel.loadByUrlAndPopulateAccountAndFiles(options.video.url)
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video', 'refresh', video.uuid, video.url)
|
||||
|
||||
logger.info('Refreshing video %s.', video.url, lTags())
|
||||
|
||||
try {
|
||||
const { videoObject } = await fetchRemoteVideo(video.url)
|
||||
|
||||
if (videoObject === undefined) {
|
||||
logger.warn('Cannot refresh remote video %s: invalid body.', video.url, lTags())
|
||||
|
||||
await video.setAsRefreshed()
|
||||
return video
|
||||
}
|
||||
|
||||
const videoUpdater = new APVideoUpdater(videoObject, video)
|
||||
await videoUpdater.update()
|
||||
|
||||
await syncVideoExternalAttributes(video, videoObject, options.syncParam)
|
||||
|
||||
ActorFollowHealthCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
|
||||
|
||||
return video
|
||||
} catch (err) {
|
||||
const statusCode = (err as PeerTubeRequestError).statusCode
|
||||
|
||||
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
|
||||
logger.info('Cannot refresh remote video %s: video does not exist anymore (404/410 error code). Deleting it.', video.url, lTags())
|
||||
|
||||
// Video does not exist anymore
|
||||
await video.destroy()
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.warn('Cannot refresh video %s.', options.video.url, { err, ...lTags() })
|
||||
|
||||
ActorFollowHealthCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
|
||||
|
||||
// Don't refresh in loop
|
||||
await video.setAsRefreshed()
|
||||
return video
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
refreshVideoIfNeeded
|
||||
}
|
||||
257
server/core/lib/activitypub/videos/shared/abstract-builder.ts
Normal file
257
server/core/lib/activitypub/videos/shared/abstract-builder.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ActivityTagObject,
|
||||
ThumbnailType,
|
||||
VideoChaptersObject,
|
||||
VideoObject,
|
||||
VideoStreamingPlaylistType_Type
|
||||
} from '@peertube/peertube-models'
|
||||
import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
|
||||
import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
|
||||
import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
|
||||
import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
|
||||
import { replaceChapters } from '@server/lib/video-chapters.js'
|
||||
import { setVideoTags } from '@server/lib/video.js'
|
||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||
import {
|
||||
MStreamingPlaylistFiles,
|
||||
MStreamingPlaylistFilesVideo,
|
||||
MVideo,
|
||||
MVideoCaption,
|
||||
MVideoFile,
|
||||
MVideoFullLight,
|
||||
MVideoThumbnail
|
||||
} from '@server/types/models/index.js'
|
||||
import { CreationAttributes, Transaction } from 'sequelize'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { findOwner, getOrCreateAPActor } from '../../actors/index.js'
|
||||
import { upsertAPPlayerSettings } from '../../player-settings.js'
|
||||
import {
|
||||
getCaptionAttributesFromObject,
|
||||
getFileAttributesFromUrl,
|
||||
getLiveAttributesFromObject,
|
||||
getLiveSchedulesAttributesFromObject,
|
||||
getPreviewFromIcons,
|
||||
getStoryboardAttributeFromObject,
|
||||
getStreamingPlaylistAttributesFromObject,
|
||||
getTagsFromObject,
|
||||
getThumbnailFromIcons
|
||||
} from './object-to-model-attributes.js'
|
||||
import { getTrackerUrls, setVideoTrackers } from './trackers.js'
|
||||
|
||||
export abstract class APVideoAbstractBuilder {
|
||||
protected abstract videoObject: VideoObject
|
||||
protected abstract lTags: LoggerTagsFn
|
||||
|
||||
protected async getOrCreateVideoChannelFromVideoObject () {
|
||||
const channel = await findOwner(this.videoObject.id, this.videoObject.attributedTo, 'Group')
|
||||
if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.id)
|
||||
|
||||
return getOrCreateAPActor(channel.id, 'all')
|
||||
}
|
||||
|
||||
protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
|
||||
const miniatureIcon = getThumbnailFromIcons(this.videoObject)
|
||||
if (!miniatureIcon) {
|
||||
logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const miniatureModel = updateRemoteVideoThumbnail({
|
||||
fileUrl: miniatureIcon.url,
|
||||
video,
|
||||
type: ThumbnailType.MINIATURE,
|
||||
size: miniatureIcon,
|
||||
onDisk: false // Lazy download remote thumbnails
|
||||
})
|
||||
|
||||
await video.addAndSaveThumbnail(miniatureModel, t)
|
||||
}
|
||||
|
||||
protected async setPreview (video: MVideoFullLight, t?: Transaction) {
|
||||
const previewIcon = getPreviewFromIcons(this.videoObject)
|
||||
if (!previewIcon) return
|
||||
|
||||
const previewModel = updateRemoteVideoThumbnail({
|
||||
fileUrl: previewIcon.url,
|
||||
video,
|
||||
type: ThumbnailType.PREVIEW,
|
||||
size: previewIcon,
|
||||
onDisk: false // Lazy download remote previews
|
||||
})
|
||||
|
||||
await video.addAndSaveThumbnail(previewModel, t)
|
||||
}
|
||||
|
||||
protected async setTags (video: MVideoFullLight, t: Transaction) {
|
||||
const tags = getTagsFromObject(this.videoObject)
|
||||
await setVideoTags({ video, tags, transaction: t })
|
||||
}
|
||||
|
||||
protected async setTrackers (video: MVideoFullLight, t: Transaction) {
|
||||
const trackers = getTrackerUrls(this.videoObject, video)
|
||||
await setVideoTrackers({ video, trackers, transaction: t })
|
||||
}
|
||||
|
||||
protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) {
|
||||
const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
|
||||
|
||||
let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject)
|
||||
.map(a => new VideoCaptionModel(a) as MVideoCaption)
|
||||
|
||||
for (const existingCaption of existingCaptions) {
|
||||
// Only keep captions that do not already exist
|
||||
const filtered = captionsToCreate.filter(c => !c.isEqual(existingCaption))
|
||||
|
||||
// This caption already exists, we don't need to destroy and create it
|
||||
if (filtered.length !== captionsToCreate.length) {
|
||||
captionsToCreate = filtered
|
||||
continue
|
||||
}
|
||||
|
||||
// Destroy this caption that does not exist anymore
|
||||
await existingCaption.destroy({ transaction: t })
|
||||
}
|
||||
|
||||
for (const captionToCreate of captionsToCreate) {
|
||||
await captionToCreate.save({ transaction: t })
|
||||
}
|
||||
}
|
||||
|
||||
protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) {
|
||||
const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t)
|
||||
if (existingStoryboard) await existingStoryboard.destroy({ transaction: t })
|
||||
|
||||
const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
|
||||
if (!storyboardAttributes) return
|
||||
|
||||
return StoryboardModel.create(storyboardAttributes, { transaction: t })
|
||||
}
|
||||
|
||||
protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
|
||||
const attributes = getLiveAttributesFromObject(video, this.videoObject)
|
||||
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
|
||||
|
||||
await VideoLiveScheduleModel.deleteAllOfLiveId(videoLive.id, transaction)
|
||||
videoLive.LiveSchedules = []
|
||||
|
||||
for (const scheduleAttributes of getLiveSchedulesAttributesFromObject(videoLive, this.videoObject)) {
|
||||
const scheduleModel = new VideoLiveScheduleModel(scheduleAttributes)
|
||||
|
||||
videoLive.LiveSchedules.push(await scheduleModel.save({ transaction }))
|
||||
}
|
||||
}
|
||||
|
||||
protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
|
||||
const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
|
||||
const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
|
||||
|
||||
// Remove video files that do not exist anymore
|
||||
await deleteAllModels(filterNonExistingModels(video.VideoFiles || [], newVideoFiles), t)
|
||||
|
||||
// Update or add other one
|
||||
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
|
||||
video.VideoFiles = await Promise.all(upsertTasks)
|
||||
}
|
||||
|
||||
protected async updateChapters (video: MVideoFullLight) {
|
||||
if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
|
||||
|
||||
const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
|
||||
if (!isVideoChaptersObjectValid(body)) {
|
||||
logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
|
||||
|
||||
return retryTransactionWrapper(() => {
|
||||
return sequelizeTypescript.transaction(async t => {
|
||||
const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
|
||||
|
||||
await replaceChapters({ chapters, transaction: t, video })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
protected async upsertPlayerSettings (video: MVideoFullLight) {
|
||||
if (typeof this.videoObject.playerSettings !== 'string') return
|
||||
|
||||
await upsertAPPlayerSettings({
|
||||
settingsObject: this.videoObject.playerSettings,
|
||||
video,
|
||||
channel: undefined,
|
||||
contextUrl: video.url
|
||||
})
|
||||
}
|
||||
|
||||
protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
|
||||
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
|
||||
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
|
||||
|
||||
// Remove video playlists that do not exist anymore
|
||||
await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t)
|
||||
|
||||
const oldPlaylists = video.VideoStreamingPlaylists
|
||||
video.VideoStreamingPlaylists = []
|
||||
|
||||
for (const playlistAttributes of streamingPlaylistAttributes) {
|
||||
const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
|
||||
streamingPlaylistModel.Video = video
|
||||
|
||||
await this.setStreamingPlaylistFiles(oldPlaylists, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
|
||||
|
||||
video.VideoStreamingPlaylists.push(streamingPlaylistModel)
|
||||
}
|
||||
}
|
||||
|
||||
private async insertOrReplaceStreamingPlaylist (attributes: CreationAttributes<VideoStreamingPlaylistModel>, t: Transaction) {
|
||||
const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
|
||||
|
||||
return streamingPlaylist as MStreamingPlaylistFilesVideo
|
||||
}
|
||||
|
||||
private getStreamingPlaylistFiles (oldPlaylists: MStreamingPlaylistFiles[], type: VideoStreamingPlaylistType_Type) {
|
||||
const playlist = oldPlaylists.find(s => s.type === type)
|
||||
if (!playlist) return []
|
||||
|
||||
return playlist.VideoFiles
|
||||
}
|
||||
|
||||
private async setStreamingPlaylistFiles (
|
||||
oldPlaylists: MStreamingPlaylistFiles[],
|
||||
playlistModel: MStreamingPlaylistFilesVideo,
|
||||
tagObjects: ActivityTagObject[],
|
||||
t: Transaction
|
||||
) {
|
||||
const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type)
|
||||
|
||||
const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
|
||||
|
||||
await deleteAllModels(filterNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles), t)
|
||||
|
||||
// Update or add other one
|
||||
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
|
||||
playlistModel.VideoFiles = await Promise.all(upsertTasks)
|
||||
}
|
||||
|
||||
protected async setAutomaticTags (options: {
|
||||
video: MVideo
|
||||
oldVideo?: Pick<MVideo, 'name' | 'description'>
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const { video, transaction, oldVideo } = options
|
||||
|
||||
if (oldVideo && video.name === oldVideo.name && video.description === oldVideo.description) return
|
||||
|
||||
const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction })
|
||||
await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction })
|
||||
}
|
||||
}
|
||||
70
server/core/lib/activitypub/videos/shared/creator.ts
Normal file
70
server/core/lib/activitypub/videos/shared/creator.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { VideoObject } from '@peertube/peertube-models'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
|
||||
import { APVideoAbstractBuilder } from './abstract-builder.js'
|
||||
import { getVideoAttributesFromObject } from './object-to-model-attributes.js'
|
||||
|
||||
export class APVideoCreator extends APVideoAbstractBuilder {
|
||||
protected lTags: LoggerTagsFn
|
||||
|
||||
constructor (protected readonly videoObject: VideoObject) {
|
||||
super()
|
||||
|
||||
this.lTags = loggerTagsFactory('ap', 'video', 'create', this.videoObject.uuid, this.videoObject.id)
|
||||
}
|
||||
|
||||
async create () {
|
||||
logger.debug('Adding remote video %s.', this.videoObject.id, { ...this.videoObject, ...this.lTags() })
|
||||
|
||||
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
|
||||
const channel = channelActor.VideoChannel
|
||||
channel.Actor = channelActor
|
||||
|
||||
const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
|
||||
const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail
|
||||
|
||||
const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
|
||||
const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
|
||||
videoCreated.VideoChannel = channel
|
||||
|
||||
await this.setThumbnail(videoCreated, t)
|
||||
await this.setPreview(videoCreated, t)
|
||||
await this.setWebVideoFiles(videoCreated, t)
|
||||
await this.setStreamingPlaylists(videoCreated, t)
|
||||
await this.setTags(videoCreated, t)
|
||||
await this.setTrackers(videoCreated, t)
|
||||
await this.insertOrReplaceCaptions(videoCreated, t)
|
||||
await this.insertOrReplaceLive(videoCreated, t)
|
||||
await this.insertOrReplaceStoryboard(videoCreated, t)
|
||||
|
||||
await this.setAutomaticTags({ video: videoCreated, transaction: t })
|
||||
|
||||
// We added a video in this channel, set it as updated
|
||||
await channel.setAsUpdated(t)
|
||||
|
||||
const autoBlacklisted = await autoBlacklistVideoIfNeeded({
|
||||
video: videoCreated,
|
||||
user: undefined,
|
||||
isRemote: true,
|
||||
isNew: true,
|
||||
isNewFile: true,
|
||||
transaction: t
|
||||
})
|
||||
|
||||
logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
|
||||
|
||||
Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
})
|
||||
|
||||
await this.updateChapters(videoCreated)
|
||||
await this.upsertPlayerSettings(videoCreated)
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
}
|
||||
}
|
||||
6
server/core/lib/activitypub/videos/shared/index.ts
Normal file
6
server/core/lib/activitypub/videos/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './abstract-builder.js'
|
||||
export * from './creator.js'
|
||||
export * from './object-to-model-attributes.js'
|
||||
export * from './trackers.js'
|
||||
export * from './url-to-object.js'
|
||||
export * from './video-sync-attributes.js'
|
||||
@@ -0,0 +1,398 @@
|
||||
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
ActivityHashTagObject,
|
||||
ActivityMagnetUrlObject,
|
||||
ActivityPlaylistSegmentHashesObject,
|
||||
ActivityPlaylistUrlObject,
|
||||
ActivitySensitiveTagObject,
|
||||
ActivityTagObject,
|
||||
ActivityUrlObject,
|
||||
ActivityVideoUrlObject,
|
||||
NSFWFlag,
|
||||
stringToNSFWFlag,
|
||||
VideoFileFormatFlag,
|
||||
VideoFileStream,
|
||||
VideoObject,
|
||||
VideoPrivacy,
|
||||
VideoResolution,
|
||||
VideoStreamingPlaylistType
|
||||
} from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
|
||||
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { exists, isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js'
|
||||
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||
import { getExtFromMimetype } from '@server/helpers/video.js'
|
||||
import { MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants.js'
|
||||
import { generateTorrentFileName } from '@server/lib/paths.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { VideoFileModel } from '@server/models/video/video-file.js'
|
||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import {
|
||||
isStreamingPlaylist,
|
||||
MChannelId,
|
||||
MStreamingPlaylistVideo,
|
||||
MVideo,
|
||||
MVideoFile,
|
||||
MVideoId,
|
||||
MVideoLive
|
||||
} from '@server/types/models/index.js'
|
||||
import { decode as magnetUriDecode } from 'magnet-uri'
|
||||
import { basename, extname } from 'path'
|
||||
import { getDurationFromActivityStream } from '../../activity.js'
|
||||
|
||||
export function getThumbnailFromIcons (videoObject: VideoObject) {
|
||||
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minRemoteWidth)
|
||||
// Fallback if there are not valid icons
|
||||
if (validIcons.length === 0) validIcons = videoObject.icon
|
||||
|
||||
return minBy(validIcons, 'width')
|
||||
}
|
||||
|
||||
export function getPreviewFromIcons (videoObject: VideoObject) {
|
||||
const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minRemoteWidth)
|
||||
|
||||
return maxBy(validIcons, 'width')
|
||||
}
|
||||
|
||||
export function getTagsFromObject (videoObject: VideoObject) {
|
||||
return videoObject.tag
|
||||
.filter(isAPHashTagObject)
|
||||
.map(t => t.name)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getFileAttributesFromUrl (
|
||||
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
|
||||
urls: (ActivityTagObject | ActivityUrlObject)[]
|
||||
) {
|
||||
const fileUrls = urls.filter(u => isAPVideoUrlObject(u))
|
||||
if (fileUrls.length === 0) return []
|
||||
|
||||
const attributes: FilteredModelAttributes<VideoFileModel>[] = []
|
||||
for (const fileUrl of fileUrls) {
|
||||
// Fetch associated metadata url, if any
|
||||
const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
|
||||
.find(u => {
|
||||
return u.height === fileUrl.height &&
|
||||
u.fps === fileUrl.fps &&
|
||||
u.rel.includes(fileUrl.mediaType)
|
||||
})
|
||||
|
||||
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
|
||||
const resolution = fileUrl.height
|
||||
|
||||
const [ videoId, videoStreamingPlaylistId ] = isStreamingPlaylist(videoOrPlaylist)
|
||||
? [ null, videoOrPlaylist.id ]
|
||||
: [ videoOrPlaylist.id, null ]
|
||||
|
||||
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl })
|
||||
|
||||
const attribute: Partial<AttributesOnly<MVideoFile>> = {
|
||||
extname,
|
||||
resolution,
|
||||
|
||||
size: fileUrl.size,
|
||||
fps: exists(fileUrl.fps) && fileUrl.fps >= 0
|
||||
? fileUrl.fps
|
||||
: -1,
|
||||
|
||||
metadataUrl: metadata?.href,
|
||||
|
||||
width: fileUrl.width,
|
||||
height: fileUrl.height,
|
||||
|
||||
// Use the name of the remote file because we don't proxify video file requests
|
||||
filename: basename(fileUrl.href),
|
||||
fileUrl: fileUrl.href,
|
||||
|
||||
infoHash,
|
||||
torrentFilename,
|
||||
torrentUrl,
|
||||
|
||||
formatFlags: buildFileFormatFlags(fileUrl, isStreamingPlaylist(videoOrPlaylist)),
|
||||
streams: buildFileStreams(fileUrl, resolution),
|
||||
|
||||
// This is a video file owned by a video or by a streaming playlist
|
||||
videoId,
|
||||
videoStreamingPlaylistId
|
||||
}
|
||||
|
||||
attributes.push(attribute)
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
function buildFileFormatFlags (fileUrl: ActivityVideoUrlObject, isStreamingPlaylist: boolean) {
|
||||
const attachment = fileUrl.attachment || []
|
||||
|
||||
const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'peertube_format_flag')
|
||||
if (formatHints.length === 0) {
|
||||
return isStreamingPlaylist
|
||||
? VideoFileFormatFlag.FRAGMENTED
|
||||
: VideoFileFormatFlag.WEB_VIDEO
|
||||
}
|
||||
|
||||
let formatFlags = VideoFileFormatFlag.NONE
|
||||
|
||||
for (const hint of formatHints) {
|
||||
if (hint.value === 'fragmented') formatFlags |= VideoFileFormatFlag.FRAGMENTED
|
||||
else if (hint.value === 'web-video') formatFlags |= VideoFileFormatFlag.WEB_VIDEO
|
||||
}
|
||||
|
||||
return formatFlags
|
||||
}
|
||||
|
||||
function buildFileStreams (fileUrl: ActivityVideoUrlObject, resolution: number) {
|
||||
const attachment = fileUrl.attachment || []
|
||||
|
||||
const formatHints = attachment.filter(a => a.type === 'PropertyValue' && a.name === 'ffprobe_codec_type')
|
||||
|
||||
if (formatHints.length === 0) {
|
||||
if (resolution === VideoResolution.H_NOVIDEO) return VideoFileStream.AUDIO
|
||||
|
||||
return VideoFileStream.VIDEO | VideoFileStream.AUDIO
|
||||
}
|
||||
|
||||
let streams = VideoFileStream.NONE
|
||||
|
||||
for (const hint of formatHints) {
|
||||
if (hint.value === 'audio') streams |= VideoFileStream.AUDIO
|
||||
else if (hint.value === 'video') streams |= VideoFileStream.VIDEO
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u))
|
||||
if (playlistUrls.length === 0) return []
|
||||
|
||||
const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
|
||||
for (const playlistUrlObject of playlistUrls) {
|
||||
const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
|
||||
|
||||
const files = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u))
|
||||
|
||||
const attribute = {
|
||||
type: VideoStreamingPlaylistType.HLS,
|
||||
|
||||
playlistFilename: basename(playlistUrlObject.href),
|
||||
playlistUrl: playlistUrlObject.href,
|
||||
|
||||
segmentsSha256Filename: segmentsSha256UrlObject
|
||||
? basename(segmentsSha256UrlObject.href)
|
||||
: null,
|
||||
|
||||
segmentsSha256Url: segmentsSha256UrlObject?.href ?? null,
|
||||
|
||||
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
|
||||
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
|
||||
videoId: video.id,
|
||||
|
||||
tagAPObject: playlistUrlObject.tag
|
||||
}
|
||||
|
||||
attributes.push(attribute)
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
export function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
return {
|
||||
saveReplay: videoObject.liveSaveReplay,
|
||||
permanentLive: videoObject.permanentLive,
|
||||
latencyMode: videoObject.latencyMode,
|
||||
videoId: video.id
|
||||
}
|
||||
}
|
||||
export function getLiveSchedulesAttributesFromObject (live: MVideoLive, videoObject: VideoObject) {
|
||||
const schedules = videoObject.schedules || []
|
||||
|
||||
return schedules.map(s => ({
|
||||
liveVideoId: live.id,
|
||||
startAt: s.startDate
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
return videoObject.subtitleLanguage.map(c => {
|
||||
// This field is sanitized in validators
|
||||
const url = c.url
|
||||
|
||||
const filename = VideoCaptionModel.generateCaptionName(c.identifier)
|
||||
|
||||
return {
|
||||
videoId: video.id,
|
||||
filename,
|
||||
language: c.identifier,
|
||||
automaticallyGenerated: c.automaticallyGenerated === true,
|
||||
fileUrl: url.find(u => u.mediaType === 'text/vtt')?.href,
|
||||
m3u8Filename: VideoCaptionModel.generateM3U8Filename(filename),
|
||||
m3u8Url: url.find(u => u.mediaType === 'application/x-mpegURL')?.href
|
||||
} as Partial<AttributesOnly<VideoCaptionModel>>
|
||||
})
|
||||
}
|
||||
|
||||
export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||
if (!isArray(videoObject.preview)) return undefined
|
||||
|
||||
const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard'))
|
||||
if (!storyboard) return undefined
|
||||
|
||||
const url = arrayify(storyboard.url).find(u => u.mediaType === 'image/jpeg')
|
||||
|
||||
return {
|
||||
filename: generateImageFilename(extname(url.href)),
|
||||
totalHeight: url.height,
|
||||
totalWidth: url.width,
|
||||
spriteHeight: url.tileHeight,
|
||||
spriteWidth: url.tileWidth,
|
||||
spriteDuration: getDurationFromActivityStream(url.tileDuration),
|
||||
fileUrl: url.href,
|
||||
videoId: video.id
|
||||
}
|
||||
}
|
||||
|
||||
export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
|
||||
const privacy = hasAPPublic(to)
|
||||
? VideoPrivacy.PUBLIC
|
||||
: VideoPrivacy.UNLISTED
|
||||
|
||||
const language = videoObject.language?.identifier
|
||||
|
||||
const category = videoObject.category
|
||||
? parseInt(videoObject.category.identifier, 10)
|
||||
: undefined
|
||||
|
||||
const licence = videoObject.licence
|
||||
? parseInt(videoObject.licence.identifier, 10)
|
||||
: undefined
|
||||
|
||||
const description = videoObject.content || null
|
||||
const support = videoObject.support || null
|
||||
|
||||
return {
|
||||
name: videoObject.name,
|
||||
uuid: videoObject.uuid,
|
||||
url: videoObject.id,
|
||||
category,
|
||||
licence,
|
||||
language,
|
||||
description,
|
||||
support,
|
||||
|
||||
nsfw: videoObject.sensitive,
|
||||
nsfwSummary: videoObject.sensitive
|
||||
? (videoObject.summary ?? null)
|
||||
: null,
|
||||
nsfwFlags: videoObject.sensitive
|
||||
? getNSFWFlags(videoObject.tag)
|
||||
: NSFWFlag.NONE,
|
||||
|
||||
commentsPolicy: videoObject.commentsPolicy,
|
||||
|
||||
downloadEnabled: videoObject.downloadEnabled,
|
||||
waitTranscoding: videoObject.waitTranscoding,
|
||||
isLive: videoObject.isLiveBroadcast,
|
||||
state: videoObject.state,
|
||||
aspectRatio: videoObject.aspectRatio,
|
||||
channelId: videoChannel.id,
|
||||
duration: getDurationFromActivityStream(videoObject.duration),
|
||||
createdAt: new Date(videoObject.published),
|
||||
publishedAt: new Date(videoObject.published),
|
||||
|
||||
originallyPublishedAt: videoObject.originallyPublishedAt
|
||||
? new Date(videoObject.originallyPublishedAt)
|
||||
: null,
|
||||
|
||||
inputFileUpdatedAt: videoObject.uploadDate
|
||||
? new Date(videoObject.uploadDate)
|
||||
: null,
|
||||
|
||||
updatedAt: new Date(videoObject.updated),
|
||||
views: videoObject.views,
|
||||
remote: true,
|
||||
privacy
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
|
||||
return !!MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType]
|
||||
}
|
||||
|
||||
function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
|
||||
return url?.mediaType === 'application/x-mpegURL'
|
||||
}
|
||||
|
||||
function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
|
||||
return tag?.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
|
||||
}
|
||||
|
||||
function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
|
||||
return url?.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
|
||||
}
|
||||
|
||||
function isAPHashTagObject (tag: any): tag is ActivityHashTagObject {
|
||||
return tag?.type === 'Hashtag'
|
||||
}
|
||||
|
||||
function isAPSensitiveTagObject (tag: any): tag is ActivitySensitiveTagObject {
|
||||
return tag?.type === 'SensitiveTag'
|
||||
}
|
||||
|
||||
function getTorrentRelatedInfo (options: {
|
||||
videoOrPlaylist: MVideo | MStreamingPlaylistVideo
|
||||
urls: (ActivityTagObject | ActivityUrlObject)[]
|
||||
fileUrl: ActivityVideoUrlObject
|
||||
}) {
|
||||
const { urls, fileUrl, videoOrPlaylist } = options
|
||||
|
||||
// Fetch associated magnet uri
|
||||
const magnet = urls.filter(isAPMagnetUrlObject)
|
||||
.find(u => u.height === fileUrl.height)
|
||||
|
||||
if (!magnet) {
|
||||
return {
|
||||
torrentUrl: null,
|
||||
torrentFilename: null,
|
||||
infoHash: null
|
||||
}
|
||||
}
|
||||
|
||||
const magnetParsed = magnetUriDecode(magnet.href)
|
||||
if (magnetParsed && isVideoFileInfoHashValid(magnetParsed.infoHash) === false) {
|
||||
throw new Error('Info hash is not valid in magnet URI ' + magnet.href)
|
||||
}
|
||||
|
||||
const torrentUrl = Array.isArray(magnetParsed.xs)
|
||||
? magnetParsed.xs[0]
|
||||
: magnetParsed.xs
|
||||
|
||||
return {
|
||||
torrentUrl,
|
||||
|
||||
// Use our own torrent name since we proxify torrent requests
|
||||
torrentFilename: generateTorrentFileName(videoOrPlaylist, fileUrl.height),
|
||||
|
||||
infoHash: magnetParsed.infoHash
|
||||
}
|
||||
}
|
||||
|
||||
function getNSFWFlags (tags: ActivityTagObject[]) {
|
||||
return tags.filter(t => isAPSensitiveTagObject(t))
|
||||
.map(t => stringToNSFWFlag(t.name))
|
||||
.filter(t => !!t)
|
||||
.reduce((acc, t) => acc | t, 0)
|
||||
}
|
||||
43
server/core/lib/activitypub/videos/shared/trackers.ts
Normal file
43
server/core/lib/activitypub/videos/shared/trackers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Transaction } from 'sequelize'
|
||||
import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { isArray } from '@server/helpers/custom-validators/misc.js'
|
||||
import { REMOTE_SCHEME } from '@server/initializers/constants.js'
|
||||
import { TrackerModel } from '@server/models/server/tracker.js'
|
||||
import { MVideo, MVideoWithHost } from '@server/types/models/index.js'
|
||||
import { ActivityTrackerUrlObject, VideoObject } from '@peertube/peertube-models'
|
||||
import { buildRemoteUrl } from '../../url.js'
|
||||
|
||||
function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
|
||||
let wsFound = false
|
||||
|
||||
const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
|
||||
.map((u: ActivityTrackerUrlObject) => {
|
||||
if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
|
||||
|
||||
return u.href
|
||||
})
|
||||
|
||||
if (wsFound) return trackers
|
||||
|
||||
return [
|
||||
buildRemoteUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
|
||||
buildRemoteUrl(video, '/tracker/announce')
|
||||
]
|
||||
}
|
||||
|
||||
async function setVideoTrackers (options: {
|
||||
video: MVideo
|
||||
trackers: string[]
|
||||
transaction: Transaction
|
||||
}) {
|
||||
const { video, trackers, transaction } = options
|
||||
|
||||
const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
|
||||
|
||||
await video.$set('Trackers', trackerInstances, { transaction })
|
||||
}
|
||||
|
||||
export {
|
||||
getTrackerUrls,
|
||||
setVideoTrackers
|
||||
}
|
||||
25
server/core/lib/activitypub/videos/shared/url-to-object.ts
Normal file
25
server/core/lib/activitypub/videos/shared/url-to-object.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { VideoObject } from '@peertube/peertube-models'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { checkUrlsSameHost } from '../../url.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video')
|
||||
|
||||
async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
|
||||
logger.info('Fetching remote video %s.', videoUrl, lTags(videoUrl))
|
||||
|
||||
const { statusCode, body } = await fetchAP<any>(videoUrl)
|
||||
|
||||
if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
|
||||
logger.debug('Remote video JSON is not valid.', { body, ...lTags(videoUrl) })
|
||||
|
||||
return { statusCode, videoObject: undefined }
|
||||
}
|
||||
|
||||
return { statusCode, videoObject: body }
|
||||
}
|
||||
|
||||
export {
|
||||
fetchRemoteVideo
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ActivityPubOrderedCollection, ActivitypubHttpFetcherPayload, VideoObject } from '@peertube/peertube-models'
|
||||
import { runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { VideoCommentModel } from '@server/models/video/video-comment.js'
|
||||
import { VideoShareModel } from '@server/models/video/video-share.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { fetchAP } from '../../activity.js'
|
||||
import { crawlCollectionPage } from '../../crawl.js'
|
||||
import { addVideoShares } from '../../share.js'
|
||||
import { addVideoComments } from '../../video-comments.js'
|
||||
|
||||
const lTags = loggerTagsFactory('ap', 'video')
|
||||
|
||||
export type SyncParam = {
|
||||
rates: boolean
|
||||
shares: boolean
|
||||
comments: boolean
|
||||
refreshVideo?: boolean
|
||||
}
|
||||
|
||||
export async function syncVideoExternalAttributes (
|
||||
video: MVideo,
|
||||
fetchedVideo: VideoObject,
|
||||
syncParam: Pick<SyncParam, 'rates' | 'shares' | 'comments'>
|
||||
) {
|
||||
logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
|
||||
|
||||
const ratePromise = updateVideoRates(video, fetchedVideo)
|
||||
if (syncParam.rates) await ratePromise
|
||||
|
||||
await syncShares(video, fetchedVideo, syncParam.shares)
|
||||
|
||||
await syncComments(video, fetchedVideo, syncParam.comments)
|
||||
}
|
||||
|
||||
export async function updateVideoRates (video: MVideo, fetchedVideo: VideoObject) {
|
||||
const [ likes, dislikes ] = await Promise.all([
|
||||
getRatesCount('like', video, fetchedVideo),
|
||||
getRatesCount('dislike', video, fetchedVideo)
|
||||
])
|
||||
|
||||
return runInReadCommittedTransaction(async t => {
|
||||
await VideoModel.updateRatesOf(video.id, 'like', likes, t)
|
||||
await VideoModel.updateRatesOf(video.id, 'dislike', dislikes, t)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getRatesCount (type: 'like' | 'dislike', video: MVideo, fetchedVideo: VideoObject) {
|
||||
const uri = type === 'like'
|
||||
? fetchedVideo.likes
|
||||
: fetchedVideo.dislikes
|
||||
|
||||
if (!uri) return
|
||||
|
||||
logger.info('Sync %s of video %s', type, video.url)
|
||||
|
||||
const { body } = await fetchAP<ActivityPubOrderedCollection<any>>(uri)
|
||||
|
||||
if (isNaN(body.totalItems)) {
|
||||
logger.error('Cannot sync %s of video %s, totalItems is not a number', type, video.url, { body })
|
||||
return
|
||||
}
|
||||
|
||||
return body.totalItems
|
||||
}
|
||||
|
||||
function syncShares (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
|
||||
const uri = fetchedVideo.shares
|
||||
if (!uri) return
|
||||
|
||||
if (!isSync) {
|
||||
return createJob({ uri, videoId: video.id, type: 'video-shares' })
|
||||
}
|
||||
|
||||
const handler = items => addVideoShares(items, video)
|
||||
const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
|
||||
|
||||
return crawlCollectionPage<string>(uri, handler, cleaner)
|
||||
.catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
|
||||
}
|
||||
|
||||
function syncComments (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
|
||||
const uri = fetchedVideo.comments
|
||||
if (!uri) return
|
||||
|
||||
if (!isSync) {
|
||||
return createJob({ uri, videoId: video.id, type: 'video-comments' })
|
||||
}
|
||||
|
||||
const handler = items => addVideoComments(items)
|
||||
const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
|
||||
|
||||
return crawlCollectionPage<string>(uri, handler, cleaner)
|
||||
.catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
|
||||
}
|
||||
|
||||
function createJob (payload: ActivitypubHttpFetcherPayload) {
|
||||
return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
|
||||
}
|
||||
192
server/core/lib/activitypub/videos/updater.ts
Normal file
192
server/core/lib/activitypub/videos/updater.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { VideoObject, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||
import { Notifier } from '@server/lib/notifier/index.js'
|
||||
import { PeerTubeSocket } from '@server/lib/peertube-socket.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live.js'
|
||||
import {
|
||||
MActorHost,
|
||||
MChannelAccountLight,
|
||||
MChannelId,
|
||||
MVideoAccountLightBlacklistAllFiles,
|
||||
MVideoFullLight
|
||||
} from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { haveActorsSameRemoteHost } from '../actors/check-actor.js'
|
||||
import { APVideoAbstractBuilder, getVideoAttributesFromObject, updateVideoRates } from './shared/index.js'
|
||||
|
||||
export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||
private readonly wasPrivateVideo: boolean
|
||||
private readonly wasUnlistedVideo: boolean
|
||||
|
||||
private readonly oldVideoChannel: MChannelAccountLight
|
||||
|
||||
protected lTags: LoggerTagsFn
|
||||
|
||||
constructor (
|
||||
protected readonly videoObject: VideoObject,
|
||||
private readonly video: MVideoAccountLightBlacklistAllFiles
|
||||
) {
|
||||
super()
|
||||
|
||||
this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
|
||||
this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
|
||||
|
||||
this.oldVideoChannel = this.video.VideoChannel
|
||||
|
||||
this.lTags = loggerTagsFactory('ap', 'video', 'update', video.uuid, video.url)
|
||||
}
|
||||
|
||||
async update (overrideTo?: string[]) {
|
||||
logger.debug(
|
||||
'Updating remote video "%s".',
|
||||
this.videoObject.uuid,
|
||||
{ videoObject: this.videoObject, ...this.lTags() }
|
||||
)
|
||||
|
||||
const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt
|
||||
|
||||
try {
|
||||
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
|
||||
|
||||
this.checkChannelUpdateOrThrow(channelActor)
|
||||
|
||||
const oldState = this.video.state
|
||||
const oldVideo = { name: this.video.name, description: this.video.description }
|
||||
|
||||
const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo)
|
||||
|
||||
await runInReadCommittedTransaction(async t => {
|
||||
await this.setWebVideoFiles(videoUpdated, t)
|
||||
await this.setStreamingPlaylists(videoUpdated, t)
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
|
||||
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
|
||||
runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
|
||||
runInReadCommittedTransaction(t => this.setAutomaticTags({ video: videoUpdated, transaction: t, oldVideo })),
|
||||
runInReadCommittedTransaction(t => {
|
||||
return Promise.all([
|
||||
this.setPreview(videoUpdated, t),
|
||||
this.setThumbnail(videoUpdated, t)
|
||||
])
|
||||
}),
|
||||
this.setOrDeleteLive(videoUpdated)
|
||||
])
|
||||
|
||||
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
|
||||
|
||||
await this.updateChapters(videoUpdated)
|
||||
await this.upsertPlayerSettings(videoUpdated)
|
||||
|
||||
await autoBlacklistVideoIfNeeded({
|
||||
video: videoUpdated,
|
||||
user: undefined,
|
||||
isRemote: true,
|
||||
isNew: false,
|
||||
isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt,
|
||||
transaction: undefined
|
||||
})
|
||||
|
||||
await updateVideoRates(videoUpdated, this.videoObject)
|
||||
|
||||
// Notify our users?
|
||||
if (this.wasPrivateVideo || this.wasUnlistedVideo) {
|
||||
Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(videoUpdated)
|
||||
}
|
||||
|
||||
if (videoUpdated.isLive && oldState !== videoUpdated.state) {
|
||||
PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
|
||||
Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(videoUpdated)
|
||||
}
|
||||
|
||||
Hooks.runAction('action:activity-pub.remote-video.updated', { video: videoUpdated, videoAPObject: this.videoObject })
|
||||
|
||||
logger.info('Remote video with uuid %s updated', this.videoObject.uuid, this.lTags())
|
||||
|
||||
return videoUpdated
|
||||
} catch (err) {
|
||||
await this.catchUpdateError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check we can update the channel: we trust the remote server
|
||||
private checkChannelUpdateOrThrow (newChannelActor: MActorHost) {
|
||||
if (haveActorsSameRemoteHost(this.oldVideoChannel.Actor, newChannelActor) !== true) {
|
||||
throw new Error(`Actor ${this.oldVideoChannel.Actor.url} is not on the same host as ${newChannelActor.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
private updateVideo (channel: MChannelId, transaction?: Transaction, overrideTo?: string[]) {
|
||||
const to = overrideTo || this.videoObject.to
|
||||
const videoData = getVideoAttributesFromObject(channel, this.videoObject, to)
|
||||
|
||||
this.video.name = videoData.name
|
||||
this.video.uuid = videoData.uuid
|
||||
this.video.url = videoData.url
|
||||
this.video.category = videoData.category
|
||||
this.video.licence = videoData.licence
|
||||
this.video.language = videoData.language
|
||||
this.video.description = videoData.description
|
||||
this.video.support = videoData.support
|
||||
this.video.nsfw = videoData.nsfw
|
||||
this.video.nsfwSummary = videoData.nsfwSummary
|
||||
this.video.nsfwFlags = videoData.nsfwFlags
|
||||
this.video.commentsPolicy = videoData.commentsPolicy
|
||||
this.video.downloadEnabled = videoData.downloadEnabled
|
||||
this.video.waitTranscoding = videoData.waitTranscoding
|
||||
this.video.state = videoData.state
|
||||
this.video.duration = videoData.duration
|
||||
this.video.createdAt = videoData.createdAt
|
||||
this.video.publishedAt = videoData.publishedAt
|
||||
this.video.originallyPublishedAt = videoData.originallyPublishedAt
|
||||
this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt
|
||||
this.video.privacy = videoData.privacy
|
||||
this.video.channelId = videoData.channelId
|
||||
this.video.views = videoData.views
|
||||
this.video.isLive = videoData.isLive
|
||||
this.video.aspectRatio = videoData.aspectRatio
|
||||
|
||||
// Ensures we update the updatedAt attribute, even if main attributes did not change
|
||||
this.video.changed('updatedAt', true)
|
||||
|
||||
return this.video.save({ transaction }) as Promise<MVideoFullLight>
|
||||
}
|
||||
|
||||
private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
|
||||
await this.insertOrReplaceCaptions(videoUpdated, t)
|
||||
}
|
||||
|
||||
private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) {
|
||||
await this.insertOrReplaceStoryboard(videoUpdated, t)
|
||||
}
|
||||
|
||||
private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
|
||||
if (!this.video.isLive) return
|
||||
|
||||
if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction)
|
||||
|
||||
// Delete existing live if it exists
|
||||
await VideoLiveModel.destroy({
|
||||
where: {
|
||||
videoId: this.video.id
|
||||
},
|
||||
transaction
|
||||
})
|
||||
|
||||
videoUpdated.VideoLive = null
|
||||
}
|
||||
|
||||
private async catchUpdateError (err: Error) {
|
||||
if (this.video !== undefined) {
|
||||
await resetSequelizeInstance(this.video)
|
||||
}
|
||||
|
||||
// This is just a debug because we will retry the insert
|
||||
logger.debug('Cannot update the remote video.', { err, ...this.lTags() })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
86
server/core/lib/actor-follow-health-cache.ts
Normal file
86
server/core/lib/actor-follow-health-cache.ts
Normal file
@@ -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
|
||||
}
|
||||
251
server/core/lib/auth/external-auth.ts
Normal file
251
server/core/lib/auth/external-auth.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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'
|
||||
import { isDevInstance } from '@peertube/peertube-node-utils'
|
||||
|
||||
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, externalRedirectUri } = 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)
|
||||
}
|
||||
}
|
||||
|
||||
if (externalRedirectUri) {
|
||||
const url = new URL(externalRedirectUri)
|
||||
url.searchParams.set('externalAuthToken', bypassToken)
|
||||
url.searchParams.set('username', user.username)
|
||||
res.redirect(url.href)
|
||||
} else {
|
||||
const query = `externalAuthToken=${bypassToken}&username=${user.username}`
|
||||
|
||||
if (isDevInstance() && process.env.ANGULAR_CLIENT_ENABLED === 'true') {
|
||||
res.redirect(`http://localhost:3000/login?${query}`)
|
||||
} else {
|
||||
res.redirect(`/login?${query}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
334
server/core/lib/auth/oauth-model.ts
Normal file
334
server/core/lib/auth/oauth-model.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { AccessDeniedError } from '@node-oauth/oauth2-server'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { isUserPasswordTooLong } from '@server/helpers/custom-validators/users.js'
|
||||
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 express from 'express'
|
||||
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, getByEmailPermissive } from '../user.js'
|
||||
import { ExternalUser } from './external-auth.js'
|
||||
import { AccountBlockedError, EmailNotVerifiedError, TooLongPasswordError } from './oauth.js'
|
||||
import { TokensCache } from './tokens-cache.js'
|
||||
|
||||
type TokenInfo = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accessTokenExpiresAt: Date
|
||||
refreshTokenExpiresAt: Date
|
||||
loginDevice: string
|
||||
loginIP: string
|
||||
loginDate: Date
|
||||
lastActivityDevice: string
|
||||
lastActivityIP: string
|
||||
lastActivityDate: 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
|
||||
}
|
||||
|
||||
// Keep this function signature, required by oauth2-server
|
||||
async function getUser (usernameOrEmail?: string, password?: string, options?: {
|
||||
bypassLogin?: BypassLogin
|
||||
req: express.Request
|
||||
}) {
|
||||
const { bypassLogin, req } = options
|
||||
|
||||
// Special treatment coming from a plugin
|
||||
if (bypassLogin?.bypass === true) {
|
||||
logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
|
||||
|
||||
let user = getByEmailPermissive(await UserModel.loadByEmailCaseInsensitive(bypassLogin.user.email), 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(req.t('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, req)
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
|
||||
|
||||
const users = await UserModel.loadByUsernameOrEmailCaseInsensitive(usernameOrEmail)
|
||||
let user: MUserDefault
|
||||
|
||||
if (usernameOrEmail.includes('@')) {
|
||||
user = getByEmailPermissive(users, usernameOrEmail)
|
||||
} else if (users.length === 1) {
|
||||
user = users[0]
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
if (isUserPasswordTooLong(password)) {
|
||||
throw new TooLongPasswordError(req.t('Password is too long. Please reset it using the password reset procedure.'))
|
||||
}
|
||||
|
||||
checkUserValidityOrThrow(user, req)
|
||||
|
||||
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
|
||||
// Keep this message sync with the client
|
||||
// TODO: use custom server code
|
||||
throw new EmailNotVerifiedError(req.t('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 = {
|
||||
...pick(token, [
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'accessTokenExpiresAt',
|
||||
'refreshTokenExpiresAt',
|
||||
'loginDevice',
|
||||
'loginIP',
|
||||
'loginDate',
|
||||
'lastActivityDate',
|
||||
'lastActivityDevice',
|
||||
'lastActivityIP'
|
||||
]),
|
||||
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, req: express.Request) {
|
||||
if (user.blocked) throw new AccountBlockedError(req.t('User is blocked.'))
|
||||
}
|
||||
|
||||
function buildExpiresIn (expiresAt: Date) {
|
||||
return Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000)
|
||||
}
|
||||
303
server/core/lib/auth/oauth.ts
Normal file
303
server/core/lib/auth/oauth.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import OAuth2Server, {
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
Request,
|
||||
Response,
|
||||
UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError
|
||||
} from '@node-oauth/oauth2-server'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@peertube/peertube-models'
|
||||
import { sha1 } from '@peertube/peertube-node-utils'
|
||||
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 express from 'express'
|
||||
import { OTP } from '../../initializers/constants.js'
|
||||
import { Hooks } from '../plugins/hooks.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 TooLongPasswordError extends Error {
|
||||
code = HttpStatusCode.BAD_REQUEST_400
|
||||
name = ServerErrorCode.TOO_LONG_PASSWORD
|
||||
}
|
||||
|
||||
class AccountBlockedError extends Error {
|
||||
code = HttpStatusCode.BAD_REQUEST_400
|
||||
name = ServerErrorCode.ACCOUNT_BLOCKED
|
||||
}
|
||||
|
||||
class EmailNotVerifiedError extends Error {
|
||||
code = HttpStatusCode.BAD_REQUEST_400
|
||||
name = ServerErrorCode.EMAIL_NOT_VERIFIED
|
||||
}
|
||||
|
||||
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 oauthRequest = new Request(req)
|
||||
const { refreshTokenAuthName, bypassLogin } = options
|
||||
|
||||
if (oauthRequest.method !== 'POST') {
|
||||
throw new InvalidRequestError('Invalid request: method must be POST')
|
||||
}
|
||||
|
||||
if (!oauthRequest.is([ 'application/x-www-form-urlencoded' ])) {
|
||||
throw new InvalidRequestError('Invalid request: content must be application/x-www-form-urlencoded')
|
||||
}
|
||||
|
||||
const clientId = oauthRequest.body.client_id
|
||||
const clientSecret = oauthRequest.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 = oauthRequest.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')
|
||||
}
|
||||
|
||||
const ip = req.ip
|
||||
const userAgent = req.headers['user-agent']
|
||||
|
||||
if (grantType === 'password') {
|
||||
return handlePasswordGrant({
|
||||
req,
|
||||
oauthRequest,
|
||||
client,
|
||||
bypassLogin,
|
||||
ip,
|
||||
userAgent
|
||||
})
|
||||
}
|
||||
|
||||
return handleRefreshGrant({
|
||||
req,
|
||||
oauthRequest,
|
||||
client,
|
||||
refreshTokenAuthName,
|
||||
ip,
|
||||
userAgent
|
||||
})
|
||||
}
|
||||
|
||||
function handleOAuthAuthenticate (
|
||||
req: express.Request,
|
||||
res: express.Response
|
||||
) {
|
||||
return oAuthServer.authenticate(new Request(req), new Response(res))
|
||||
}
|
||||
|
||||
export {
|
||||
handleOAuthAuthenticate,
|
||||
handleOAuthToken,
|
||||
InvalidTwoFactorError,
|
||||
MissingTwoFactorError,
|
||||
TooLongPasswordError,
|
||||
AccountBlockedError,
|
||||
EmailNotVerifiedError
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handlePasswordGrant (options: {
|
||||
req: express.Request
|
||||
oauthRequest: Request
|
||||
client: MOAuthClient
|
||||
bypassLogin?: BypassLogin
|
||||
ip: string
|
||||
userAgent: string
|
||||
}) {
|
||||
const { req, oauthRequest, client } = options
|
||||
|
||||
const { bypassLogin, usernameOrEmail, password } = await Hooks.wrapObject({
|
||||
bypassLogin: options.bypassLogin,
|
||||
usernameOrEmail: oauthRequest.body.username,
|
||||
password: oauthRequest.body.password
|
||||
}, 'filter:oauth.password-grant.get-user.params')
|
||||
|
||||
if (!options.oauthRequest.body.username) {
|
||||
throw new InvalidRequestError(req.t('Missing parameter: `username`'))
|
||||
}
|
||||
|
||||
if (!bypassLogin && !options.oauthRequest.body.password) {
|
||||
throw new InvalidRequestError(req.t('Missing parameter: `password`'))
|
||||
}
|
||||
|
||||
const user = await getUser(usernameOrEmail, password, { bypassLogin, req })
|
||||
if (!user) {
|
||||
const registrations = await UserRegistrationModel.listByEmailCaseInsensitiveOrUsername(usernameOrEmail)
|
||||
|
||||
if (registrations.length === 1) {
|
||||
if (registrations[0].state === UserRegistrationState.REJECTED) {
|
||||
throw new RegistrationApprovalRejected(req.t('Registration approval for this account has been rejected'))
|
||||
} else if (registrations[0].state === UserRegistrationState.PENDING) {
|
||||
throw new RegistrationWaitingForApproval(req.t('Registration for this account is awaiting approval'))
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidGrantError(req.t('Invalid grant: user credentials are invalid'))
|
||||
}
|
||||
|
||||
if (user.otpSecret) {
|
||||
if (!options.oauthRequest.headers[OTP.HEADER_NAME]) {
|
||||
throw new MissingTwoFactorError(req.t('Missing two factor header'))
|
||||
}
|
||||
|
||||
if (await isOTPValid({ encryptedSecret: user.otpSecret, token: options.oauthRequest.headers[OTP.HEADER_NAME] }) !== true) {
|
||||
throw new InvalidTwoFactorError(req.t('Invalid two factor header'))
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const token = await buildToken({
|
||||
loginDevice: options.userAgent,
|
||||
loginIP: options.ip,
|
||||
loginDate: now,
|
||||
lastActivityDevice: options.userAgent,
|
||||
lastActivityIP: options.ip,
|
||||
lastActivityDate: now
|
||||
})
|
||||
|
||||
return saveToken(token, client, user, { bypassLogin })
|
||||
}
|
||||
|
||||
async function handleRefreshGrant (options: {
|
||||
req: express.Request
|
||||
oauthRequest: Request
|
||||
client: MOAuthClient
|
||||
refreshTokenAuthName: string
|
||||
ip: string
|
||||
userAgent: string
|
||||
}) {
|
||||
const { req, oauthRequest, client, refreshTokenAuthName } = options
|
||||
|
||||
if (!oauthRequest.body.refresh_token) {
|
||||
throw new InvalidRequestError(req.t('Missing parameter: `refresh_token`'))
|
||||
}
|
||||
|
||||
const refreshToken = await getRefreshToken(oauthRequest.body.refresh_token)
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new InvalidGrantError(req.t('Invalid grant: refresh token is invalid'))
|
||||
}
|
||||
|
||||
if (refreshToken.client.id !== client.id) {
|
||||
throw new InvalidGrantError(req.t('Invalid grant: refresh token is invalid'))
|
||||
}
|
||||
|
||||
if (refreshToken.refreshTokenExpiresAt && refreshToken.refreshTokenExpiresAt < new Date()) {
|
||||
throw new InvalidGrantError(req.t('Invalid grant: refresh token has expired'))
|
||||
}
|
||||
|
||||
await revokeToken({ refreshToken: refreshToken.refreshToken })
|
||||
|
||||
const token = await buildToken({
|
||||
lastActivityDevice: options.userAgent,
|
||||
lastActivityIP: options.ip,
|
||||
lastActivityDate: new Date(),
|
||||
|
||||
loginIP: refreshToken.token.loginIP,
|
||||
loginDate: refreshToken.token.loginDate,
|
||||
loginDevice: refreshToken.token.loginDevice
|
||||
})
|
||||
|
||||
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 (options: {
|
||||
loginDevice: string
|
||||
loginIP: string
|
||||
loginDate: Date
|
||||
lastActivityDevice: string
|
||||
lastActivityIP: string
|
||||
lastActivityDate: Date
|
||||
}) {
|
||||
const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: getTokenExpiresAt('access'),
|
||||
refreshTokenExpiresAt: getTokenExpiresAt('refresh'),
|
||||
|
||||
...pick(options, [
|
||||
'loginDevice',
|
||||
'loginIP',
|
||||
'loginDate',
|
||||
'lastActivityDevice',
|
||||
'lastActivityIP',
|
||||
'lastActivityDate'
|
||||
])
|
||||
}
|
||||
}
|
||||
52
server/core/lib/auth/tokens-cache.ts
Normal file
52
server/core/lib/auth/tokens-cache.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
server/core/lib/automatic-tags/automatic-tagger.ts
Normal file
142
server/core/lib/automatic-tags/automatic-tagger.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
99
server/core/lib/automatic-tags/automatic-tags.ts
Normal file
99
server/core/lib/automatic-tags/automatic-tags.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
90
server/core/lib/blocklist.ts
Normal file
90
server/core/lib/blocklist.ts
Normal file
@@ -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
|
||||
}
|
||||
36
server/core/lib/client-urls.ts
Normal file
36
server/core/lib/client-urls.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PluginType_Type } from '@peertube/peertube-models'
|
||||
import { WEBSERVER } from '@server/initializers/constants.js'
|
||||
import { MAbuseId, MUserId } from '@server/types/models/index.js'
|
||||
|
||||
export const instanceFollowingUrl = `${WEBSERVER.URL}/admin/settings/follows/following-list`
|
||||
export const instanceFollowersUrl = `${WEBSERVER.URL}/admin/follows/followers-list`
|
||||
export const videoAutoBlacklistUrl = `${WEBSERVER.URL}/admin/moderation/video-blocks/list`
|
||||
export const myAccountImportExportUrl = `${WEBSERVER.URL}/my-account/import-export`
|
||||
export const loginUrl = `${WEBSERVER.URL}/login`
|
||||
export const adminRegistrationsListUrl = `${WEBSERVER.URL}/admin/moderation/registrations/list`
|
||||
export const adminUsersListUrl = `${WEBSERVER.URL}/admin/overview/users/list`
|
||||
export const myVideoImportsUrl = `${WEBSERVER.URL}/my-library/video-imports`
|
||||
|
||||
export function getAdminAbuseUrl (abuse: MAbuseId) {
|
||||
const suffix = abuse
|
||||
? '?search=%23' + abuse.id
|
||||
: ''
|
||||
|
||||
return WEBSERVER.URL + '/admin/moderation/abuses/list' + suffix
|
||||
}
|
||||
|
||||
export function getUserAbuseUrl (abuse: MAbuseId) {
|
||||
const suffix = abuse
|
||||
? '?search=%23' + abuse.id
|
||||
: ''
|
||||
|
||||
return WEBSERVER.URL + '/my-account/abuses' + suffix
|
||||
}
|
||||
|
||||
export function getResetPasswordUrl (user: MUserId, verificationString: string) {
|
||||
return WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
|
||||
}
|
||||
|
||||
export function getPluginUrl (pluginType: PluginType_Type) {
|
||||
return WEBSERVER.URL + '/admin/settings/plugins/list-installed?pluginType=' + pluginType
|
||||
}
|
||||
519
server/core/lib/emailer.ts
Normal file
519
server/core/lib/emailer.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import { EmailPayload, MailAction, SendEmailDefaultOptions, To, UserExportState, UserRegistrationState } from '@peertube/peertube-models'
|
||||
import { getFilenameWithoutExt, isTestOrDevInstance, root } from '@peertube/peertube-node-utils'
|
||||
import { t } from '@server/helpers/i18n.js'
|
||||
import { toSafeMailHtml } from '@server/helpers/markdown.js'
|
||||
import { getServerActor } from '@server/models/application/application.js'
|
||||
import { UserModel } from '@server/models/user/user.js'
|
||||
import { readFileSync } from 'fs'
|
||||
import { readdir, readFile } from 'fs/promises'
|
||||
import handlebars, { HelperOptions } from 'handlebars'
|
||||
import merge from 'lodash-es/merge.js'
|
||||
import { createTransport, Transporter } 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, MUserExport, MUserImport } from '../types/models/index.js'
|
||||
import { loginUrl, myAccountImportExportUrl } from './client-urls.js'
|
||||
import { JobQueue } from './job-queue/index.js'
|
||||
import { Hooks } from './plugins/hooks.js'
|
||||
import { ServerConfigManager } from './server-config-manager.js'
|
||||
|
||||
export class Emailer {
|
||||
private static instance: Emailer
|
||||
|
||||
private initialized = false
|
||||
private registeringHandlebars: Promise<any>
|
||||
|
||||
private transporter: Transporter
|
||||
|
||||
private readonly compiledTemplates = new Map<string, HandlebarsTemplateDelegate>()
|
||||
|
||||
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 (options: {
|
||||
username: string
|
||||
to: string
|
||||
language: string
|
||||
resetPasswordUrl: string
|
||||
}) {
|
||||
const { username, to, resetPasswordUrl, language } = options
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'password-reset',
|
||||
to: { email: to, language },
|
||||
subject: t('Reset your account password', language),
|
||||
locals: {
|
||||
username,
|
||||
resetPasswordUrl,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addPasswordCreateEmailJob (options: {
|
||||
username: string
|
||||
to: string
|
||||
language: string
|
||||
createPasswordUrl: string
|
||||
}) {
|
||||
const { username, to, createPasswordUrl, language } = options
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'password-create',
|
||||
to: { email: to, language },
|
||||
subject: t('Create your account password', language),
|
||||
locals: {
|
||||
username,
|
||||
createPasswordUrl,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addUserVerifyChangeEmailJob (options: {
|
||||
username: string
|
||||
to: string
|
||||
language: string
|
||||
verifyEmailUrl: string
|
||||
}) {
|
||||
const { username, to, verifyEmailUrl, language } = options
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'verify-user-change-email',
|
||||
to: { email: to, language },
|
||||
subject: t('Verify your email on {instanceName}', language, { instanceName: CONFIG.INSTANCE.NAME }),
|
||||
locals: {
|
||||
username,
|
||||
verifyEmailUrl,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addRegistrationVerifyEmailJob (options: {
|
||||
username: string
|
||||
isRegistrationRequest: boolean
|
||||
to: string
|
||||
language: string
|
||||
verifyEmailUrl: string
|
||||
}) {
|
||||
const { username, isRegistrationRequest, to, verifyEmailUrl, language } = options
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'verify-registration-email',
|
||||
to: { email: to, language },
|
||||
|
||||
subject: t('Verify your email on {instanceName}', language, { instanceName: CONFIG.INSTANCE.NAME }),
|
||||
locals: {
|
||||
username,
|
||||
verifyEmailUrl,
|
||||
isRegistrationRequest,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addUserBlockJob (options: {
|
||||
username: string
|
||||
email: string
|
||||
language: string
|
||||
blocked: boolean
|
||||
reason?: string
|
||||
}) {
|
||||
const { username, language, email, blocked, reason } = options
|
||||
|
||||
const emailPayload = blocked
|
||||
? {
|
||||
template: 'my-user-block-new',
|
||||
to: { email, language },
|
||||
subject: t('Your account has been blocked', language),
|
||||
locals: {
|
||||
username,
|
||||
instanceName: CONFIG.INSTANCE.NAME,
|
||||
reason
|
||||
}
|
||||
}
|
||||
: {
|
||||
template: 'my-user-unblocked',
|
||||
to: { email, language },
|
||||
subject: t('Your account has been unblocked', language),
|
||||
locals: {
|
||||
username,
|
||||
instanceName: CONFIG.INSTANCE.NAME
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
addContactFormJob (options: {
|
||||
fromEmail: string
|
||||
|
||||
fromName: string
|
||||
subject: string
|
||||
body: string
|
||||
}) {
|
||||
const { fromEmail, fromName, subject, body } = options
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
template: 'contact-form',
|
||||
to: { email: CONFIG.ADMIN.EMAIL, language: CONFIG.INSTANCE.DEFAULT_LANGUAGE },
|
||||
replyTo: `"${fromName}" <${fromEmail}>`,
|
||||
subject: t('Contact form - {subject}', CONFIG.INSTANCE.DEFAULT_LANGUAGE, { 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: Pick<MRegistration, 'username' | 'state' | 'email' | 'moderationResponse'>
|
||||
) {
|
||||
const language = CONFIG.INSTANCE.DEFAULT_LANGUAGE
|
||||
|
||||
let template: string
|
||||
let subject: string
|
||||
let action: MailAction
|
||||
|
||||
if (registration.state === UserRegistrationState.ACCEPTED) {
|
||||
template = 'user-registration-request-accepted'
|
||||
subject = t('Your registration request for {username} has been accepted', language, { username: registration.username })
|
||||
|
||||
action = { text: t('Login to your account', language), url: loginUrl }
|
||||
} else {
|
||||
template = 'user-registration-request-rejected'
|
||||
subject = t('Your registration request for {username} has been rejected', language, { username: registration.username })
|
||||
}
|
||||
|
||||
const to = registration.email
|
||||
const emailPayload: EmailPayload = {
|
||||
to: { email: to, language },
|
||||
template,
|
||||
subject,
|
||||
locals: {
|
||||
username: registration.username,
|
||||
moderationResponse: registration.moderationResponse,
|
||||
action,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async addUserExportCompletedOrErroredJob (userExport: Pick<MUserExport, 'userId' | 'state' | 'error'>, toOverride?: To) {
|
||||
let template: string
|
||||
let subject: string
|
||||
|
||||
const to = toOverride ?? await UserModel.loadForEmail(userExport.userId)
|
||||
|
||||
if (userExport.state === UserExportState.COMPLETED) {
|
||||
template = 'user-export-completed'
|
||||
subject = t('Your export archive has been created', to.language)
|
||||
} else {
|
||||
template = 'user-export-errored'
|
||||
subject = t('Failed to create your export archive', to.language)
|
||||
}
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to,
|
||||
template,
|
||||
subject,
|
||||
locals: {
|
||||
exportsUrl: myAccountImportExportUrl,
|
||||
errorMessage: userExport.error,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
async addUserImportErroredJob (userImport: Pick<MUserImport, 'userId' | 'error'>, toOverride?: To) {
|
||||
const to = toOverride ?? await UserModel.loadForEmail(userImport.userId)
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to,
|
||||
|
||||
template: 'user-import-errored',
|
||||
subject: t('Failed to import your archive', to.language),
|
||||
locals: {
|
||||
errorMessage: userImport.error,
|
||||
|
||||
hideNotificationPreferencesLink: true
|
||||
}
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
|
||||
}
|
||||
|
||||
async addUserImportSuccessJob (userImport: Pick<MUserImport, 'userId' | 'resultSummary'>, toOverride?: To) {
|
||||
const to = toOverride ?? await UserModel.loadForEmail(userImport.userId)
|
||||
|
||||
const emailPayload: EmailPayload = {
|
||||
to,
|
||||
|
||||
template: 'user-import-completed',
|
||||
subject: t('Your archive import has finished', to.language),
|
||||
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,
|
||||
juice: false,
|
||||
htmlToText: {
|
||||
selectors: [
|
||||
{ selector: 'img', format: 'skip' },
|
||||
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true } }
|
||||
]
|
||||
},
|
||||
render: async (view: string, locals: Record<string, string>) => {
|
||||
if (view.split('/').pop() !== 'html') return undefined
|
||||
|
||||
await this.initHandlebarsIfNeeded()
|
||||
|
||||
const templatePath = await Hooks.wrapObject(
|
||||
join(root(), 'dist', 'core', 'assets', 'email-templates', view + '.hbs'),
|
||||
'filter:email.template-path.result',
|
||||
{ view }
|
||||
)
|
||||
|
||||
let compiledTemplate = this.compiledTemplates.get(templatePath)
|
||||
|
||||
if (!compiledTemplate) {
|
||||
compiledTemplate = handlebars.compile(await readFile(templatePath, 'utf-8'))
|
||||
this.compiledTemplates.set(templatePath, compiledTemplate)
|
||||
}
|
||||
|
||||
return compiledTemplate(locals)
|
||||
},
|
||||
message: {
|
||||
from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
|
||||
},
|
||||
transport: this.transporter,
|
||||
subjectPrefix: this.buildSubjectPrefix()
|
||||
})
|
||||
const subject = await Hooks.wrapObject(
|
||||
options.subject,
|
||||
'filter:email.subject.result',
|
||||
{ template: 'template' in options ? options.template : undefined }
|
||||
)
|
||||
|
||||
const errors: Error[] = []
|
||||
|
||||
for (const to of arrayify(options.to)) {
|
||||
const baseOptions: SendEmailDefaultOptions = {
|
||||
template: 'common',
|
||||
message: {
|
||||
to: to.email,
|
||||
from: options.from,
|
||||
subject,
|
||||
replyTo: options.replyTo
|
||||
},
|
||||
locals: { // default variables available in all templates
|
||||
WEBSERVER,
|
||||
instanceName: CONFIG.INSTANCE.NAME,
|
||||
text: options.text,
|
||||
subject,
|
||||
signature: this.buildSignature(),
|
||||
fg: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR || '#000',
|
||||
bg: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR || '#fff',
|
||||
primary: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR || '#FF8F37',
|
||||
onPrimary: CONFIG.THEME.CUSTOMIZATION.ON_PRIMARY_COLOR || '#000',
|
||||
language: to.language,
|
||||
logoUrl: ServerConfigManager.Instance.getLogoUrl(await getServerActor(), 192)
|
||||
}
|
||||
}
|
||||
|
||||
// overridden/new variables given for a specific template in the payload
|
||||
const sendOptions = merge(baseOptions, options)
|
||||
|
||||
try {
|
||||
const res = await email.send(sendOptions)
|
||||
|
||||
logger.debug('Sent email.', { res })
|
||||
} catch (err) {
|
||||
errors.push(err)
|
||||
|
||||
logger.error('Error in email sender.', { err })
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length !== 0) {
|
||||
const err = new Error('Some errors when sent emails') as Error & { errors: Error[] }
|
||||
err.errors = errors
|
||||
|
||||
throw 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
|
||||
})
|
||||
}
|
||||
|
||||
private buildSubjectPrefix () {
|
||||
let prefix = CONFIG.EMAIL.SUBJECT.PREFIX
|
||||
if (!prefix) return prefix
|
||||
|
||||
prefix = prefix.replace(/{{instanceName}}/g, CONFIG.INSTANCE.NAME)
|
||||
if (prefix.endsWith(' ')) return prefix
|
||||
|
||||
return prefix + ' '
|
||||
}
|
||||
|
||||
private buildSignature () {
|
||||
const signature = CONFIG.EMAIL.BODY.SIGNATURE
|
||||
if (!signature) return signature
|
||||
|
||||
return signature.replace(/{{instanceName}}/g, CONFIG.INSTANCE.NAME)
|
||||
}
|
||||
|
||||
private initHandlebarsIfNeeded () {
|
||||
if (this.registeringHandlebars !== undefined) return this.registeringHandlebars
|
||||
|
||||
this.registeringHandlebars = this._initHandlebarsIfNeeded()
|
||||
|
||||
return this.registeringHandlebars
|
||||
}
|
||||
|
||||
private async _initHandlebarsIfNeeded () {
|
||||
const partialsPath = join(root(), 'dist', 'core', 'assets', 'email-templates', 'partials')
|
||||
const partialFiles = await readdir(partialsPath)
|
||||
|
||||
for (const partialFile of partialFiles) {
|
||||
handlebars.registerPartial(getFilenameWithoutExt(partialFile), await readFile(join(partialsPath, partialFile), 'utf-8'))
|
||||
}
|
||||
|
||||
handlebars.registerHelper('t', function (key: string, options: HelperOptions) {
|
||||
const result = t(key, this.language, options.hash)
|
||||
|
||||
return toSafeMailHtml(result)
|
||||
})
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
26
server/core/lib/files-cache/avatar-permanent-file-cache.ts
Normal file
26
server/core/lib/files-cache/avatar-permanent-file-cache.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CONFIG } from '@server/initializers/config.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 undefined
|
||||
}
|
||||
}
|
||||
6
server/core/lib/files-cache/index.ts
Normal file
6
server/core/lib/files-cache/index.ts
Normal file
@@ -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,131 @@
|
||||
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
|
||||
|
||||
isLocal(): 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.isLocal()) {
|
||||
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,29 @@
|
||||
import { remove } from 'fs-extra/esm'
|
||||
import { logger } from '../../../helpers/logger.js'
|
||||
import memoizee from 'memoizee'
|
||||
|
||||
type GetFilePathResult = { isLocal: 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.isLocal !== 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 }))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2
server/core/lib/files-cache/shared/index.ts
Normal file
2
server/core/lib/files-cache/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './abstract-permanent-file-cache.js'
|
||||
export * from './abstract-simple-file-cache.js'
|
||||
@@ -0,0 +1,59 @@
|
||||
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.isLocal()) {
|
||||
return { isLocal: true, path: videoCaption.getFSFilePath() }
|
||||
}
|
||||
|
||||
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.isLocal()) 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.getOriginFileUrl(video)
|
||||
const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.filename)
|
||||
|
||||
try {
|
||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||
|
||||
return { isLocal: 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,57 @@
|
||||
import { ThumbnailType } from '@peertube/peertube-models'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
|
||||
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
|
||||
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'
|
||||
|
||||
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.isLocal()) return { isLocal: 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.isLocal()) 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 { isLocal: false, path: destPath }
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote preview file %s.', remoteUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VideoPreviewsSimpleFileCache
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
|
||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||
import { join } from 'path'
|
||||
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.isLocal()) return { isLocal: 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 { isLocal: false, path: destPath }
|
||||
} catch (err) {
|
||||
logger.info('Cannot fetch remote storyboard file %s.', remoteUrl, { err })
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VideoStoryboardsSimpleFileCache
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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().isLocal()) {
|
||||
const downloadName = this.buildDownloadName(file.getVideo(), file)
|
||||
|
||||
return { isLocal: 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().isLocal()) 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 { isLocal: 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
|
||||
}
|
||||
356
server/core/lib/hls.ts
Normal file
356
server/core/lib/hls.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { sortBy, uniqify, uuidRegex } from '@peertube/peertube-core-utils'
|
||||
import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||
import { FileStorage, VideoResolution } from '@peertube/peertube-models'
|
||||
import { sha256 } from '@peertube/peertube-node-utils'
|
||||
import { ApplicationModel } from '@server/models/application/application.js'
|
||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo, MVideoCaption } 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 { storeHLSFileFromContent } from './object-storage/index.js'
|
||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHLSResolutionPlaylistFilename } from './paths.js'
|
||||
import { VideoPathManager } from './video-path-manager.js'
|
||||
|
||||
const lTags = loggerTagsFactory('hls')
|
||||
|
||||
export async function updateStreamingPlaylistsInfohashesIfNeeded () {
|
||||
let playlistsToUpdateIds = new Set(await VideoStreamingPlaylistModel.listIdsByIncorrectPeerVersion())
|
||||
|
||||
if (playlistsToUpdateIds.size !== 0) {
|
||||
logger.info(`Will update ${playlistsToUpdateIds.size} streaming playlists infohash because of protocol version change.`, lTags())
|
||||
}
|
||||
|
||||
if (await ApplicationModel.streamingPlaylistBaseUrlChanged()) {
|
||||
const localIds = await VideoStreamingPlaylistModel.listIdsLocals()
|
||||
|
||||
if (localIds.length !== 0) {
|
||||
logger.info(`Will update ${localIds.length} local streaming playlists infohash because of object storage base URL change.`, lTags())
|
||||
|
||||
playlistsToUpdateIds = new Set([ ...playlistsToUpdateIds, ...localIds ])
|
||||
}
|
||||
}
|
||||
|
||||
// Use separate SQL queries, because we could have many videos to update
|
||||
for (const playlistId of playlistsToUpdateIds) {
|
||||
try {
|
||||
await sequelizeTypescript.transaction(async t => {
|
||||
const playlist = await VideoStreamingPlaylistModel.loadWithVideo(playlistId, t)
|
||||
const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlistId, t)
|
||||
|
||||
playlist.assignP2PMediaLoaderInfoHashes(playlist.Video, videoFiles)
|
||||
playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
|
||||
|
||||
await playlist.save({ transaction: t })
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`Cannot update streaming playlist infohash of playlist id ${playlistId}`, { err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateM3U8AndShaPlaylist (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 captions = await VideoCaptionModel.listVideoCaptions(video.id)
|
||||
|
||||
const extMediaAudio: string[] = []
|
||||
const extMediaSubtitle: string[] = []
|
||||
const extStreamInfo: string[] = []
|
||||
let separatedAudioCodec: string
|
||||
|
||||
const splitAudioAndVideo = playlist.hasAudioAndVideoSplitted()
|
||||
|
||||
for (const caption of captions) {
|
||||
if (!caption.m3u8Filename) continue
|
||||
|
||||
extMediaSubtitle.push(
|
||||
`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subtitles",` +
|
||||
`NAME="${VideoCaptionModel.getLanguageLabel(caption.language)}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,` +
|
||||
`LANGUAGE="${caption.language}",URI="${caption.m3u8Filename}"`
|
||||
)
|
||||
}
|
||||
|
||||
// Sort to have the audio resolution first (if it exists)
|
||||
for (const file of sortBy(playlist.VideoFiles, 'resolution')) {
|
||||
const playlistFilename = getHLSResolutionPlaylistFilename(file.filename)
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
||||
const probe = await ffprobePromise(videoFilePath)
|
||||
|
||||
if (splitAudioAndVideo && file.resolution === VideoResolution.H_NOVIDEO) {
|
||||
separatedAudioCodec = await getAudioStreamCodec(videoFilePath, probe)
|
||||
}
|
||||
|
||||
const size = await getVideoStreamDimensionsInfo(videoFilePath, probe)
|
||||
|
||||
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
|
||||
const resolution = file.resolution === VideoResolution.H_NOVIDEO
|
||||
? ''
|
||||
: `,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, probe),
|
||||
separatedAudioCodec || getAudioStreamCodec(videoFilePath, probe)
|
||||
])
|
||||
|
||||
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
|
||||
|
||||
if (splitAudioAndVideo) line += `,AUDIO="audio"`
|
||||
if (extMediaSubtitle.length !== 0) line += `,SUBTITLES="subtitles"`
|
||||
|
||||
// Don't include audio only resolution as a regular "video" resolution
|
||||
// Some player may use it automatically and so the user would not have a video stream
|
||||
// But if it's the only resolution we can treat it as a regular stream
|
||||
if (resolution || playlist.VideoFiles.length === 1) {
|
||||
extStreamInfo.push(line)
|
||||
extStreamInfo.push(playlistFilename)
|
||||
} else if (splitAudioAndVideo) {
|
||||
extMediaAudio.push(`#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",AUTOSELECT=YES,DEFAULT=YES,URI="${playlistFilename}"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const masterPlaylists = [ '#EXTM3U', '#EXT-X-VERSION:3', '', ...extMediaSubtitle, '', ...extMediaAudio, '', ...extStreamInfo ]
|
||||
|
||||
await playlist.reload()
|
||||
|
||||
if (playlist.playlistFilename) {
|
||||
await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
|
||||
}
|
||||
playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
|
||||
|
||||
const masterPlaylistContent = masterPlaylists.join('\n') + '\n'
|
||||
|
||||
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
||||
playlist.playlistUrl = await storeHLSFileFromContent({
|
||||
playlist,
|
||||
pathOrFilename: playlist.playlistFilename,
|
||||
content: masterPlaylistContent
|
||||
})
|
||||
|
||||
logger.info(`Updated master playlist file of video ${video.uuid} to object storage ${playlist.playlistUrl}`, lTags(video.uuid))
|
||||
} else {
|
||||
const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
|
||||
await writeFile(masterPlaylistPath, masterPlaylistContent)
|
||||
|
||||
logger.info(`Updated master playlist file ${masterPlaylistPath} of video ${video.uuid}`, lTags(video.uuid))
|
||||
}
|
||||
|
||||
return playlist.save()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
|
||||
playlist.segmentsSha256Url = await storeHLSFileFromContent({
|
||||
playlist,
|
||||
pathOrFilename: playlist.segmentsSha256Filename,
|
||||
content: JSON.stringify(json)
|
||||
})
|
||||
} else {
|
||||
const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
|
||||
await outputJSON(outputPath, json)
|
||||
}
|
||||
|
||||
return playlist.save()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildSha256Segment (segmentPath: string) {
|
||||
const buf = await readFile(segmentPath)
|
||||
return sha256(buf)
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export 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')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function injectQueryToPlaylistUrls (content: string, queryString: string) {
|
||||
return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildCaptionM3U8Content (options: {
|
||||
video: MVideo
|
||||
caption: MVideoCaption
|
||||
}) {
|
||||
const { video, caption } = options
|
||||
|
||||
return `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${video.duration}\n#EXT-X-MEDIA-SEQUENCE:0\n` +
|
||||
`#EXTINF:${video.duration},\n${caption.getFileUrl(video)}\n#EXT-X-ENDLIST\n`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
95
server/core/lib/html/client-html.ts
Normal file
95
server/core/lib/html/client-html.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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 (videoId: string, req: express.Request, res: express.Response) {
|
||||
return VideoHtml.getWatchVideoHTML(videoId, req, res)
|
||||
}
|
||||
|
||||
static getVideoEmbedHTML (videoId: string) {
|
||||
return VideoHtml.getEmbedVideoHTML(videoId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getWatchPlaylistHTMLPage (videoPlaylistId: string, req: express.Request, res: express.Response) {
|
||||
return PlaylistHtml.getWatchPlaylistHTML(videoPlaylistId, req, res)
|
||||
}
|
||||
|
||||
static getVideoPlaylistEmbedHTML (playlistId: string) {
|
||||
return PlaylistHtml.getEmbedPlaylistHTML(playlistId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getAccountHTMLPage (handle: string, req: express.Request, res: express.Response) {
|
||||
return ActorHtml.getAccountHTMLPage(handle, req, res)
|
||||
}
|
||||
|
||||
static getVideoChannelHTMLPage (handle: string, req: express.Request, res: express.Response) {
|
||||
return ActorHtml.getVideoChannelHTMLPage(handle, req, res)
|
||||
}
|
||||
|
||||
static getActorHTMLPage (handle: string, req: express.Request, res: express.Response) {
|
||||
return ActorHtml.getActorHTMLPage(handle, req, res)
|
||||
}
|
||||
}
|
||||
|
||||
function sendHTML (html: string, res: express.Response, localizedHTML = 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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user