はじまりの大地

このコミットが含まれているのは:
2024-07-15 09:14:04 +09:00
コミット 6632905f32
3501個のファイルの変更1439465行の追加0行の削除
+74
ファイルの表示
@@ -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
}
+149
ファイルの表示
@@ -0,0 +1,149 @@
import { arrayify } from '@peertube/peertube-core-utils'
import { ActivityPubActor, APObjectId } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { JobQueue } from '@server/lib/job-queue/index.js'
import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders/index.js'
import {
MActor,
MActorAccountChannelId,
MActorAccountChannelIdActor,
MActorAccountId,
MActorFullActor
} from '@server/types/models/index.js'
import { fetchAPObjectIfNeeded, getAPId } from '../activity.js'
import { checkUrlsSameHost } from '../url.js'
import { refreshActorIfNeeded } from './refresh.js'
import { APActorCreator, fetchRemoteActor } from './shared/index.js'
function getOrCreateAPActor (
activityActor: string | ActivityPubActor,
fetchType: 'all',
recurseIfNeeded?: boolean,
updateCollections?: boolean
): Promise<MActorFullActor>
function getOrCreateAPActor (
activityActor: string | ActivityPubActor,
fetchType?: 'association-ids',
recurseIfNeeded?: boolean,
updateCollections?: boolean
): Promise<MActorAccountChannelId>
async function getOrCreateAPActor (
activityActor: string | ActivityPubActor,
fetchType: ActorLoadByUrlType = 'association-ids',
recurseIfNeeded = true,
updateCollections = false
): Promise<MActorFullActor | MActorAccountChannelId> {
const actorUrl = getAPId(activityActor)
let actor = await loadActorFromDB(actorUrl, fetchType)
let created = false
let accountPlaylistsUrl: string
// We don't have this actor in our database, fetch it on remote
if (!actor) {
const { actorObject } = await fetchRemoteActor(actorUrl)
if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl)
// actorUrl is just an alias/redirection, so process object id instead
if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
// Create the attributed to actor
// In PeerTube a video channel is owned by an account
let ownerActor: MActorFullActor
if (recurseIfNeeded === true && actorObject.type === 'Group') {
ownerActor = await getOrCreateAPOwner(actorObject, actorUrl)
}
const creator = new APActorCreator(actorObject, ownerActor)
actor = await retryTransactionWrapper(creator.create.bind(creator))
created = true
accountPlaylistsUrl = actorObject.playlists
}
if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor
if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType })
if (!actorRefreshed) throw new Error('Actor ' + actor.url + ' does not exist anymore.')
await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections)
await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
return actorRefreshed
}
async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) {
const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person')
if (!accountAttributedTo) {
throw new Error(`Cannot find account attributed to video channel ${actorUrl}`)
}
try {
// Don't recurse another time
const recurseIfNeeded = false
return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
} catch (err) {
logger.error('Cannot get or create account attributed to video channel ' + actorUrl)
throw new Error(err)
}
}
async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') {
for (const actorToCheck of arrayify(attributedTo)) {
const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck))
if (!actorObject) {
logger.warn('Unknown attributed to actor %s for owner %s', actorToCheck, rootUrl)
continue
}
if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) {
logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`)
continue
}
if (actorObject.type === type) return actorObject
}
return undefined
}
// ---------------------------------------------------------------------------
export {
getOrCreateAPOwner,
getOrCreateAPActor,
findOwner
}
// ---------------------------------------------------------------------------
async function loadActorFromDB (actorUrl: string, fetchType: ActorLoadByUrlType) {
let actor = await loadActorByUrl(actorUrl, fetchType)
// Orphan actor (not associated to an account of channel) so recreate it
if (actor && (!actor.Account && !actor.VideoChannel)) {
await actor.destroy()
actor = null
}
return actor
}
async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, refreshed: boolean, updateCollections: boolean) {
if ((created === true || refreshed === true) && updateCollections === true) {
const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
}
}
async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
// We created a new account: fetch the playlists
if (created === true && actor.Account && accountPlaylistsUrl) {
const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' }
await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
}
}
+112
ファイルの表示
@@ -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'
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
export * from './get.js'
export * from './image.js'
export * from './keys.js'
export * from './refresh.js'
export * from './updater.js'
export * from './webfinger.js'
+16
ファイルの表示
@@ -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
ファイルの表示
@@ -0,0 +1,83 @@
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CachePromiseFactory } from '@server/helpers/promise-cache.js'
import { PeerTubeRequestError } from '@server/helpers/requests.js'
import { ActorLoadByUrlType } from '@server/lib/model-loaders/index.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { MActorAccountChannelId, MActorFull } from '@server/types/models/index.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import { fetchRemoteActor } from './shared/index.js'
import { APActorUpdater } from './updater.js'
import { getUrlFromWebfinger } from './webfinger.js'
type RefreshResult <T> = Promise<{ actor: T | MActorFull, refreshed: boolean }>
type RefreshOptions <T> = {
actor: T
fetchedType: ActorLoadByUrlType
}
const promiseCache = new CachePromiseFactory(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url)
function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <T> {
const actorArg = options.actor
if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false })
return promiseCache.run(options)
}
export {
refreshActorIfNeeded
}
// ---------------------------------------------------------------------------
async function doRefresh <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <MActorFull> {
const { actor: actorArg, fetchedType } = options
// We need more attributes
const actor = fetchedType === 'all'
? actorArg as MActorFull
: await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
const lTags = loggerTagsFactory('ap', 'actor', 'refresh', actor.url)
logger.info('Refreshing actor %s.', actor.url, lTags())
try {
const actorUrl = await getActorUrl(actor)
const { actorObject } = await fetchRemoteActor(actorUrl)
if (actorObject === undefined) {
logger.info('Cannot fetch remote actor %s in refresh actor.', actorUrl)
return { actor, refreshed: false }
}
const updater = new APActorUpdater(actorObject, actor)
await updater.update()
return { refreshed: true, actor }
} catch (err) {
const statusCode = (err as PeerTubeRequestError).statusCode
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
logger.info('Deleting actor %s because there is a 404/410 in refresh actor.', actor.url, lTags())
actor.Account
? await actor.Account.destroy()
: await actor.VideoChannel.destroy()
return { actor: undefined, refreshed: false }
}
logger.info('Cannot refresh actor %s.', actor.url, { err, ...lTags() })
return { actor, refreshed: false }
}
}
function getActorUrl (actor: MActorFull) {
return getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
.catch(err => {
logger.warn('Cannot get actor URL from webfinger, keeping the old one.', { err })
return actor.url
})
}
+159
ファイルの表示
@@ -0,0 +1,159 @@
import { Op, Transaction } from 'sequelize'
import { ActivityPubActor, ActorImageType, ActorImageType_Type } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { AccountModel } from '@server/models/account/account.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { ServerModel } from '@server/models/server/server.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import {
MAccount,
MAccountDefault,
MActor,
MActorFullActor,
MActorId,
MActorImages,
MChannel,
MServer
} from '@server/types/models/index.js'
import { updateActorImages } from '../image.js'
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes.js'
import { fetchActorFollowsCount } from './url-to-object.js'
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
export class APActorCreator {
constructor (
private readonly actorObject: ActivityPubActor,
private readonly ownerActor?: MActorFullActor
) {
}
async create (): Promise<MActorFullActor> {
const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject)
const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount))
return sequelizeTypescript.transaction(async t => {
const server = await this.setServer(actorInstance, t)
const { actorCreated, created } = await this.saveActor(actorInstance, t)
await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t)
await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t)
await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
if (isAccountActor(actorCreated.type)) {
actorCreated.Account = await this.saveAccount(actorCreated, t) as MAccountDefault
actorCreated.Account.Actor = actorCreated
}
if (isChannelActor(actorCreated.type)) {
const channel = await this.saveVideoChannel(actorCreated, t)
actorCreated.VideoChannel = Object.assign(channel, { Actor: actorCreated, Account: this.ownerActor.Account })
}
actorCreated.Server = server
return actorCreated
})
}
private async setServer (actor: MActor, t: Transaction) {
const actorHost = new URL(actor.url).host
const serverOptions = {
where: {
host: actorHost
},
defaults: {
host: actorHost
},
transaction: t
}
const [ server ] = await ServerModel.findOrCreate(serverOptions)
// Save our new account in database
actor.serverId = server.id
return server as MServer
}
private async setImageIfNeeded (actor: MActor, type: ActorImageType_Type, t: Transaction) {
const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
if (imagesInfo.length === 0) return
return updateActorImages(actor as MActorImages, type, imagesInfo, t)
}
private async saveActor (actor: MActor, t: Transaction) {
// Force the actor creation using findOrCreate() instead of save()
// Sometimes Sequelize skips the save() when it thinks the instance already exists
// (which could be false in a retried query)
const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({
defaults: actor.toJSON(),
where: {
[Op.or]: [
{
url: actor.url
},
{
serverId: actor.serverId,
preferredUsername: actor.preferredUsername
}
]
},
transaction: t
})
return { actorCreated, created }
}
private async tryToFixActorUrlIfNeeded (actorCreated: MActor, newActor: MActor, created: boolean, t: Transaction) {
// Try to fix non HTTPS accounts of remote instances that fixed their URL afterwards
if (created !== true && actorCreated.url !== newActor.url) {
// Only fix http://example.com/account/djidane to https://example.com/account/djidane
if (actorCreated.url.replace(/^http:\/\//, '') !== newActor.url.replace(/^https:\/\//, '')) {
throw new Error(`Actor from DB with URL ${actorCreated.url} does not correspond to actor ${newActor.url}`)
}
actorCreated.url = newActor.url
await actorCreated.save({ transaction: t })
}
}
private async saveAccount (actor: MActorId, t: Transaction) {
const [ accountCreated ] = await AccountModel.findOrCreate({
defaults: {
name: getActorDisplayNameFromObject(this.actorObject),
description: this.actorObject.summary,
actorId: actor.id
},
where: {
actorId: actor.id
},
transaction: t
})
return accountCreated as MAccount
}
private async saveVideoChannel (actor: MActorId, t: Transaction) {
const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({
defaults: {
name: getActorDisplayNameFromObject(this.actorObject),
description: this.actorObject.summary,
support: this.actorObject.support,
actorId: actor.id,
accountId: this.ownerActor.Account.id
},
where: {
actorId: actor.id
},
transaction: t
})
return videoChannelCreated as MChannel
}
}
+3
ファイルの表示
@@ -0,0 +1,3 @@
export * from './creator.js'
export * from './object-to-model-attributes.js'
export * from './url-to-object.js'
+83
ファイルの表示
@@ -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 []
}
+56
ファイルの表示
@@ -0,0 +1,56 @@
import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor.js'
import { logger } from '@server/helpers/logger.js'
import { ActivityPubActor, ActivityPubOrderedCollection } from '@peertube/peertube-models'
import { fetchAP } from '../../activity.js'
import { checkUrlsSameHost } from '../../url.js'
async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode: number, actorObject: ActivityPubActor }> {
logger.info('Fetching remote actor %s.', actorUrl)
const { body, statusCode } = await fetchAP<ActivityPubActor>(actorUrl)
if (sanitizeAndCheckActorObject(body) === false) {
logger.debug('Remote actor JSON is not valid.', { actorJSON: body })
return { actorObject: undefined, statusCode }
}
if (checkUrlsSameHost(body.id, actorUrl) !== true) {
logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, body.id)
return { actorObject: undefined, statusCode }
}
return {
statusCode,
actorObject: body
}
}
async function fetchActorFollowsCount (actorObject: ActivityPubActor) {
let followersCount = 0
let followingCount = 0
if (actorObject.followers) followersCount = await fetchActorTotalItems(actorObject.followers)
if (actorObject.following) followingCount = await fetchActorTotalItems(actorObject.following)
return { followersCount, followingCount }
}
// ---------------------------------------------------------------------------
export {
fetchActorFollowsCount,
fetchRemoteActor
}
// ---------------------------------------------------------------------------
async function fetchActorTotalItems (url: string) {
try {
const { body } = await fetchAP<ActivityPubOrderedCollection<unknown>>(url)
return body.totalItems || 0
} catch (err) {
logger.info('Cannot fetch remote actor count %s.', url, { err })
return 0
}
}
+91
ファイルの表示
@@ -0,0 +1,91 @@
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js'
import { ActivityPubActor, ActorImageType } from '@peertube/peertube-models'
import { getOrCreateAPOwner } from './get.js'
import { updateActorImages } from './image.js'
import { fetchActorFollowsCount } from './shared/index.js'
import { getImagesInfoFromObject } from './shared/object-to-model-attributes.js'
export class APActorUpdater {
private readonly accountOrChannel: MAccount | MChannel
constructor (
private readonly actorObject: ActivityPubActor,
private readonly actor: MActorFull
) {
if (this.actorObject.type === 'Group') this.accountOrChannel = this.actor.VideoChannel
else this.accountOrChannel = this.actor.Account
}
async update () {
const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
try {
await this.updateActorInstance(this.actor, this.actorObject)
this.accountOrChannel.name = this.actorObject.name || this.actorObject.preferredUsername
this.accountOrChannel.description = this.actorObject.summary
if (this.accountOrChannel instanceof VideoChannelModel) {
const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.url)
this.accountOrChannel.accountId = owner.Account.id
this.accountOrChannel.Account = owner.Account as AccountModel
this.accountOrChannel.support = this.actorObject.support
}
await runInReadCommittedTransaction(async t => {
await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
})
await runInReadCommittedTransaction(async t => {
await this.actor.save({ transaction: t })
await this.accountOrChannel.save({ transaction: t })
})
logger.info('Remote account %s updated', this.actorObject.url)
} catch (err) {
if (this.actor !== undefined) {
await resetSequelizeInstance(this.actor)
}
if (this.accountOrChannel !== undefined) {
await resetSequelizeInstance(this.accountOrChannel)
}
// This is just a debug because we will retry the insert
logger.debug('Cannot update the remote account.', { err })
throw err
}
}
private async updateActorInstance (actorInstance: MActor, actorObject: ActivityPubActor) {
const { followersCount, followingCount } = await fetchActorFollowsCount(actorObject)
actorInstance.type = actorObject.type
actorInstance.preferredUsername = actorObject.preferredUsername
actorInstance.url = actorObject.id
actorInstance.publicKey = actorObject.publicKey.publicKeyPem
actorInstance.followersCount = followersCount
actorInstance.followingCount = followingCount
actorInstance.inboxUrl = actorObject.inbox
actorInstance.outboxUrl = actorObject.outbox
actorInstance.followersUrl = actorObject.followers
actorInstance.followingUrl = actorObject.following
if (actorObject.published) actorInstance.remoteCreatedAt = new Date(actorObject.published)
if (actorObject.endpoints?.sharedInbox) {
actorInstance.sharedInboxUrl = actorObject.endpoints.sharedInbox
}
// Force actor update
actorInstance.changed('updatedAt', true)
}
}
+67
ファイルの表示
@@ -0,0 +1,67 @@
import WebFinger from 'webfinger.js'
import { WebFingerData } from '@peertube/peertube-models'
import { isProdInstance } from '@peertube/peertube-node-utils'
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { REQUEST_TIMEOUTS, WEBSERVER } from '@server/initializers/constants.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { MActorFull } from '@server/types/models/index.js'
const webfinger = new WebFinger({
webfist_fallback: false,
tls_only: isProdInstance(),
uri_fallback: false,
request_timeout: REQUEST_TIMEOUTS.DEFAULT
})
async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
// Handle strings like @toto@example.com
const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg
const [ name, host ] = uri.split('@')
let actor: MActorFull
if (!host || host === WEBSERVER.HOST) {
actor = await ActorModel.loadLocalByName(name)
} else {
actor = await ActorModel.loadByNameAndHost(name, host)
}
if (actor) return actor.url
return getUrlFromWebfinger(uri)
}
async function getUrlFromWebfinger (uri: string) {
const webfingerData: WebFingerData = await webfingerLookup(uri)
return getLinkOrThrow(webfingerData)
}
// ---------------------------------------------------------------------------
export {
getUrlFromWebfinger,
loadActorUrlOrGetFromWebfinger
}
// ---------------------------------------------------------------------------
function getLinkOrThrow (webfingerData: WebFingerData) {
if (Array.isArray(webfingerData.links) === false) throw new Error('WebFinger links is not an array.')
const selfLink = webfingerData.links.find(l => l.rel === 'self')
if (selfLink === undefined || isActivityPubUrlValid(selfLink.href) === false) {
throw new Error('Cannot find self link or href is not a valid URL.')
}
return selfLink.href
}
function webfingerLookup (nameWithHost: string) {
return new Promise<WebFingerData>((res, rej) => {
webfinger.lookup(nameWithHost, (err, p) => {
if (err) return rej(err)
return res(p.object)
})
})
}
+26
ファイルの表示
@@ -0,0 +1,26 @@
import { ActivityAudience } from '@peertube/peertube-models'
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
import { MActorFollowersUrl } from '../../types/models/index.js'
export function getAudience (actorSender: MActorFollowersUrl, isPublic = true) {
return buildAudience([ actorSender.followersUrl ], isPublic)
}
export function buildAudience (followerUrls: string[], isPublic = true) {
let to: string[] = []
let cc: string[] = []
if (isPublic) {
to = [ getAPPublicValue() ]
cc = followerUrls
} else { // Unlisted
to = []
cc = []
}
return { to, cc }
}
export function audiencify<T> (object: T, audience: ActivityAudience) {
return { ...audience, ...object }
}
+87
ファイルの表示
@@ -0,0 +1,87 @@
import { Transaction } from 'sequelize'
import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js'
import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js'
import { exists } from '@server/helpers/custom-validators/misc.js'
async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
if (redundancyModel) {
return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
}
return createCacheFile(cacheFileObject, video, byActor, t)
}
// ---------------------------------------------------------------------------
export {
createOrUpdateCacheFile
}
// ---------------------------------------------------------------------------
function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
return VideoRedundancyModel.create(attributes, { transaction: t })
}
function updateCacheFile (
cacheFileObject: CacheFileObject,
redundancyModel: MVideoRedundancy,
video: MVideoWithAllFiles,
byActor: MActorId,
t: Transaction
) {
if (redundancyModel.actorId !== byActor.id) {
throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.')
}
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
redundancyModel.expiresOn = attributes.expiresOn
redundancyModel.fileUrl = attributes.fileUrl
return redundancyModel.save({ transaction: t })
}
function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
const url = cacheFileObject.url
const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
return {
expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
url: cacheFileObject.id,
fileUrl: url.href,
strategy: null,
videoStreamingPlaylistId: playlist.id,
actorId: byActor.id
}
}
const url = cacheFileObject.url
const urlFPS = exists(url.fps) // TODO: compat with < 6.1, remove in 7.0
? url.fps
: url['_:fps']
const videoFile = video.VideoFiles.find(f => {
return f.resolution === url.height && f.fps === urlFPS
})
if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${urlFPS} of video ${video.url}`)
return {
expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
url: cacheFileObject.id,
fileUrl: url.href,
strategy: null,
videoFileId: videoFile.id,
actorId: byActor.id
}
}
+66
ファイルの表示
@@ -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
ファイルの表示
@@ -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'
)
}
}
+58
ファイルの表示
@@ -0,0 +1,58 @@
import Bluebird from 'bluebird'
import { URL } from 'url'
import { ActivityPubOrderedCollection } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger } from '../../helpers/logger.js'
import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants.js'
import { fetchAP } from './activity.js'
type HandlerFunction<T> = (items: T[]) => (Promise<any> | Bluebird<any>)
type CleanerFunction = (startedDate: Date) => Promise<any>
async function crawlCollectionPage <T> (argUrl: string, handler: HandlerFunction<T>, cleaner?: CleanerFunction) {
let url = argUrl
logger.info('Crawling ActivityPub data on %s.', url)
const startDate = new Date()
const response = await fetchAP<ActivityPubOrderedCollection<T>>(url)
const firstBody = response.body
const limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT
let i = 0
let nextLink = firstBody.first
while (nextLink && i < limit) {
let body: any
if (typeof nextLink === 'string') {
// Don't crawl ourselves
const remoteHost = new URL(nextLink).host
if (remoteHost === WEBSERVER.HOST) continue
url = nextLink
const res = await fetchAP<ActivityPubOrderedCollection<T>>(url)
body = res.body
} else {
// nextLink is already the object we want
body = nextLink
}
nextLink = body.next
i++
if (Array.isArray(body.orderedItems)) {
const items = body.orderedItems
logger.info('Processing %i ActivityPub items for %s.', items.length, url)
await handler(items)
}
}
if (cleaner) await retryTransactionWrapper(cleaner, startDate)
}
export {
crawlCollectionPage
}
+51
ファイルの表示
@@ -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
ファイルの表示
@@ -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
ファイルの表示
@@ -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
ファイルの表示
@@ -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
}
+157
ファイルの表示
@@ -0,0 +1,157 @@
import Bluebird from 'bluebird'
import { isArray } from '@server/helpers/custom-validators/misc.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail.js'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { FilteredModelAttributes } from '@server/types/index.js'
import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js'
import { PlaylistObject } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { getAPId } from '../activity.js'
import { getOrCreateAPActor } from '../actors/index.js'
import { crawlCollectionPage } from '../crawl.js'
import { getOrCreateAPVideo } from '../videos/index.js'
import {
fetchRemotePlaylistElement,
fetchRemoteVideoPlaylist,
playlistElementObjectToDBAttributes,
playlistObjectToDBAttributes
} from './shared/index.js'
const lTags = loggerTagsFactory('ap', 'video-playlist')
async function createAccountPlaylists (playlistUrls: string[]) {
await Bluebird.map(playlistUrls, async playlistUrl => {
try {
const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
if (exists === true) return
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
if (playlistObject === undefined) {
throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
}
return createOrUpdateVideoPlaylist(playlistObject)
} catch (err) {
logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
}
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
}
async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
await setVideoChannel(playlistObject, playlistAttributes)
const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
const playlistElementUrls = await fetchElementUrls(playlistObject)
// Refetch playlist from DB since elements fetching could be long in time
const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null)
await updatePlaylistThumbnail(playlistObject, playlist)
const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
playlist.setVideosLength(elementsLength)
return playlist
}
// ---------------------------------------------------------------------------
export {
createAccountPlaylists,
createOrUpdateVideoPlaylist
}
// ---------------------------------------------------------------------------
async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
}
const actor = await getOrCreateAPActor(getAPId(playlistObject.attributedTo[0]), 'all')
if (!actor.VideoChannel) {
logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
return
}
playlistAttributes.videoChannelId = actor.VideoChannel.id
playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
}
async function fetchElementUrls (playlistObject: PlaylistObject) {
let accItems: string[] = []
await crawlCollectionPage<string>(playlistObject.id, items => {
accItems = accItems.concat(items)
return Promise.resolve()
})
return accItems
}
async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
if (playlistObject.icon) {
let thumbnailModel: MThumbnail
try {
thumbnailModel = await updateRemotePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
} catch (err) {
logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
if (thumbnailModel) await thumbnailModel.removeThumbnail()
}
return
}
// Playlist does not have an icon, destroy existing one
if (playlist.hasThumbnail()) {
await playlist.Thumbnail.destroy()
playlist.Thumbnail = null
}
}
async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist)
await retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => {
await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
for (const element of elementsToCreate) {
await VideoPlaylistElementModel.create(element, { transaction: t })
}
}))
logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
return elementsToCreate.length
}
async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
await Bluebird.map(elementUrls, async elementUrl => {
try {
const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video-and-blacklist' })
elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
} catch (err) {
logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) })
}
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
return elementsToCreate
}
+35
ファイルの表示
@@ -0,0 +1,35 @@
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoPlaylistFullSummary } from '@server/types/models/index.js'
import { APObjectId } from '@peertube/peertube-models'
import { getAPId } from '../activity.js'
import { createOrUpdateVideoPlaylist } from './create-update.js'
import { scheduleRefreshIfNeeded } from './refresh.js'
import { fetchRemoteVideoPlaylist } from './shared/index.js'
async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise<MVideoPlaylistFullSummary> {
const playlistUrl = getAPId(playlistObjectArg)
const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
if (playlistFromDatabase) {
scheduleRefreshIfNeeded(playlistFromDatabase)
return playlistFromDatabase
}
const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
// playlistUrl is just an alias/redirection, so process object id instead
if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject)
const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject)
return playlistCreated
}
// ---------------------------------------------------------------------------
export {
getOrCreateAPVideoPlaylist
}
+3
ファイルの表示
@@ -0,0 +1,3 @@
export * from './get.js'
export * from './create-update.js'
export * from './refresh.js'
+55
ファイルの表示
@@ -0,0 +1,55 @@
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { PeerTubeRequestError } from '@server/helpers/requests.js'
import { JobQueue } from '@server/lib/job-queue/index.js'
import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models/index.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import { createOrUpdateVideoPlaylist } from './create-update.js'
import { fetchRemoteVideoPlaylist } from './shared/index.js'
function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
if (!playlist.isOutdated()) return
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
}
async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
if (!videoPlaylist.isOutdated()) return videoPlaylist
const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url)
logger.info('Refreshing playlist %s.', videoPlaylist.url, lTags())
try {
const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
if (playlistObject === undefined) {
logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags())
await videoPlaylist.setAsRefreshed()
return videoPlaylist
}
await createOrUpdateVideoPlaylist(playlistObject)
return videoPlaylist
} catch (err) {
const statusCode = (err as PeerTubeRequestError).statusCode
if (statusCode === HttpStatusCode.NOT_FOUND_404 || statusCode === HttpStatusCode.GONE_410) {
logger.info('Cannot refresh not existing playlist (404/410 error code) %s. Deleting it.', videoPlaylist.url, lTags())
await videoPlaylist.destroy()
return undefined
}
logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() })
await videoPlaylist.setAsRefreshed()
return videoPlaylist
}
}
export {
scheduleRefreshIfNeeded,
refreshVideoPlaylistIfNeeded
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './object-to-model-attributes.js'
export * from './url-to-object.js'
+39
ファイルの表示
@@ -0,0 +1,39 @@
import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoId, MVideoPlaylistId } from '@server/types/models/index.js'
export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
const privacy = hasAPPublic(to)
? VideoPlaylistPrivacy.PUBLIC
: VideoPlaylistPrivacy.UNLISTED
return {
name: playlistObject.name,
description: playlistObject.content,
privacy,
url: playlistObject.id,
uuid: playlistObject.uuid,
ownerAccountId: null,
videoChannelId: null,
createdAt: new Date(playlistObject.published),
updatedAt: new Date(playlistObject.updated)
} as AttributesOnly<VideoPlaylistModel>
}
export function playlistElementObjectToDBAttributes (
elementObject: PlaylistElementObject,
videoPlaylist: MVideoPlaylistId,
video: MVideoId
) {
return {
position: elementObject.position,
url: elementObject.id,
startTimestamp: elementObject.startTimestamp || null,
stopTimestamp: elementObject.stopTimestamp || null,
videoPlaylistId: videoPlaylist.id,
videoId: video.id
} as AttributesOnly<VideoPlaylistElementModel>
}
+47
ファイルの表示
@@ -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
ファイルの表示
@@ -0,0 +1 @@
export * from './process.js'
+32
ファイルの表示
@@ -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
ファイルの表示
@@ -0,0 +1,65 @@
import { ActivityAnnounce } from '@peertube/peertube-models'
import { getAPId } from '@server/lib/activitypub/activity.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { Notifier } from '../../notifier/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { maybeGetOrCreateAPVideo } from '../videos/index.js'
async function processAnnounceActivity (options: APProcessorOptions<ActivityAnnounce>) {
const { activity, byActor: actorAnnouncer } = options
// Only notify if it is not from a fetcher job
const notify = options.fromFetch !== true
// Announces by accounts are not supported
if (actorAnnouncer.type !== 'Application' && actorAnnouncer.type !== 'Group') return
return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity, notify)
}
// ---------------------------------------------------------------------------
export {
processAnnounceActivity
}
// ---------------------------------------------------------------------------
async function processVideoShare (actorAnnouncer: MActorSignature, activity: ActivityAnnounce, notify: boolean) {
const objectUri = getAPId(activity.object)
const { video, created: videoCreated } = await maybeGetOrCreateAPVideo({ videoObject: objectUri })
if (!video) return
await sequelizeTypescript.transaction(async t => {
// Add share entry
const share = {
actorId: actorAnnouncer.id,
videoId: video.id,
url: activity.id
}
const [ , created ] = await VideoShareModel.findOrCreate({
where: {
url: activity.id
},
defaults: share,
transaction: t
})
if (video.isOwned() && created === true) {
// Don't resend the activity to the sender
const exceptions = [ actorAnnouncer ]
await forwardVideoRelatedActivity(activity, t, exceptions, video)
}
return undefined
})
if (videoCreated && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
}
+191
ファイルの表示
@@ -0,0 +1,191 @@
import {
AbuseObject,
ActivityCreate,
ActivityCreateObject,
ActivityObject,
CacheFileObject,
PlaylistObject,
VideoCommentObject,
VideoObject,
WatchActionObject
} from '@peertube/peertube-models'
import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
import { isRedundancyAccepted } from '@server/lib/redundancy.js'
import { VideoCommentModel } from '@server/models/video/video-comment.js'
import { VideoModel } from '@server/models/video/video.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models/index.js'
import { Notifier } from '../../notifier/index.js'
import { fetchAPObjectIfNeeded } from '../activity.js'
import { createOrUpdateCacheFile } from '../cache-file.js'
import { createOrUpdateLocalVideoViewer } from '../local-video-viewer.js'
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
import { sendReplyApproval } from '../send/send-reply-approval.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { resolveThread } from '../video-comments.js'
import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) {
const { activity, byActor } = options
// Only notify if it is not from a fetcher job
const notify = options.fromFetch !== true
const activityObject = await fetchAPObjectIfNeeded<Exclude<ActivityObject, AbuseObject>>(activity.object)
const activityType = activityObject.type
if (activityType === 'Video') {
return processCreateVideo(activityObject, notify)
}
if (activityType === 'Note') {
// Comments will be fetched from videos
if (options.fromFetch) return
return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, options.fromFetch)
}
if (activityType === 'WatchAction') {
return retryTransactionWrapper(processCreateWatchAction, activityObject)
}
if (activityType === 'CacheFile') {
return retryTransactionWrapper(processCreateCacheFile, activity, activityObject, byActor)
}
if (activityType === 'Playlist') {
return retryTransactionWrapper(processCreatePlaylist, activity, activityObject, byActor)
}
logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
return Promise.resolve(undefined)
}
// ---------------------------------------------------------------------------
export {
processCreateActivity
}
// ---------------------------------------------------------------------------
async function processCreateVideo (videoToCreateData: VideoObject, notify: boolean) {
const syncParam = { rates: false, shares: false, comments: false, refreshVideo: false }
const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam })
if (created && notify) Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(video)
return video
}
async function processCreateCacheFile (
activity: ActivityCreate<CacheFileObject | string>,
cacheFile: CacheFileObject,
byActor: MActorSignature
) {
if (await isRedundancyAccepted(activity, byActor) !== true) return
const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
if (video.isOwned() && !canVideoBeFederated(video)) {
logger.warn(`Do not process create cache file ${cacheFile.object} on a video that cannot be federated`)
return
}
await sequelizeTypescript.transaction(async t => {
return createOrUpdateCacheFile(cacheFile, video, byActor, t)
})
if (video.isOwned()) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
}
}
async function processCreateWatchAction (watchAction: WatchActionObject) {
if (watchAction.actionStatus !== 'CompletedActionStatus') return
const video = await VideoModel.loadByUrl(watchAction.object)
if (video.remote) return
await sequelizeTypescript.transaction(async t => {
return createOrUpdateLocalVideoViewer(watchAction, video, t)
})
}
async function processCreateVideoComment (
activity: ActivityCreate<VideoCommentObject | string>,
commentObject: VideoCommentObject,
byActor: MActorSignature,
fromFetch: false
) {
if (fromFetch) throw new Error('Processing create video comment from fetch is not supported')
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url)
let video: MVideoAccountLightBlacklistAllFiles
let created: boolean
let comment: MCommentOwnerVideo
try {
const resolveThreadResult = await resolveThread({ url: commentObject.id, isVideo: false })
if (!resolveThreadResult) return // Comment not accepted
video = resolveThreadResult.video
created = resolveThreadResult.commentCreated
comment = resolveThreadResult.comment
} catch (err) {
logger.debug(
'Cannot process video comment because we could not resolve thread %s. Maybe it was not a video thread, so skip it.',
commentObject.inReplyTo,
{ err }
)
return
}
// Try to not forward unwanted comments on our videos
if (video.isOwned()) {
if (!canVideoBeFederated(video)) {
logger.info('Skip comment forward on non federated video' + video.url)
return
}
if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) {
logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url)
return
}
// New non-moderated comment -> auto approve reply
if (comment.heldForReview === false && created) {
const reply = await VideoCommentModel.loadById(comment.inReplyToCommentId)
sendReplyApproval(Object.assign(comment, { InReplyToVideoComment: reply }), 'ApproveReply')
}
// New comment or re-sent after an approval -> forward comment
if (comment.heldForReview === false && (created || commentObject.replyApproval)) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
}
}
if (created) Notifier.Instance.notifyOnNewComment(comment)
}
async function processCreatePlaylist (
activity: ActivityCreate<PlaylistObject | string>,
playlistObject: PlaylistObject,
byActor: MActorSignature
) {
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
await createOrUpdateVideoPlaylist(playlistObject, activity.to)
}
+154
ファイルの表示
@@ -0,0 +1,154 @@
import { ActivityDelete } from '@peertube/peertube-models'
import { isAccountActor, isChannelActor } from '@server/helpers/actors.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { ActorModel } from '../../../models/actor/actor.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
import { VideoModel } from '../../../models/video/video.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import {
MAccountActor,
MActor,
MActorFull,
MActorSignature,
MChannelAccountActor,
MChannelActor,
MCommentOwnerVideo
} from '../../../types/models/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) {
const { activity, byActor } = options
const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id
if (activity.actor === objectUrl) {
// We need more attributes (all the account and channel)
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
if (isAccountActor(byActorFull.type)) {
if (!byActorFull.Account) throw new Error('Actor ' + byActorFull.url + ' is a person but we cannot find it in database.')
const accountToDelete = byActorFull.Account as MAccountActor
accountToDelete.Actor = byActorFull
return retryTransactionWrapper(processDeleteAccount, accountToDelete)
} else if (isChannelActor(byActorFull.type)) {
if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
const channelToDelete = byActorFull.VideoChannel as MChannelAccountActor & { Actor: MActorFull }
channelToDelete.Actor = byActorFull
return retryTransactionWrapper(processDeleteVideoChannel, channelToDelete)
}
}
{
const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(objectUrl)
if (videoCommentInstance) {
return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
}
}
{
const videoInstance = await VideoModel.loadByUrlAndPopulateAccountAndFiles(objectUrl)
if (videoInstance) {
if (videoInstance.isOwned()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`)
return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance)
}
}
{
const videoPlaylist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(objectUrl)
if (videoPlaylist) {
if (videoPlaylist.isOwned()) throw new Error(`Remote instance cannot delete owned playlist ${videoPlaylist.url}.`)
return retryTransactionWrapper(processDeleteVideoPlaylist, byActor, videoPlaylist)
}
}
return undefined
}
// ---------------------------------------------------------------------------
export {
processDeleteActivity
}
// ---------------------------------------------------------------------------
async function processDeleteVideo (actor: MActor, videoToDelete: VideoModel) {
logger.debug('Removing remote video "%s".', videoToDelete.uuid)
await sequelizeTypescript.transaction(async t => {
if (videoToDelete.VideoChannel.Account.Actor.id !== actor.id) {
throw new Error('Account ' + actor.url + ' does not own video channel ' + videoToDelete.VideoChannel.Actor.url)
}
await videoToDelete.destroy({ transaction: t })
})
logger.info('Remote video with uuid %s removed.', videoToDelete.uuid)
}
async function processDeleteVideoPlaylist (actor: MActor, playlistToDelete: VideoPlaylistModel) {
logger.debug('Removing remote video playlist "%s".', playlistToDelete.uuid)
await sequelizeTypescript.transaction(async t => {
if (playlistToDelete.OwnerAccount.Actor.id !== actor.id) {
throw new Error('Account ' + actor.url + ' does not own video playlist ' + playlistToDelete.url)
}
await playlistToDelete.destroy({ transaction: t })
})
logger.info('Remote video playlist with uuid %s removed.', playlistToDelete.uuid)
}
async function processDeleteAccount (accountToRemove: MAccountActor) {
logger.debug('Removing remote account "%s".', accountToRemove.Actor.url)
await sequelizeTypescript.transaction(async t => {
await accountToRemove.destroy({ transaction: t })
})
logger.info('Remote account %s removed.', accountToRemove.Actor.url)
}
async function processDeleteVideoChannel (videoChannelToRemove: MChannelActor) {
logger.debug('Removing remote video channel "%s".', videoChannelToRemove.Actor.url)
await sequelizeTypescript.transaction(async t => {
await videoChannelToRemove.destroy({ transaction: t })
})
logger.info('Remote video channel %s removed.', videoChannelToRemove.Actor.url)
}
function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) {
// Already deleted
if (videoComment.isDeleted()) return Promise.resolve()
logger.debug('Removing remote video comment "%s".', videoComment.url)
return sequelizeTypescript.transaction(async t => {
if (byActor.Account.id !== videoComment.Account.id && byActor.Account.id !== videoComment.Video.VideoChannel.accountId) {
throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
}
videoComment.markAsDeleted()
await videoComment.save({ transaction: t })
if (videoComment.Video.isOwned()) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, t, exceptions, videoComment.Video)
}
logger.info('Remote video comment %s removed.', videoComment.url)
})
}
+62
ファイルの表示
@@ -0,0 +1,62 @@
import { ActivityDislike } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { VideoModel } from '@server/models/video/video.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) {
const { activity, byActor } = options
return retryTransactionWrapper(processDislike, activity, byActor)
}
// ---------------------------------------------------------------------------
export {
processDislikeActivity
}
// ---------------------------------------------------------------------------
async function processDislike (activity: ActivityDislike, byActor: MActorSignature) {
const videoUrl = activity.object
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
if (!onlyVideo?.isOwned()) return
if (!canVideoBeFederated(onlyVideo)) {
logger.warn(`Do not process dislike on video ${videoUrl} that cannot be federated`)
return
}
return sequelizeTypescript.transaction(async t => {
const video = await VideoModel.loadFull(onlyVideo.id, t)
const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
if (existingRate && existingRate.type === 'dislike') return
await video.increment('dislikes', { transaction: t })
video.dislikes++
if (existingRate && existingRate.type === 'like') {
await video.decrement('likes', { transaction: t })
video.likes--
}
const rate = existingRate || new AccountVideoRateModel()
rate.type = 'dislike'
rate.videoId = video.id
rate.accountId = byAccount.id
rate.url = activity.id
await rate.save({ transaction: t })
await federateVideoIfNeeded(video, false, t)
})
}
+103
ファイルの表示
@@ -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 })
}
}
}
+156
ファイルの表示
@@ -0,0 +1,156 @@
import { Transaction } from 'sequelize'
import { ActivityFollow } from '@peertube/peertube-models'
import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
import { AccountModel } from '@server/models/account/account.js'
import { getServerActor } from '@server/models/application/application.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { logger } from '../../../helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { getAPId } from '../../../lib/activitypub/activity.js'
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
import { ActorModel } from '../../../models/actor/actor.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorFollow, MActorFull, MActorId, MActorSignature } from '../../../types/models/index.js'
import { Notifier } from '../../notifier/index.js'
import { autoFollowBackIfNeeded } from '../follow.js'
import { sendAccept, sendReject } from '../send/index.js'
async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) {
const { activity, byActor } = options
const activityId = activity.id
const objectId = getAPId(activity.object)
return retryTransactionWrapper(processFollow, byActor, activityId, objectId)
}
// ---------------------------------------------------------------------------
export {
processFollowActivity
}
// ---------------------------------------------------------------------------
async function processFollow (byActor: MActorSignature, activityId: string, targetActorURL: string) {
const { actorFollow, created, targetActor } = await sequelizeTypescript.transaction(async t => {
const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
if (!targetActor) throw new Error('Unknown actor')
if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
if (await rejectIfInstanceFollowDisabled(byActor, activityId, targetActor)) return { actorFollow: undefined }
if (await rejectIfMuted(byActor, activityId, targetActor)) return { actorFollow: undefined }
const [ actorFollow, created ] = await ActorFollowModel.findOrCreateCustom({
byActor,
targetActor,
activityId,
state: await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
? 'pending'
: 'accepted',
transaction: t
})
if (rejectIfAlreadyRejected(actorFollow, byActor, activityId, targetActor)) return { actorFollow: undefined }
await acceptIfNeeded(actorFollow, targetActor, t)
await fixFollowURLIfNeeded(actorFollow, activityId, t)
actorFollow.ActorFollower = byActor
actorFollow.ActorFollowing = targetActor
// Target sends to actor he accepted the follow request
if (actorFollow.state === 'accepted') {
sendAccept(actorFollow)
await autoFollowBackIfNeeded(actorFollow, t)
}
return { actorFollow, created, targetActor }
})
// Rejected
if (!actorFollow) return
if (created) {
const follower = await ActorModel.loadFull(byActor.id)
const actorFollowFull = Object.assign(actorFollow, { ActorFollowing: targetActor, ActorFollower: follower })
if (await isFollowingInstance(targetActor)) {
Notifier.Instance.notifyOfNewInstanceFollow(actorFollowFull)
} else {
Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
}
}
logger.info('Actor %s is followed by actor %s.', targetActorURL, byActor.url)
}
async function rejectIfInstanceFollowDisabled (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
if (await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
sendReject(activityId, byActor, targetActor)
return true
}
return false
}
async function rejectIfMuted (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
const followerAccount = await AccountModel.load(byActor.Account.id)
const followingAccountId = targetActor.Account
if (followerAccount && await isBlockedByServerOrAccount(followerAccount, followingAccountId)) {
logger.info('Rejecting %s because follower is muted.', byActor.url)
sendReject(activityId, byActor, targetActor)
return true
}
return false
}
function rejectIfAlreadyRejected (actorFollow: MActorFollow, byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
// Already rejected
if (actorFollow.state === 'rejected') {
logger.info('Rejecting %s because follow is already rejected.', byActor.url)
sendReject(activityId, byActor, targetActor)
return true
}
return false
}
async function acceptIfNeeded (actorFollow: MActorFollow, targetActor: MActorFull, transaction: Transaction) {
// Set the follow as accepted if the remote actor follows a channel or account
// Or if the instance automatically accepts followers
if (actorFollow.state === 'accepted') return
if (!await isFollowingInstance(targetActor)) return
if (CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL === true && await isFollowingInstance(targetActor)) return
actorFollow.state = 'accepted'
await actorFollow.save({ transaction })
}
async function fixFollowURLIfNeeded (actorFollow: MActorFollow, activityId: string, transaction: Transaction) {
// Before PeerTube V3 we did not save the follow ID. Try to fix these old follows
if (!actorFollow.url) {
actorFollow.url = activityId
await actorFollow.save({ transaction })
}
}
async function isFollowingInstance (targetActor: MActorId) {
const serverActor = await getServerActor()
return targetActor.id === serverActor.id
}
+64
ファイルの表示
@@ -0,0 +1,64 @@
import { ActivityLike } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { VideoModel } from '@server/models/video/video.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { getAPId } from '../../../lib/activitypub/activity.js'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { canVideoBeFederated, federateVideoIfNeeded, maybeGetOrCreateAPVideo } from '../videos/index.js'
async function processLikeActivity (options: APProcessorOptions<ActivityLike>) {
const { activity, byActor } = options
return retryTransactionWrapper(processLikeVideo, byActor, activity)
}
// ---------------------------------------------------------------------------
export {
processLikeActivity
}
// ---------------------------------------------------------------------------
async function processLikeVideo (byActor: MActorSignature, activity: ActivityLike) {
const videoUrl = getAPId(activity.object)
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video-and-blacklist' })
if (!onlyVideo?.isOwned()) return
if (!canVideoBeFederated(onlyVideo)) {
logger.warn(`Do not process like on video ${videoUrl} that cannot be federated`)
return
}
return sequelizeTypescript.transaction(async t => {
const video = await VideoModel.loadFull(onlyVideo.id, t)
const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
if (existingRate && existingRate.type === 'like') return
if (existingRate && existingRate.type === 'dislike') {
await video.decrement('dislikes', { transaction: t })
video.dislikes--
}
await video.increment('likes', { transaction: t })
video.likes++
const rate = existingRate || new AccountVideoRateModel()
rate.type = 'like'
rate.videoId = video.id
rate.accountId = byAccount.id
rate.url = activity.id
await rate.save({ transaction: t })
await federateVideoIfNeeded(video, false, t)
})
}
+33
ファイルの表示
@@ -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
})
}
+45
ファイルの表示
@@ -0,0 +1,45 @@
import { ActivityApproveReply, ActivityRejectReply, ActivityType } from '@peertube/peertube-models'
import { VideoCommentModel } from '@server/models/video/video-comment.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
import { sendCreateVideoCommentIfNeeded } from '../send/send-create.js'
export function processReplyApprovalFactory (type: Extract<ActivityType, 'ApproveReply' | 'RejectReply'>) {
return async (options: APProcessorOptions<ActivityApproveReply | ActivityRejectReply>) => {
if (type === 'RejectReply') return // Not yet implemented
const { activity, byActor } = options
const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(activity.object)
if (!comment || comment.isDeleted()) {
throw new Error(`Cannot process reply approval on comment ${comment.url} that doesn't exist`)
}
if (comment.isOwned() !== true) {
throw new Error(`Cannot process reply approval on non-owned comment ${comment.url}`)
}
if (byActor.id !== comment.Video.VideoChannel.Account.Actor.id) {
throw new Error(`Cannot process reply approval on ${comment.url} by non video owner`)
}
return processApproveReply(activity.id, comment)
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function processApproveReply (replyApproval: string, comment: MCommentOwnerVideoReply) {
if (comment.heldForReview === false || comment.replyApproval === replyApproval) return
return sequelizeTypescript.transaction(async t => {
comment.heldForReview = false
comment.replyApproval = replyApproval
await comment.save({ transaction: t })
await sendCreateVideoCommentIfNeeded(comment, t)
})
}
+181
ファイルの表示
@@ -0,0 +1,181 @@
import {
ActivityAnnounce,
ActivityCreate,
ActivityDislike,
ActivityFollow,
ActivityLike,
ActivityUndo,
ActivityUndoObject,
CacheFileObject
} from '@peertube/peertube-models'
import { VideoModel } from '@server/models/video/video.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
import { ActorModel } from '../../../models/actor/actor.js'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { fetchAPObjectIfNeeded } from '../activity.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { federateVideoIfNeeded, getOrCreateAPVideo, maybeGetOrCreateAPVideo } from '../videos/index.js'
async function processUndoActivity (options: APProcessorOptions<ActivityUndo<ActivityUndoObject>>) {
const { activity, byActor } = options
const activityToUndo = activity.object
if (activityToUndo.type === 'Like') {
return retryTransactionWrapper(processUndoLike, byActor, activity)
}
if (activityToUndo.type === 'Create') {
const objectToUndo = await fetchAPObjectIfNeeded<CacheFileObject>(activityToUndo.object)
if (objectToUndo.type === 'CacheFile') {
return retryTransactionWrapper(processUndoCacheFile, byActor, activity, objectToUndo)
}
}
if (activityToUndo.type === 'Dislike') {
return retryTransactionWrapper(processUndoDislike, byActor, activity)
}
if (activityToUndo.type === 'Follow') {
return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
}
if (activityToUndo.type === 'Announce') {
return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo)
}
logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
return undefined
}
// ---------------------------------------------------------------------------
export {
processUndoActivity
}
// ---------------------------------------------------------------------------
async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo<ActivityLike>) {
const likeActivity = activity.object
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: likeActivity.object })
if (!onlyVideo?.isOwned()) return
return sequelizeTypescript.transaction(async t => {
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
const video = await VideoModel.loadFull(onlyVideo.id, t)
const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, likeActivity.id, t)
if (!rate || rate.type !== 'like') {
logger.warn('Unknown like by account %d for video %d.', byActor.Account.id, video.id)
return
}
await rate.destroy({ transaction: t })
await video.decrement('likes', { transaction: t })
video.likes--
await federateVideoIfNeeded(video, false, t)
})
}
async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo<ActivityDislike>) {
const dislikeActivity = activity.object
const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: dislikeActivity.object })
if (!onlyVideo?.isOwned()) return
return sequelizeTypescript.transaction(async t => {
if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
const video = await VideoModel.loadFull(onlyVideo.id, t)
const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislikeActivity.id, t)
if (!rate || rate.type !== 'dislike') {
logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id)
return
}
await rate.destroy({ transaction: t })
await video.decrement('dislikes', { transaction: t })
video.dislikes--
await federateVideoIfNeeded(video, false, t)
})
}
// ---------------------------------------------------------------------------
async function processUndoCacheFile (
byActor: MActorSignature,
activity: ActivityUndo<ActivityCreate<CacheFileObject>>,
cacheFileObject: CacheFileObject
) {
const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
return sequelizeTypescript.transaction(async t => {
const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
if (!cacheFile) {
logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id)
return
}
if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
await cacheFile.destroy({ transaction: t })
if (video.isOwned()) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, t, exceptions, video)
}
})
}
function processUndoAnnounce (byActor: MActorSignature, announceActivity: ActivityAnnounce) {
return sequelizeTypescript.transaction(async t => {
const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
if (!share) {
logger.warn('Unknown video share %d', announceActivity.id)
return
}
if (share.actorId !== byActor.id) throw new Error(`${share.url} is not shared by ${byActor.url}.`)
await share.destroy({ transaction: t })
if (share.Video.isOwned()) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(announceActivity, t, exceptions, share.Video)
}
})
}
// ---------------------------------------------------------------------------
function processUndoFollow (follower: MActorSignature, followActivity: ActivityFollow) {
return sequelizeTypescript.transaction(async t => {
const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
if (!actorFollow) {
logger.warn('Unknown actor follow %d -> %d.', follower.id, following.id)
return
}
await actorFollow.destroy({ transaction: t })
return undefined
})
}
+131
ファイルの表示
@@ -0,0 +1,131 @@
import {
ActivityPubActor,
ActivityPubActorType,
ActivityUpdate,
ActivityUpdateObject,
CacheFileObject,
PlaylistObject,
VideoObject
} from '@peertube/peertube-models'
import { isActorTypeValid } from '@server/helpers/custom-validators/activitypub/actor.js'
import { isRedundancyAccepted } from '@server/lib/redundancy.js'
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file.js'
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos.js'
import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
import { logger } from '../../../helpers/logger.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { ActorModel } from '../../../models/actor/actor.js'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorFull, MActorSignature } from '../../../types/models/index.js'
import { fetchAPObjectIfNeeded } from '../activity.js'
import { APActorUpdater } from '../actors/updater.js'
import { createOrUpdateCacheFile } from '../cache-file.js'
import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { APVideoUpdater, canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
const { activity, byActor } = options
const object = await fetchAPObjectIfNeeded(activity.object)
const objectType = object.type
if (objectType === 'Video') {
return retryTransactionWrapper(processUpdateVideo, activity)
}
if (isActorTypeValid(objectType as ActivityPubActorType)) {
// We need more attributes
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
return retryTransactionWrapper(processUpdateActor, byActorFull, object)
}
if (objectType === 'CacheFile') {
// We need more attributes
const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity, object)
}
if (objectType === 'Playlist') {
return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
}
return undefined
}
// ---------------------------------------------------------------------------
export {
processUpdateActivity
}
// ---------------------------------------------------------------------------
async function processUpdateVideo (activity: ActivityUpdate<VideoObject | string>) {
const videoObject = activity.object as VideoObject
if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
logger.debug('Video sent by update is not valid.', { videoObject })
return undefined
}
const { video, created } = await getOrCreateAPVideo({
videoObject: videoObject.id,
allowRefresh: false,
fetchType: 'all'
})
// We did not have this video, it has been created so no need to update
if (created) return
const updater = new APVideoUpdater(videoObject, video)
return updater.update(activity.to)
}
async function processUpdateCacheFile (
byActor: MActorSignature,
activity: ActivityUpdate<CacheFileObject | string>,
cacheFileObject: CacheFileObject
) {
if (await isRedundancyAccepted(activity, byActor) !== true) return
if (!isCacheFileObjectValid(cacheFileObject)) {
logger.debug('Cache file object sent by update is not valid.', { cacheFileObject })
return undefined
}
const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
if (video.isOwned() && !canVideoBeFederated(video)) {
logger.warn(`Do not process update cache file on video ${activity.object} that cannot be federated`)
return
}
await sequelizeTypescript.transaction(async t => {
await createOrUpdateCacheFile(cacheFileObject, video, byActor, t)
})
if (video.isOwned()) {
// Don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
}
}
async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
logger.debug('Updating remote account "%s".', actorObject.url)
const updater = new APActorUpdater(actorObject, actor)
return updater.update()
}
async function processUpdatePlaylist (
byActor: MActorSignature,
activity: ActivityUpdate<PlaylistObject | string>,
playlistObject: PlaylistObject
) {
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
await createOrUpdateVideoPlaylist(playlistObject, activity.to)
}
+63
ファイルの表示
@@ -0,0 +1,63 @@
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { ActivityView } from '@peertube/peertube-models'
import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
import { MActorSignature } from '../../../types/models/index.js'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
import { getOrCreateAPVideo } from '../videos/index.js'
async function processViewActivity (options: APProcessorOptions<ActivityView>) {
const { activity, byActor } = options
return processCreateView(activity, byActor)
}
// ---------------------------------------------------------------------------
export {
processViewActivity
}
// ---------------------------------------------------------------------------
async function processCreateView (activity: ActivityView, byActor: MActorSignature) {
const videoObject = activity.object
const { video } = await getOrCreateAPVideo({
videoObject,
fetchType: 'only-video-and-blacklist',
allowRefresh: false
})
await VideoViewsManager.Instance.processRemoteView({
video,
viewerId: activity.id,
viewerExpires: getExpires(activity)
? new Date(getExpires(activity))
: undefined,
viewerResultCounter: getViewerResultCounter(activity)
})
if (video.isOwned()) {
// Forward the view but don't resend the activity to the sender
const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
}
}
// Viewer protocol V2
function getViewerResultCounter (activity: ActivityView) {
const result = activity.result
if (!getExpires(activity) || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined
const counter = parseInt(result.userInteractionCount + '')
if (isNaN(counter)) return undefined
return counter
}
// TODO: compat with < 6.1, remove in 7.0
function getExpires (activity: ActivityView) {
return activity.expires || activity['expiration'] as string
}
+91
ファイルの表示
@@ -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)
}
}
}
+70
ファイルの表示
@@ -0,0 +1,70 @@
import { ContextType } from '@peertube/peertube-models'
import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@server/initializers/constants.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { getServerActor } from '@server/models/application/application.js'
import { MActor } from '@server/types/models/index.js'
import { getContextFilter } from '../context.js'
import { buildDigestFromWorker, signJsonLDObjectFromWorker } from '@server/lib/worker/parent-process.js'
import { signAndContextify } from '@server/helpers/activity-pub-utils.js'
import { logger } from '@server/helpers/logger.js'
type Payload <T> = { body: T, contextType: ContextType, signatureActorId?: number }
export async function computeBody <T> (
payload: Payload<T>
): Promise<T | T & { type: 'RsaSignature2017', creator: string, created: string }> {
let body = payload.body
if (payload.signatureActorId) {
const actorSignature = await ActorModel.load(payload.signatureActorId)
if (!actorSignature) throw new Error('Unknown signature actor id.')
try {
body = await signAndContextify({
byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey },
data: payload.body,
contextType: payload.contextType,
contextFilter: getContextFilter(),
signerFunction: signJsonLDObjectFromWorker
})
} catch (err) {
logger.error('Cannot sign and contextify body', { body, err })
}
}
return body
}
export async function buildGlobalHTTPHeaders (body: any) {
return {
'digest': await buildDigestFromWorker(body),
'content-type': 'application/activity+json',
'accept': ACTIVITY_PUB.ACCEPT_HEADER
}
}
export async function buildSignedRequestOptions (options: {
signatureActorId?: number
hasPayload: boolean
}) {
let actor: MActor | null
if (options.signatureActorId) {
actor = await ActorModel.load(options.signatureActorId)
if (!actor) throw new Error('Unknown signature actor id.')
} else {
// We need to sign the request, so use the server
actor = await getServerActor()
}
const keyId = actor.url
return {
algorithm: HTTP_SIGNATURE.ALGORITHM,
authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
keyId,
key: actor.privateKey,
headers: options.hasPayload
? HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD
: HTTP_SIGNATURE.HEADERS_TO_SIGN_WITHOUT_PAYLOAD
}
}
+11
ファイルの表示
@@ -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
ファイルの表示
@@ -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
}
}
+58
ファイルの表示
@@ -0,0 +1,58 @@
import { Transaction } from 'sequelize'
import { ActivityAnnounce, ActivityAudience } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { MActorLight, MVideo } from '../../../types/models/index.js'
import { MVideoShare } from '../../../types/models/video/index.js'
import { audiencify, getAudience } from '../audience.js'
import { getActorsInvolvedInVideo, getAudienceFromFollowersOf } from './shared/index.js'
import { broadcastToFollowers } from './shared/send-utils.js'
async function buildAnnounceWithVideoAudience (
byActor: MActorLight,
videoShare: MVideoShare,
video: MVideo,
t: Transaction
) {
const announcedObject = video.url
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
return { activity, actorsInvolvedInVideo }
}
async function sendVideoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
const { activity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction)
logger.info('Creating job to send announce %s.', videoShare.url)
return broadcastToFollowers({
data: activity,
byActor,
toFollowersOf: actorsInvolvedInVideo,
transaction,
actorsException: [ byActor ],
contextType: 'Announce'
})
}
function buildAnnounceActivity (url: string, byActor: MActorLight, object: string, audience?: ActivityAudience): ActivityAnnounce {
if (!audience) audience = getAudience(byActor)
return audiencify({
type: 'Announce' as 'Announce',
id: url,
actor: byActor.url,
object
}, audience)
}
// ---------------------------------------------------------------------------
export {
sendVideoAnnounce,
buildAnnounceActivity,
buildAnnounceWithVideoAudience
}
+235
ファイルの表示
@@ -0,0 +1,235 @@
import {
ActivityAudience,
ActivityCreate,
ActivityCreateObject,
ContextType,
VideoCommentObject,
VideoPlaylistPrivacy,
VideoPrivacy
} from '@peertube/peertube-models'
import { AccountModel } from '@server/models/account/account.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoModel } from '@server/models/video/video.js'
import { Transaction } from 'sequelize'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
import {
MActorLight,
MCommentOwnerVideoReply,
MLocalVideoViewerWithWatchSections,
MVideoAP, MVideoAccountLight,
MVideoPlaylistFull,
MVideoRedundancyFileVideo,
MVideoRedundancyStreamingPlaylistVideo
} from '../../../types/models/index.js'
import { audiencify, getAudience } from '../audience.js'
import { canVideoBeFederated } from '../videos/federate.js'
import {
broadcastToActors,
broadcastToFollowers,
getActorsInvolvedInVideo,
getAudienceFromFollowersOf,
getVideoCommentAudience,
sendVideoActivityToOrigin,
sendVideoRelatedActivity,
unicastTo
} from './shared/index.js'
const lTags = loggerTagsFactory('ap', 'create')
export async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
if (!canVideoBeFederated(video)) return undefined
logger.info('Creating job to send video creation of %s.', video.url, lTags(video.uuid))
const byActor = video.VideoChannel.Account.Actor
const videoObject = await video.toActivityPubObject()
const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
const createActivity = buildCreateActivity(video.url, byActor, videoObject, audience)
return broadcastToFollowers({
data: createActivity,
byActor,
toFollowersOf: [ byActor ],
transaction,
contextType: 'Video'
})
}
export async function sendCreateCacheFile (
byActor: MActorLight,
video: MVideoAccountLight,
fileRedundancy: MVideoRedundancyStreamingPlaylistVideo | MVideoRedundancyFileVideo
) {
logger.info('Creating job to send file cache of %s.', fileRedundancy.url, lTags(video.uuid))
return sendVideoRelatedCreateActivity({
byActor,
video,
url: fileRedundancy.url,
object: fileRedundancy.toActivityPubObject(),
contextType: 'CacheFile'
})
}
export async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
const byActor = await getServerActor()
const activityBuilder = (audience: ActivityAudience) => {
return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience)
}
return sendVideoActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
}
export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
logger.info('Creating job to send create video playlist of %s.', playlist.url, lTags(playlist.uuid))
const byActor = playlist.OwnerAccount.Actor
const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
const object = await playlist.toActivityPubObject(null, transaction)
const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
const serverActor = await getServerActor()
const toFollowersOf = [ byActor, serverActor ]
if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
return broadcastToFollowers({
data: createActivity,
byActor,
toFollowersOf,
transaction,
contextType: 'Playlist'
})
}
export async function sendCreateVideoCommentIfNeeded (comment: MCommentOwnerVideoReply, transaction: Transaction) {
const isOrigin = comment.Video.isOwned()
if (isOrigin) {
const videoWithBlacklist = await VideoModel.loadWithBlacklist(comment.Video.id)
if (!canVideoBeFederated(videoWithBlacklist)) {
logger.debug(`Do not send comment ${comment.url} on a video that cannot be federated`)
return undefined
}
if (comment.heldForReview) {
logger.debug(`Do not send comment ${comment.url} that requires approval`)
return undefined
}
}
logger.info('Creating job to send comment %s.', comment.url)
const byActor = comment.Account.Actor
const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction })
const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
// Add the actor that commented too
actorsInvolvedInComment.push(byActor)
const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
.map(c => c.Account.Actor)
let audience: ActivityAudience
if (isOrigin) {
audience = getVideoCommentAudience(comment, threadParentComments, actorsInvolvedInComment, isOrigin)
} else {
audience = getAudienceFromFollowersOf(actorsInvolvedInComment.concat(parentsCommentActors))
}
const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience)
// This was a reply, send it to the parent actors
const actorsException = [ byActor ]
await broadcastToActors({
data: createActivity,
byActor,
toActors: parentsCommentActors,
transaction,
actorsException,
contextType: 'Comment'
})
// Broadcast to our followers
await broadcastToFollowers({
data: createActivity,
byActor,
toFollowersOf: [ byActor ],
transaction,
contextType: 'Comment'
})
// Send to actors involved in the comment
if (isOrigin) {
return broadcastToFollowers({
data: createActivity,
byActor,
toFollowersOf: actorsInvolvedInComment,
transaction,
actorsException,
contextType: 'Comment'
})
}
// Send to origin
return transaction.afterCommit(() => {
return unicastTo({
data: createActivity,
byActor,
toActorUrl: videoAccount.Actor.getSharedInbox(),
contextType: 'Comment'
})
})
}
export function buildCreateActivity <T extends ActivityCreateObject> (
url: string,
byActor: MActorLight,
object: T,
audience?: ActivityAudience
): ActivityCreate<T> {
if (!audience) audience = getAudience(byActor)
return audiencify(
{
type: 'Create' as 'Create',
id: url + '/activity',
actor: byActor.url,
object: typeof object === 'string'
? object
: audiencify(object, audience)
},
audience
)
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function sendVideoRelatedCreateActivity (options: {
byActor: MActorLight
video: MVideoAccountLight
url: string
object: any
contextType: ContextType
transaction?: Transaction
}) {
const activityBuilder = (audience: ActivityAudience) => {
return buildCreateActivity(options.url, options.byActor, options.object, audience)
}
return sendVideoRelatedActivity(activityBuilder, options)
}
+162
ファイルの表示
@@ -0,0 +1,162 @@
import { Transaction } from 'sequelize'
import { getServerActor } from '@server/models/application/application.js'
import { ActivityAudience, ActivityDelete } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { ActorModel } from '../../../models/actor/actor.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { MActorUrl } from '../../../types/models/index.js'
import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../types/models/video/index.js'
import { audiencify } from '../audience.js'
import { getDeleteActivityPubUrl } from '../url.js'
import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared/index.js'
import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './shared/send-utils.js'
import { AccountModel } from '@server/models/account/account.js'
async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
logger.info('Creating job to broadcast delete of video %s.', video.url)
const byActor = video.VideoChannel.Account.Actor
const activityBuilder = (audience: ActivityAudience) => {
const url = getDeleteActivityPubUrl(video.url)
return buildDeleteActivity(url, video.url, byActor, audience)
}
return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'Delete', transaction })
}
async function sendDeleteActor (byActor: ActorModel, transaction: Transaction) {
logger.info('Creating job to broadcast delete of actor %s.', byActor.url)
const url = getDeleteActivityPubUrl(byActor.url)
const activity = buildDeleteActivity(url, byActor.url, byActor)
const actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction)
// In case the actor did not have any videos
const serverActor = await getServerActor()
actorsInvolved.push(serverActor)
actorsInvolved.push(byActor)
return broadcastToFollowers({
data: activity,
byActor,
toFollowersOf: actorsInvolved,
contextType: 'Delete',
transaction
})
}
async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, transaction: Transaction) {
logger.info('Creating job to send delete of comment %s.', videoComment.url)
const isVideoOrigin = videoComment.Video.isOwned()
const url = getDeleteActivityPubUrl(videoComment.url)
const videoAccount = await AccountModel.load(videoComment.Video.VideoChannel.Account.id, transaction)
const byActor = videoComment.isOwned()
? videoComment.Account.Actor
: videoAccount.Actor
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment, transaction })
const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
const actorsInvolvedInComment = await getActorsInvolvedInVideo(videoComment.Video, transaction)
actorsInvolvedInComment.push(byActor) // Add the actor that commented the video
const audience = getVideoCommentAudience(videoComment, threadParentCommentsFiltered, actorsInvolvedInComment, isVideoOrigin)
const activity = buildDeleteActivity(url, videoComment.url, byActor, audience)
// This was a reply, send it to the parent actors
const actorsException = [ byActor ]
await broadcastToActors({
data: activity,
byActor,
toActors: threadParentCommentsFiltered.map(c => c.Account.Actor),
transaction,
contextType: 'Delete',
actorsException
})
// Broadcast to our followers
await broadcastToFollowers({
data: activity,
byActor,
toFollowersOf: [ byActor ],
contextType: 'Delete',
transaction
})
// Send to actors involved in the comment
if (isVideoOrigin) {
return broadcastToFollowers({
data: activity,
byActor,
toFollowersOf: actorsInvolvedInComment,
transaction,
contextType: 'Delete',
actorsException
})
}
// Send to origin
return transaction.afterCommit(() => {
return unicastTo({
data: activity,
byActor,
toActorUrl: videoAccount.Actor.getSharedInbox(),
contextType: 'Delete'
})
})
}
async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary, transaction: Transaction) {
logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
const byActor = videoPlaylist.OwnerAccount.Actor
const url = getDeleteActivityPubUrl(videoPlaylist.url)
const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
const serverActor = await getServerActor()
const toFollowersOf = [ byActor, serverActor ]
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
return broadcastToFollowers({
data: activity,
byActor,
toFollowersOf,
contextType: 'Delete',
transaction
})
}
// ---------------------------------------------------------------------------
export {
sendDeleteVideo,
sendDeleteActor,
sendDeleteVideoComment,
sendDeleteVideoPlaylist
}
// ---------------------------------------------------------------------------
function buildDeleteActivity (url: string, object: string, byActor: MActorUrl, audience?: ActivityAudience): ActivityDelete {
const activity = {
type: 'Delete' as 'Delete',
id: url,
actor: byActor.url,
object
}
if (audience) return audiencify(activity, audience)
return activity
}
+40
ファイルの表示
@@ -0,0 +1,40 @@
import { Transaction } from 'sequelize'
import { ActivityAudience, ActivityDislike } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models/index.js'
import { audiencify, getAudience } from '../audience.js'
import { getVideoDislikeActivityPubUrlByLocalActor } from '../url.js'
import { sendVideoActivityToOrigin } from './shared/send-utils.js'
function sendDislike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
logger.info('Creating job to dislike %s.', video.url)
const activityBuilder = (audience: ActivityAudience) => {
const url = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
return buildDislikeActivity(url, byActor, video, audience)
}
return sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
}
function buildDislikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityDislike {
if (!audience) audience = getAudience(byActor)
return audiencify(
{
id: url,
type: 'Dislike' as 'Dislike',
actor: byActor.url,
object: video.url
},
audience
)
}
// ---------------------------------------------------------------------------
export {
sendDislike,
buildDislikeActivity
}
+42
ファイルの表示
@@ -0,0 +1,42 @@
import { Transaction } from 'sequelize'
import { ActivityAudience, ActivityFlag } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { MAbuseAP, MAccountLight, MActor } from '../../../types/models/index.js'
import { audiencify, getAudience } from '../audience.js'
import { getLocalAbuseActivityPubUrl } from '../url.js'
import { unicastTo } from './shared/send-utils.js'
function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
if (!flaggedAccount.Actor.serverId) return // Local user
const url = getLocalAbuseActivityPubUrl(abuse)
logger.info('Creating job to send abuse %s.', url)
// Custom audience, we only send the abuse to the origin instance
const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
const flagActivity = buildFlagActivity(url, byActor, abuse, audience)
return t.afterCommit(() => {
return unicastTo({
data: flagActivity,
byActor,
toActorUrl: flaggedAccount.Actor.getSharedInbox(),
contextType: 'Flag'
})
})
}
function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag {
if (!audience) audience = getAudience(byActor)
const activity = { id: url, actor: byActor.url, ...abuse.toActivityPubObject() }
return audiencify(activity, audience)
}
// ---------------------------------------------------------------------------
export {
sendAbuse
}
+37
ファイルの表示
@@ -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
ファイルの表示
@@ -0,0 +1,40 @@
import { Transaction } from 'sequelize'
import { ActivityAudience, ActivityLike } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models/index.js'
import { audiencify, getAudience } from '../audience.js'
import { getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
import { sendVideoActivityToOrigin } from './shared/send-utils.js'
function sendLike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
logger.info('Creating job to like %s.', video.url)
const activityBuilder = (audience: ActivityAudience) => {
const url = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
return buildLikeActivity(url, byActor, video, audience)
}
return sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
}
function buildLikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityLike {
if (!audience) audience = getAudience(byActor)
return audiencify(
{
id: url,
type: 'Like' as 'Like',
actor: byActor.url,
object: video.url
},
audience
)
}
// ---------------------------------------------------------------------------
export {
sendLike,
buildLikeActivity
}
+39
ファイルの表示
@@ -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
ファイルの表示
@@ -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
}
}
+172
ファイルの表示
@@ -0,0 +1,172 @@
import { Transaction } from 'sequelize'
import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { VideoModel } from '../../../models/video/video.js'
import {
MActor,
MActorAudience,
MActorFollowActors,
MActorLight,
MVideo,
MVideoAccountLight,
MVideoRedundancyVideo,
MVideoShare
} from '../../../types/models/index.js'
import { audiencify, getAudience } from '../audience.js'
import { getUndoActivityPubUrl, getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from '../url.js'
import { buildAnnounceWithVideoAudience } from './send-announce.js'
import { buildCreateActivity } from './send-create.js'
import { buildDislikeActivity } from './send-dislike.js'
import { buildFollowActivity } from './send-follow.js'
import { buildLikeActivity } from './send-like.js'
import { broadcastToFollowers, sendVideoActivityToOrigin, sendVideoRelatedActivity, unicastTo } from './shared/send-utils.js'
function sendUndoFollow (actorFollow: MActorFollowActors, t: Transaction) {
const me = actorFollow.ActorFollower
const following = actorFollow.ActorFollowing
// Same server as ours
if (!following.serverId) return
logger.info('Creating job to send an unfollow request to %s.', following.url)
const undoUrl = getUndoActivityPubUrl(actorFollow.url)
const followActivity = buildFollowActivity(actorFollow.url, me, following)
const undoActivity = undoActivityData(undoUrl, me, followActivity)
t.afterCommit(() => {
return unicastTo({
data: undoActivity,
byActor: me,
toActorUrl: following.inboxUrl,
contextType: 'Follow'
})
})
}
// ---------------------------------------------------------------------------
async function sendUndoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
logger.info('Creating job to undo announce %s.', videoShare.url)
const undoUrl = getUndoActivityPubUrl(videoShare.url)
const { activity: announce, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction)
const undoActivity = undoActivityData(undoUrl, byActor, announce)
return broadcastToFollowers({
data: undoActivity,
byActor,
toFollowersOf: actorsInvolvedInVideo,
transaction,
actorsException: [ byActor ],
contextType: 'Announce'
})
}
async function sendUndoCacheFile (byActor: MActor, redundancyModel: MVideoRedundancyVideo, transaction: Transaction) {
logger.info('Creating job to undo cache file %s.', redundancyModel.url)
const associatedVideo = redundancyModel.getVideo()
if (!associatedVideo) {
logger.warn('Cannot send undo activity for redundancy %s: no video files associated.', redundancyModel.url)
return
}
const video = await VideoModel.loadFull(associatedVideo.id)
const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
return sendUndoVideoRelatedActivity({
byActor,
video,
url: redundancyModel.url,
activity: createActivity,
contextType: 'CacheFile',
transaction
})
}
// ---------------------------------------------------------------------------
async function sendUndoLike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
logger.info('Creating job to undo a like of video %s.', video.url)
const likeUrl = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
const likeActivity = buildLikeActivity(likeUrl, byActor, video)
return sendUndoVideoRateToOriginActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t })
}
async function sendUndoDislike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
logger.info('Creating job to undo a dislike of video %s.', video.url)
const dislikeUrl = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
return sendUndoVideoRateToOriginActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t })
}
// ---------------------------------------------------------------------------
export {
sendUndoFollow,
sendUndoLike,
sendUndoDislike,
sendUndoAnnounce,
sendUndoCacheFile
}
// ---------------------------------------------------------------------------
function undoActivityData <T extends ActivityUndoObject> (
url: string,
byActor: MActorAudience,
object: T,
audience?: ActivityAudience
): ActivityUndo<T> {
if (!audience) audience = getAudience(byActor)
return audiencify(
{
type: 'Undo' as 'Undo',
id: url,
actor: byActor.url,
object
},
audience
)
}
async function sendUndoVideoRelatedActivity (options: {
byActor: MActor
video: MVideoAccountLight
url: string
activity: ActivityUndoObject
contextType: ContextType
transaction: Transaction
}) {
const activityBuilder = (audience: ActivityAudience) => {
const undoUrl = getUndoActivityPubUrl(options.url)
return undoActivityData(undoUrl, options.byActor, options.activity, audience)
}
return sendVideoRelatedActivity(activityBuilder, options)
}
async function sendUndoVideoRateToOriginActivity (options: {
byActor: MActor
video: MVideoAccountLight
url: string
activity: ActivityLike | ActivityDislike
transaction: Transaction
}) {
const activityBuilder = (audience: ActivityAudience) => {
const undoUrl = getUndoActivityPubUrl(options.url)
return undoActivityData(undoUrl, options.byActor, options.activity, audience)
}
return sendVideoActivityToOrigin(activityBuilder, { ...options, contextType: 'Rate' })
}
+151
ファイルの表示
@@ -0,0 +1,151 @@
import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js'
import { Transaction } from 'sequelize'
import { logger } from '../../../helpers/logger.js'
import { AccountModel } from '../../../models/account/account.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { VideoModel } from '../../../models/video/video.js'
import {
MAccountDefault,
MActor,
MActorLight,
MChannelDefault,
MVideoAPLight,
MVideoPlaylistFull,
MVideoRedundancyVideo
} from '../../../types/models/index.js'
import { audiencify, getAudience } from '../audience.js'
import { getUpdateActivityPubUrl } from '../url.js'
import { canVideoBeFederated } from '../videos/federate.js'
import { getActorsInvolvedInVideo } from './shared/index.js'
import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils.js'
export async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
if (!canVideoBeFederated(videoArg)) return undefined
const video = await videoArg.lightAPToFullAP(transaction)
logger.info('Creating job to update video %s.', video.url)
const byActor = overriddenByActor || video.VideoChannel.Account.Actor
const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
const videoObject = await video.toActivityPubObject()
const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
const updateActivity = buildUpdateActivity(url, byActor, videoObject, audience)
const actorsInvolved = await getActorsInvolvedInVideo(video, transaction)
if (overriddenByActor) actorsInvolved.push(overriddenByActor)
return broadcastToFollowers({
data: updateActivity,
byActor,
toFollowersOf: actorsInvolved,
contextType: 'Video',
transaction
})
}
export async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) {
const byActor = accountOrChannel.Actor
logger.info('Creating job to update actor %s.', byActor.url)
const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
const accountOrChannelObject = await (accountOrChannel as any).toActivityPubObject() // FIXME: typescript bug?
const audience = getAudience(byActor)
const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience)
let actorsInvolved: MActor[]
if (accountOrChannel instanceof AccountModel) {
// Actors that shared my videos are involved too
actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction)
} else {
// Actors that shared videos of my channel are involved too
actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, transaction)
}
actorsInvolved.push(byActor)
return broadcastToFollowers({
data: updateActivity,
byActor,
toFollowersOf: actorsInvolved,
transaction,
contextType: 'Actor'
})
}
export async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) {
logger.info('Creating job to update cache file %s.', redundancyModel.url)
const associatedVideo = redundancyModel.getVideo()
if (!associatedVideo) {
logger.warn('Cannot send update activity for redundancy %s: no video files associated.', redundancyModel.url)
return
}
const video = await VideoModel.loadFull(associatedVideo.id)
const activityBuilder = (audience: ActivityAudience) => {
const redundancyObject = redundancyModel.toActivityPubObject()
const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
return buildUpdateActivity(url, byActor, redundancyObject, audience)
}
return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'CacheFile' })
}
export async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) {
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
const byActor = videoPlaylist.OwnerAccount.Actor
logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
const object = await videoPlaylist.toActivityPubObject(null, transaction)
const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC)
const updateActivity = buildUpdateActivity(url, byActor, object, audience)
const serverActor = await getServerActor()
const toFollowersOf = [ byActor, serverActor ]
if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
return broadcastToFollowers({
data: updateActivity,
byActor,
toFollowersOf,
transaction,
contextType: 'Playlist'
})
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function buildUpdateActivity (
url: string,
byActor: MActorLight,
object: ActivityUpdateObject,
audience?: ActivityAudience
): ActivityUpdate<ActivityUpdateObject> {
if (!audience) audience = getAudience(byActor)
return audiencify(
{
type: 'Update' as 'Update',
id: url,
actor: byActor.url,
object: audiencify(object, audience)
},
audience
)
}
+72
ファイルの表示
@@ -0,0 +1,72 @@
import { Transaction } from 'sequelize'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models/index.js'
import { ActivityAudience, ActivityView } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { audiencify, getAudience } from '../audience.js'
import { getLocalVideoViewActivityPubUrl } from '../url.js'
import { sendVideoRelatedActivity } from './shared/send-utils.js'
import { isUsingViewersFederationV2 } from '@peertube/peertube-node-utils'
async function sendView (options: {
byActor: MActorLight
video: MVideoImmutable
viewerIdentifier: string
viewersCount?: number
transaction?: Transaction
}) {
const { byActor, viewersCount, video, viewerIdentifier, transaction } = options
logger.info('Creating job to send %s of %s.', viewersCount !== undefined ? 'viewer' : 'view', video.url)
const activityBuilder = (audience: ActivityAudience) => {
const url = getLocalVideoViewActivityPubUrl(byActor, video, viewerIdentifier)
return buildViewActivity({ url, byActor, video, audience, viewersCount })
}
return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View', parallelizable: true })
}
// ---------------------------------------------------------------------------
export {
sendView
}
// ---------------------------------------------------------------------------
function buildViewActivity (options: {
url: string
byActor: MActorAudience
video: MVideoUrl
viewersCount?: number
audience?: ActivityAudience
}): ActivityView {
const { url, byActor, viewersCount, video, audience = getAudience(byActor) } = options
const base = {
id: url,
type: 'View' as 'View',
actor: byActor.url,
object: video.url
}
if (viewersCount === undefined) {
return audiencify(base, audience)
}
return audiencify({
...base,
expires: new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString(),
result: isUsingViewersFederationV2()
? {
interactionType: 'WatchAction',
type: 'InteractionCounter',
userInteractionCount: viewersCount
}
: undefined
}, audience)
}
+65
ファイルの表示
@@ -0,0 +1,65 @@
import { ActivityAudience } from '@peertube/peertube-models'
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { VideoShareModel } from '@server/models/video/video-share.js'
import { VideoModel } from '@server/models/video/video.js'
import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models/index.js'
import { Transaction } from 'sequelize'
export function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience {
return {
to: [ accountActor.url ],
cc: actorsInvolvedInVideo.map(a => a.followersUrl)
}
}
export function getVideoCommentAudience (
videoComment: MCommentOwnerVideo,
threadParentComments: MCommentOwner[],
actorsInvolvedInVideo: MActorFollowersUrl[],
isOrigin = false
): ActivityAudience {
const to = [ getAPPublicValue() ]
const cc: string[] = []
// Owner of the video we comment
if (isOrigin === false) {
cc.push(videoComment.Video.VideoChannel.Account.Actor.url)
}
// Followers of the poster
cc.push(videoComment.Account.Actor.followersUrl)
// Send to actors we reply to
for (const parentComment of threadParentComments) {
if (parentComment.isDeleted()) continue
cc.push(parentComment.Account.Actor.url)
}
return {
to,
cc: cc.concat(actorsInvolvedInVideo.map(a => a.followersUrl))
}
}
export function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience {
return {
to: [ getAPPublicValue() as string ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
cc: []
}
}
export async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) {
const actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t)
const alreadyLoadedActor = (video as VideoModel).VideoChannel?.Account?.Actor
const videoActor = alreadyLoadedActor?.url && alreadyLoadedActor?.followersUrl
? alreadyLoadedActor
: await ActorModel.loadAccountActorFollowerUrlByVideoId(video.id, t)
if (videoActor) actors.push(videoActor)
return actors
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './audience-utils.js'
export * from './send-utils.js'
+297
ファイルの表示
@@ -0,0 +1,297 @@
import { Transaction } from 'sequelize'
import { Activity, ActivityAudience, ActivitypubHttpBroadcastPayload, ContextType } from '@peertube/peertube-models'
import { ActorFollowHealthCache } from '@server/lib/actor-follow-health-cache.js'
import { getServerActor } from '@server/models/application/application.js'
import { afterCommitIfTransaction } from '../../../../helpers/database-utils.js'
import { logger } from '../../../../helpers/logger.js'
import { ActorFollowModel } from '../../../../models/actor/actor-follow.js'
import { ActorModel } from '../../../../models/actor/actor.js'
import {
MActor,
MActorId,
MActorLight,
MActorWithInboxes,
MVideoAccountLight,
MVideoId,
MVideoImmutable
} from '../../../../types/models/index.js'
import { JobQueue } from '../../../job-queue/index.js'
import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getOriginVideoAudience } from './audience-utils.js'
async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
byActor: MActorLight
video: MVideoImmutable | MVideoAccountLight
contextType: ContextType
parallelizable?: boolean
transaction?: Transaction
}) {
const { byActor, video, transaction, contextType, parallelizable } = options
// Send to origin
if (video.isOwned() === false) {
return sendVideoActivityToOrigin(activityBuilder, options)
}
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, transaction)
// Send to followers
const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
const activity = activityBuilder(audience)
const actorsException = [ byActor ]
return broadcastToFollowers({
data: activity,
byActor,
toFollowersOf: actorsInvolvedInVideo,
transaction,
actorsException,
parallelizable,
contextType
})
}
async function sendVideoActivityToOrigin (activityBuilder: (audience: ActivityAudience) => Activity, options: {
byActor: MActorLight
video: MVideoImmutable | MVideoAccountLight
contextType: ContextType
actorsInvolvedInVideo?: MActorLight[]
transaction?: Transaction
}) {
const { byActor, video, actorsInvolvedInVideo, transaction, contextType } = options
if (video.isOwned()) throw new Error('Cannot send activity to owned video origin ' + video.url)
let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor
if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction)
const audience = getOriginVideoAudience(accountActor, actorsInvolvedInVideo)
const activity = activityBuilder(audience)
return afterCommitIfTransaction(transaction, () => {
return unicastTo({
data: activity,
byActor,
toActorUrl: accountActor.getSharedInbox(),
contextType
})
})
}
// ---------------------------------------------------------------------------
async function forwardVideoRelatedActivity (
activity: Activity,
t: Transaction,
followersException: MActorWithInboxes[],
video: MVideoId
) {
// Mastodon does not add our announces in audience, so we forward to them manually
const additionalActors = await getActorsInvolvedInVideo(video, t)
const additionalFollowerUrls = additionalActors.map(a => a.followersUrl)
return forwardActivity(activity, t, followersException, additionalFollowerUrls)
}
async function forwardActivity (
activity: Activity,
t: Transaction,
followersException: MActorWithInboxes[] = [],
additionalFollowerUrls: string[] = []
) {
logger.info('Forwarding activity %s.', activity.id)
const to = activity.to || []
const cc = activity.cc || []
const followersUrls = additionalFollowerUrls
for (const dest of to.concat(cc)) {
if (dest.endsWith('/followers')) {
followersUrls.push(dest)
}
}
const toActorFollowers = await ActorModel.listByFollowersUrls(followersUrls, t)
const uris = await computeFollowerUris(toActorFollowers, followersException, t)
if (uris.length === 0) {
logger.info('0 followers for %s, no forwarding.', toActorFollowers.map(a => a.id).join(', '))
return undefined
}
logger.debug('Creating forwarding job.', { uris })
const payload: ActivitypubHttpBroadcastPayload = {
uris,
body: activity,
contextType: null
}
return afterCommitIfTransaction(t, () => JobQueue.Instance.createJobAsync({ type: 'activitypub-http-broadcast', payload }))
}
// ---------------------------------------------------------------------------
async function broadcastToFollowers (options: {
data: any
byActor: MActorId
toFollowersOf: MActorId[]
transaction: Transaction
contextType: ContextType
parallelizable?: boolean
actorsException?: MActorWithInboxes[]
}) {
const { data, byActor, toFollowersOf, transaction, contextType, actorsException = [], parallelizable } = options
const uris = await computeFollowerUris(toFollowersOf, actorsException, transaction)
return afterCommitIfTransaction(transaction, () => {
return broadcastTo({
uris,
data,
byActor,
parallelizable,
contextType
})
})
}
async function broadcastToActors (options: {
data: any
byActor: MActorId
toActors: MActor[]
transaction: Transaction
contextType: ContextType
actorsException?: MActorWithInboxes[]
}) {
const { data, byActor, toActors, transaction, contextType, actorsException = [] } = options
const uris = await computeUris(toActors, actorsException)
return afterCommitIfTransaction(transaction, () => {
return broadcastTo({
uris,
data,
byActor,
contextType
})
})
}
function broadcastTo (options: {
uris: string[]
data: any
byActor: MActorId
contextType: ContextType
parallelizable?: boolean // default to false
}) {
const { uris, data, byActor, contextType, parallelizable } = options
if (uris.length === 0) return undefined
const broadcastUris: string[] = []
const unicastUris: string[] = []
// Bad URIs could be slow to respond, prefer to process them in a dedicated queue
for (const uri of uris) {
if (ActorFollowHealthCache.Instance.isBadInbox(uri)) {
unicastUris.push(uri)
} else {
broadcastUris.push(uri)
}
}
logger.debug('Creating broadcast job.', { broadcastUris, unicastUris })
if (broadcastUris.length !== 0) {
const payload = {
uris: broadcastUris,
signatureActorId: byActor.id,
body: data,
contextType
}
JobQueue.Instance.createJobAsync({
type: parallelizable
? 'activitypub-http-broadcast-parallel'
: 'activitypub-http-broadcast',
payload
})
}
for (const unicastUri of unicastUris) {
const payload = {
uri: unicastUri,
signatureActorId: byActor.id,
body: data,
contextType
}
JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
}
}
function unicastTo (options: {
data: any
byActor: MActorId
toActorUrl: string
contextType: ContextType
}) {
const { data, byActor, toActorUrl, contextType } = options
logger.debug('Creating unicast job.', { uri: toActorUrl })
const payload = {
uri: toActorUrl,
signatureActorId: byActor.id,
body: data,
contextType
}
JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
}
// ---------------------------------------------------------------------------
export {
broadcastToFollowers,
unicastTo,
broadcastToActors,
sendVideoActivityToOrigin,
forwardVideoRelatedActivity,
sendVideoRelatedActivity
}
// ---------------------------------------------------------------------------
async function computeFollowerUris (toFollowersOf: MActorId[], actorsException: MActorWithInboxes[], t: Transaction) {
const toActorFollowerIds = toFollowersOf.map(a => a.id)
const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t)
const sharedInboxesException = await buildSharedInboxesException(actorsException)
return result.data.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
}
async function computeUris (toActors: MActor[], actorsException: MActorWithInboxes[] = []) {
const serverActor = await getServerActor()
const targetUrls = toActors
.filter(a => a.id !== serverActor.id) // Don't send to ourselves
.map(a => a.getSharedInbox())
const toActorSharedInboxesSet = new Set(targetUrls)
const sharedInboxesException = await buildSharedInboxesException(actorsException)
return Array.from(toActorSharedInboxesSet)
.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
}
async function buildSharedInboxesException (actorsException: MActorWithInboxes[]) {
const serverActor = await getServerActor()
return actorsException
.map(f => f.getSharedInbox())
.concat([ serverActor.sharedInboxUrl ])
}
+107
ファイルの表示
@@ -0,0 +1,107 @@
import { getServerActor } from '@server/models/application/application.js'
import Bluebird from 'bluebird'
import { Transaction } from 'sequelize'
import { logger, loggerTagsFactory } from '../../helpers/logger.js'
import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
import { VideoShareModel } from '../../models/video/video-share.js'
import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video/index.js'
import { fetchAP, getAPId } from './activity.js'
import { getOrCreateAPActor } from './actors/index.js'
import { sendUndoAnnounce, sendVideoAnnounce } from './send/index.js'
import { checkUrlsSameHost, getLocalVideoAnnounceActivityPubUrl } from './url.js'
const lTags = loggerTagsFactory('share')
export async function changeVideoChannelShare (
video: MVideoAccountLight,
oldVideoChannel: MChannelActorLight,
t: Transaction
) {
logger.info(
'Updating video channel of video %s: %s -> %s.', video.uuid, oldVideoChannel.name, video.VideoChannel.name,
lTags(video.uuid)
)
await undoShareByVideoChannel(video, oldVideoChannel, t)
await shareByVideoChannel(video, t)
}
export async function addVideoShares (shareUrls: string[], video: MVideoId) {
await Bluebird.map(shareUrls, async shareUrl => {
try {
await addVideoShare(shareUrl, video)
} catch (err) {
logger.warn('Cannot add share %s.', shareUrl, { err })
}
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
}
export async function shareByServer (video: MVideo, t: Transaction) {
const serverActor = await getServerActor()
const serverShareUrl = getLocalVideoAnnounceActivityPubUrl(serverActor, video)
const [ serverShare ] = await VideoShareModel.findOrCreate({
defaults: {
actorId: serverActor.id,
videoId: video.id,
url: serverShareUrl
},
where: {
url: serverShareUrl
},
transaction: t
})
return sendVideoAnnounce(serverActor, serverShare, video, t)
}
export async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
const videoChannelShareUrl = getLocalVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
defaults: {
actorId: video.VideoChannel.actorId,
videoId: video.id,
url: videoChannelShareUrl
},
where: {
url: videoChannelShareUrl
},
transaction: t
})
return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function addVideoShare (shareUrl: string, video: MVideoId) {
const { body } = await fetchAP<any>(shareUrl)
if (!body?.actor) throw new Error('Body or body actor is invalid')
const actorUrl = getAPId(body.actor)
if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
}
const actor = await getOrCreateAPActor(actorUrl)
const entry = {
actorId: actor.id,
videoId: video.id,
url: shareUrl
}
await VideoShareModel.upsert(entry)
}
async function undoShareByVideoChannel (video: MVideo, oldVideoChannel: MChannelActorLight, t: Transaction) {
// Load old share
const oldShare = await VideoShareModel.load(oldVideoChannel.actorId, video.id, t)
if (!oldShare) return new Error('Cannot find old video channel share ' + oldVideoChannel.actorId + ' for video ' + video.id)
await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t)
await oldShare.destroy({ transaction: t })
}
+151
ファイルの表示
@@ -0,0 +1,151 @@
import { REMOTE_SCHEME, WEBSERVER } from '../../initializers/constants.js'
import {
MAbuseFull,
MAbuseId,
MActor,
MActorFollow,
MActorId,
MActorUrl,
MCommentId, MLocalVideoViewer,
MVideoId,
MVideoPlaylistElement,
MVideoUUID,
MVideoUrl,
MVideoWithHost
} from '../../types/models/index.js'
import { MVideoFileVideoUUID } from '../../types/models/video/video-file.js'
import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist.js'
import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist.js'
export function getLocalVideoActivityPubUrl (video: MVideoUUID) {
return WEBSERVER.URL + '/videos/watch/' + video.uuid
}
export function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) {
return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
}
export function getLocalVideoPlaylistElementActivityPubUrl (playlist: MVideoPlaylistUUID, element: MVideoPlaylistElement) {
return WEBSERVER.URL + '/video-playlists/' + playlist.uuid + '/videos/' + element.id
}
export function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) {
const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
return `${WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
}
export function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) {
return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}`
}
export function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) {
return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
}
export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
return WEBSERVER.URL + '/video-channels/' + videoChannelName
}
export function getLocalAccountActivityPubUrl (accountName: string) {
return WEBSERVER.URL + '/accounts/' + accountName
}
export function getLocalAbuseActivityPubUrl (abuse: MAbuseId) {
return WEBSERVER.URL + '/admin/abuses/' + abuse.id
}
export function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) {
return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier
}
export function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
}
export function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
return byActor.url + '/likes/' + video.id
}
export function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
return byActor.url + '/dislikes/' + video.id
}
export function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) {
return video.url + '/announces'
}
export function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
return video.url + '/comments'
}
export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
return video.url + '/chapters'
}
export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
return video.url + '/likes'
}
export function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) {
return video.url + '/dislikes'
}
export function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) {
return follower.url + '/follows/' + following.id
}
export function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) {
return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id
}
export function getLocalActorFollowRejectActivityPubUrl () {
return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString()
}
export function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) {
return video.url + '/announces/' + byActor.id
}
export function getDeleteActivityPubUrl (originalUrl: string) {
return originalUrl + '/delete'
}
export function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) {
return originalUrl + '/updates/' + updatedAt
}
export function getUndoActivityPubUrl (originalUrl: string) {
return originalUrl + '/undo'
}
export function getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: MCommentId) {
return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply'
}
// ---------------------------------------------------------------------------
export function getAbuseTargetUrl (abuse: MAbuseFull) {
return abuse.VideoAbuse?.Video?.url ||
abuse.VideoCommentAbuse?.VideoComment?.url ||
abuse.FlaggedAccount.Actor.url
}
// ---------------------------------------------------------------------------
export function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) {
if (!scheme) scheme = REMOTE_SCHEME.HTTP
const host = video.VideoChannel.Actor.Server.host
return scheme + '://' + host + path
}
// ---------------------------------------------------------------------------
export function checkUrlsSameHost (url1: string, url2: string) {
const idHost = new URL(url1).host
const actorHost = new URL(url2).host
return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
}
+16
ファイルの表示
@@ -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
}
+250
ファイルの表示
@@ -0,0 +1,250 @@
import { VideoCommentPolicy } from '@peertube/peertube-models'
import Bluebird from 'bluebird'
import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments.js'
import { logger } from '../../helpers/logger.js'
import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
import { VideoCommentModel } from '../../models/video/video-comment.js'
import {
MComment,
MCommentOwner,
MCommentOwnerVideo,
MVideoAccountLight,
MVideoAccountLightBlacklistAllFiles
} from '../../types/models/video/index.js'
import { AutomaticTagger } from '../automatic-tags/automatic-tagger.js'
import { setAndSaveCommentAutomaticTags } from '../automatic-tags/automatic-tags.js'
import { isRemoteVideoCommentAccepted } from '../moderation.js'
import { Hooks } from '../plugins/hooks.js'
import { shouldCommentBeHeldForReview } from '../video-comment.js'
import { fetchAP } from './activity.js'
import { getOrCreateAPActor } from './actors/index.js'
import { checkUrlsSameHost } from './url.js'
import { canVideoBeFederated, getOrCreateAPVideo } from './videos/index.js'
type ResolveThreadParams = {
url: string
comments?: MCommentOwner[]
isVideo?: boolean
commentCreated?: boolean
}
type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }>
export async function addVideoComments (commentUrls: string[]) {
return Bluebird.map(commentUrls, async commentUrl => {
try {
await resolveThread({ url: commentUrl, isVideo: false })
} catch (err) {
logger.warn('Cannot resolve thread %s.', commentUrl, { err })
}
}, { concurrency: CRAWL_REQUEST_CONCURRENCY })
}
export async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
const { url, isVideo } = params
if (params.commentCreated === undefined) params.commentCreated = false
if (params.comments === undefined) params.comments = []
// If it is not a video, or if we don't know if it's a video, try to get the thread from DB
if (isVideo === false || isVideo === undefined) {
const result = await resolveCommentFromDB(params)
if (result) return result
}
try {
// If it is a video, or if we don't know if it's a video
if (isVideo === true || isVideo === undefined) {
// Keep await so we catch the exception
return await tryToResolveThreadFromVideo(params)
}
} catch (err) {
logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err })
}
return resolveRemoteParentComment(params)
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function resolveCommentFromDB (params: ResolveThreadParams) {
const { url, comments, commentCreated } = params
const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url)
if (!commentFromDatabase) return undefined
let parentComments = comments.concat([ commentFromDatabase ])
// Speed up things and resolve directly the thread
if (commentFromDatabase.InReplyToVideoComment) {
const data = await VideoCommentModel.listThreadParentComments({ comment: commentFromDatabase, order: 'DESC' })
parentComments = parentComments.concat(data)
}
return resolveThread({
url: commentFromDatabase.Video.url,
comments: parentComments,
isVideo: true,
commentCreated
})
}
// ---------------------------------------------------------------------------
async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
const { url, comments, commentCreated } = params
// Maybe it's a reply to a video?
// If yes, it's done: we resolved all the thread
const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false }
const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam })
if (video.isOwned() && !canVideoBeFederated(video)) {
throw new Error('Cannot resolve thread of video that is not compatible with federation')
}
if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
return undefined
}
let resultComment: MCommentOwnerVideo
if (comments.length !== 0) {
const firstReply = comments[comments.length - 1] as MCommentOwnerVideo
firstReply.inReplyToCommentId = null
firstReply.originCommentId = null
firstReply.videoId = video.id
firstReply.changed('updatedAt', true)
firstReply.Video = video
if (await isRemoteCommentAccepted(firstReply) !== true) {
return undefined
}
const firstReplyAutomaticTags = await getAutomaticTagsAndAssignReview(firstReply, video)
comments[comments.length - 1] = await firstReply.save()
await setAndSaveCommentAutomaticTags({ comment: firstReply, automaticTags: firstReplyAutomaticTags })
for (let i = comments.length - 2; i >= 0; i--) {
const comment = comments[i] as MCommentOwnerVideo
comment.originCommentId = firstReply.id
comment.inReplyToCommentId = comments[i + 1].id
comment.videoId = video.id
comment.changed('updatedAt', true)
comment.Video = video
if (await isRemoteCommentAccepted(comment) !== true) {
return undefined
}
const automaticTags = await getAutomaticTagsAndAssignReview(comment, video)
comments[i] = await comment.save()
await setAndSaveCommentAutomaticTags({ comment, automaticTags })
}
resultComment = comments[0] as MCommentOwnerVideo
}
return { video, comment: resultComment, commentCreated }
}
async function getAutomaticTagsAndAssignReview (comment: MComment, video: MVideoAccountLight) {
// Remote comment already exists in database or remote video -> we don't need to rebuild automatic tags
if (comment.id) return []
const ownerAccount = video.VideoChannel.Account
const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ ownerAccount, text: comment.text })
// Third parties rely on origin, so if origin has the comment it's not held for review
if (video.isOwned() || comment.isOwned()) {
comment.heldForReview = await shouldCommentBeHeldForReview({ user: null, video, automaticTags })
} else {
comment.heldForReview = false
}
return automaticTags
}
// ---------------------------------------------------------------------------
async function resolveRemoteParentComment (params: ResolveThreadParams) {
const { url, comments } = params
if (comments.length > ACTIVITY_PUB.MAX_RECURSION_COMMENTS) {
throw new Error('Recursion limit reached when resolving a thread')
}
const { body } = await fetchAP<any>(url)
if (sanitizeAndCheckVideoCommentObject(body) === false) {
throw new Error(`Remote video comment JSON ${url} is not valid:` + JSON.stringify(body))
}
const actorUrl = body.attributedTo
if (!actorUrl && body.type !== 'Tombstone') throw new Error('Miss attributed to in comment')
if (actorUrl && checkUrlsSameHost(url, actorUrl) !== true) {
throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`)
}
if (checkUrlsSameHost(body.id, url) !== true) {
throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`)
}
const actor = actorUrl
? await getOrCreateAPActor(actorUrl, 'all')
: null
const comment = new VideoCommentModel({
url: body.id,
text: body.content ? body.content : '',
videoId: null,
accountId: actor ? actor.Account.id : null,
inReplyToCommentId: null,
originCommentId: null,
createdAt: new Date(body.published),
updatedAt: new Date(body.updated),
replyApproval: body.replyApproval,
deletedAt: body.deleted
? new Date(body.deleted)
: null
}) as MCommentOwner
comment.Account = actor ? actor.Account : null
logger.debug('Created remote comment %s', comment.url, { comment })
return resolveThread({
url: body.inReplyTo,
comments: comments.concat([ comment ]),
commentCreated: true
})
}
async function isRemoteCommentAccepted (comment: MComment) {
// Already created
if (comment.id) return true
const acceptParameters = {
comment
}
const acceptedResult = await Hooks.wrapFun(
isRemoteVideoCommentAccepted,
acceptParameters,
'filter:activity-pub.remote-video-comment.create.accept.result'
)
if (!acceptedResult || acceptedResult.accepted !== true) {
logger.info('Refused to create a remote comment.', { acceptedResult, acceptParameters })
return false
}
return true
}
+59
ファイルの表示
@@ -0,0 +1,59 @@
import { Transaction } from 'sequelize'
import { VideoRateType } from '@peertube/peertube-models'
import { MAccountActor, MActorUrl, MVideoAccountLight, MVideoFullLight, MVideoId } from '../../types/models/index.js'
import { sendLike, sendUndoDislike, sendUndoLike } from './send/index.js'
import { sendDislike } from './send/send-dislike.js'
import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url.js'
import { federateVideoIfNeeded } from './videos/index.js'
async function sendVideoRateChange (
account: MAccountActor,
video: MVideoFullLight,
likes: number,
dislikes: number,
t: Transaction
) {
if (video.isOwned()) return federateVideoIfNeeded(video, false, t)
return sendVideoRateChangeToOrigin(account, video, likes, dislikes, t)
}
function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVideoId) {
return rateType === 'like'
? getVideoLikeActivityPubUrlByLocalActor(actor, video)
: getVideoDislikeActivityPubUrlByLocalActor(actor, video)
}
// ---------------------------------------------------------------------------
export {
getLocalRateUrl,
sendVideoRateChange
}
// ---------------------------------------------------------------------------
async function sendVideoRateChangeToOrigin (
account: MAccountActor,
video: MVideoAccountLight,
likes: number,
dislikes: number,
t: Transaction
) {
// Local video, we don't need to send like
if (video.isOwned()) return
const actor = account.Actor
// Keep the order: first we undo and then we create
// Undo Like
if (likes < 0) await sendUndoLike(actor, video, t)
// Undo Dislike
if (dislikes < 0) await sendUndoDislike(actor, video, t)
// Like
if (likes > 0) await sendLike(actor, video, t)
// Dislike
if (dislikes > 0) await sendDislike(actor, video, t)
}
+53
ファイルの表示
@@ -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
ファイルの表示
@@ -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
ファイルの表示
@@ -0,0 +1,4 @@
export * from './federate.js'
export * from './get.js'
export * from './refresh.js'
export * from './updater.js'
+70
ファイルの表示
@@ -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
}
+236
ファイルの表示
@@ -0,0 +1,236 @@
import {
ActivityTagObject,
ThumbnailType,
VideoChaptersObject,
VideoObject,
VideoStreamingPlaylistType_Type
} from '@peertube/peertube-models'
import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
import { replaceChapters } from '@server/lib/video-chapters.js'
import { setVideoTags } from '@server/lib/video.js'
import { StoryboardModel } from '@server/models/video/storyboard.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
import {
MStreamingPlaylistFiles,
MStreamingPlaylistFilesVideo,
MVideo,
MVideoCaption,
MVideoFile,
MVideoFullLight,
MVideoThumbnail
} from '@server/types/models/index.js'
import { CreationAttributes, Transaction } from 'sequelize'
import { fetchAP } from '../../activity.js'
import { findOwner, getOrCreateAPActor } from '../../actors/index.js'
import {
getCaptionAttributesFromObject,
getFileAttributesFromUrl,
getLiveAttributesFromObject,
getPreviewFromIcons,
getStoryboardAttributeFromObject,
getStreamingPlaylistAttributesFromObject,
getTagsFromObject,
getThumbnailFromIcons
} from './object-to-model-attributes.js'
import { getTrackerUrls, setVideoTrackers } from './trackers.js'
export abstract class APVideoAbstractBuilder {
protected abstract videoObject: VideoObject
protected abstract lTags: LoggerTagsFn
protected async getOrCreateVideoChannelFromVideoObject () {
const channel = await findOwner(this.videoObject.id, this.videoObject.attributedTo, 'Group')
if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.url)
return getOrCreateAPActor(channel.id, 'all')
}
protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
const miniatureIcon = getThumbnailFromIcons(this.videoObject)
if (!miniatureIcon) {
logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
return undefined
}
const miniatureModel = updateRemoteVideoThumbnail({
fileUrl: miniatureIcon.url,
video,
type: ThumbnailType.MINIATURE,
size: miniatureIcon,
onDisk: false // Lazy download remote thumbnails
})
await video.addAndSaveThumbnail(miniatureModel, t)
}
protected async setPreview (video: MVideoFullLight, t?: Transaction) {
const previewIcon = getPreviewFromIcons(this.videoObject)
if (!previewIcon) return
const previewModel = updateRemoteVideoThumbnail({
fileUrl: previewIcon.url,
video,
type: ThumbnailType.PREVIEW,
size: previewIcon,
onDisk: false // Lazy download remote previews
})
await video.addAndSaveThumbnail(previewModel, t)
}
protected async setTags (video: MVideoFullLight, t: Transaction) {
const tags = getTagsFromObject(this.videoObject)
await setVideoTags({ video, tags, transaction: t })
}
protected async setTrackers (video: MVideoFullLight, t: Transaction) {
const trackers = getTrackerUrls(this.videoObject, video)
await setVideoTrackers({ video, trackers, transaction: t })
}
protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) {
const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject)
.map(a => new VideoCaptionModel(a) as MVideoCaption)
for (const existingCaption of existingCaptions) {
// Only keep captions that do not already exist
const filtered = captionsToCreate.filter(c => !c.isEqual(existingCaption))
// This caption already exists, we don't need to destroy and create it
if (filtered.length !== captionsToCreate.length) {
captionsToCreate = filtered
continue
}
// Destroy this caption that does not exist anymore
await existingCaption.destroy({ transaction: t })
}
for (const captionToCreate of captionsToCreate) {
await captionToCreate.save({ transaction: t })
}
}
protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) {
const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t)
if (existingStoryboard) await existingStoryboard.destroy({ transaction: t })
const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
if (!storyboardAttributes) return
return StoryboardModel.create(storyboardAttributes, { transaction: t })
}
protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
const attributes = getLiveAttributesFromObject(video, this.videoObject)
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
video.VideoLive = videoLive
}
protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
// Remove video files that do not exist anymore
await deleteAllModels(filterNonExistingModels(video.VideoFiles || [], newVideoFiles), t)
// Update or add other one
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
video.VideoFiles = await Promise.all(upsertTasks)
}
protected async updateChaptersOutsideTransaction (video: MVideoFullLight) {
if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
if (!isVideoChaptersObjectValid(body)) {
logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
return
}
logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
await replaceChapters({ chapters, transaction: t, video })
})
})
}
protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
// Remove video playlists that do not exist anymore
await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t)
const oldPlaylists = video.VideoStreamingPlaylists
video.VideoStreamingPlaylists = []
for (const playlistAttributes of streamingPlaylistAttributes) {
const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
streamingPlaylistModel.Video = video
await this.setStreamingPlaylistFiles(oldPlaylists, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
video.VideoStreamingPlaylists.push(streamingPlaylistModel)
}
}
private async insertOrReplaceStreamingPlaylist (attributes: CreationAttributes<VideoStreamingPlaylistModel>, t: Transaction) {
const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
return streamingPlaylist as MStreamingPlaylistFilesVideo
}
private getStreamingPlaylistFiles (oldPlaylists: MStreamingPlaylistFiles[], type: VideoStreamingPlaylistType_Type) {
const playlist = oldPlaylists.find(s => s.type === type)
if (!playlist) return []
return playlist.VideoFiles
}
private async setStreamingPlaylistFiles (
oldPlaylists: MStreamingPlaylistFiles[],
playlistModel: MStreamingPlaylistFilesVideo,
tagObjects: ActivityTagObject[],
t: Transaction
) {
const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type)
const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
await deleteAllModels(filterNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles), t)
// Update or add other one
const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
playlistModel.VideoFiles = await Promise.all(upsertTasks)
}
protected async setAutomaticTags (options: {
video: MVideo
oldVideo?: Pick<MVideo, 'name' | 'description'>
transaction: Transaction
}) {
const { video, transaction, oldVideo } = options
if (oldVideo && video.name === oldVideo.name && video.description === oldVideo.description) return
const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction })
await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction })
}
}
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { VideoObject } from '@peertube/peertube-models'
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
import { APVideoAbstractBuilder } from './abstract-builder.js'
import { getVideoAttributesFromObject } from './object-to-model-attributes.js'
export class APVideoCreator extends APVideoAbstractBuilder {
protected lTags: LoggerTagsFn
constructor (protected readonly videoObject: VideoObject) {
super()
this.lTags = loggerTagsFactory('ap', 'video', 'create', this.videoObject.uuid, this.videoObject.id)
}
async create () {
logger.debug('Adding remote video %s.', this.videoObject.id, this.lTags())
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
const channel = channelActor.VideoChannel
channel.Actor = channelActor
const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail
const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
videoCreated.VideoChannel = channel
await this.setThumbnail(videoCreated, t)
await this.setPreview(videoCreated, t)
await this.setWebVideoFiles(videoCreated, t)
await this.setStreamingPlaylists(videoCreated, t)
await this.setTags(videoCreated, t)
await this.setTrackers(videoCreated, t)
await this.insertOrReplaceCaptions(videoCreated, t)
await this.insertOrReplaceLive(videoCreated, t)
await this.insertOrReplaceStoryboard(videoCreated, t)
await this.setAutomaticTags({ video: videoCreated, transaction: t })
// We added a video in this channel, set it as updated
await channel.setAsUpdated(t)
const autoBlacklisted = await autoBlacklistVideoIfNeeded({
video: videoCreated,
user: undefined,
isRemote: true,
isNew: true,
isNewFile: true,
transaction: t
})
logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
return { autoBlacklisted, videoCreated }
})
await this.updateChaptersOutsideTransaction(videoCreated)
return { autoBlacklisted, videoCreated }
}
}
+6
ファイルの表示
@@ -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'
+299
ファイルの表示
@@ -0,0 +1,299 @@
import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
import {
ActivityHashTagObject,
ActivityMagnetUrlObject,
ActivityPlaylistSegmentHashesObject,
ActivityPlaylistUrlObject,
ActivityTagObject,
ActivityUrlObject,
ActivityVideoUrlObject,
VideoObject,
VideoPrivacy,
VideoStreamingPlaylistType
} from '@peertube/peertube-models'
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js'
import { isArray } from '@server/helpers/custom-validators/misc.js'
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js'
import { generateImageFilename } from '@server/helpers/image-utils.js'
import { getExtFromMimetype } from '@server/helpers/video.js'
import { MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants.js'
import { generateTorrentFileName } from '@server/lib/paths.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
import { FilteredModelAttributes } from '@server/types/index.js'
import { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId, isStreamingPlaylist } from '@server/types/models/index.js'
import { decode as magnetUriDecode } from 'magnet-uri'
import { basename, extname } from 'path'
import { getDurationFromActivityStream } from '../../activity.js'
export function getThumbnailFromIcons (videoObject: VideoObject) {
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minRemoteWidth)
// Fallback if there are not valid icons
if (validIcons.length === 0) validIcons = videoObject.icon
return minBy(validIcons, 'width')
}
export function getPreviewFromIcons (videoObject: VideoObject) {
const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minRemoteWidth)
return maxBy(validIcons, 'width')
}
export function getTagsFromObject (videoObject: VideoObject) {
return videoObject.tag
.filter(isAPHashTagObject)
.map(t => t.name)
}
export function getFileAttributesFromUrl (
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
urls: (ActivityTagObject | ActivityUrlObject)[]
) {
const fileUrls = urls.filter(u => isAPVideoUrlObject(u))
if (fileUrls.length === 0) return []
const attributes: FilteredModelAttributes<VideoFileModel>[] = []
for (const fileUrl of fileUrls) {
// Fetch associated metadata url, if any
const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
.find(u => {
return u.height === fileUrl.height &&
u.fps === fileUrl.fps &&
u.rel.includes(fileUrl.mediaType)
})
const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
const resolution = fileUrl.height
const videoId = isStreamingPlaylist(videoOrPlaylist) ? null : videoOrPlaylist.id
const videoStreamingPlaylistId = isStreamingPlaylist(videoOrPlaylist)
? videoOrPlaylist.id
: null
const { torrentFilename, infoHash, torrentUrl } = getTorrentRelatedInfo({ videoOrPlaylist, urls, fileUrl })
const attribute = {
extname,
resolution,
size: fileUrl.size,
fps: fileUrl.fps || -1,
metadataUrl: metadata?.href,
width: fileUrl.width,
height: fileUrl.height,
// Use the name of the remote file because we don't proxify video file requests
filename: basename(fileUrl.href),
fileUrl: fileUrl.href,
infoHash,
torrentFilename,
torrentUrl,
// This is a video file owned by a video or by a streaming playlist
videoId,
videoStreamingPlaylistId
}
attributes.push(attribute)
}
return attributes
}
export function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u))
if (playlistUrls.length === 0) return []
const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
for (const playlistUrlObject of playlistUrls) {
const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
const files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u))
const attribute = {
type: VideoStreamingPlaylistType.HLS,
playlistFilename: basename(playlistUrlObject.href),
playlistUrl: playlistUrlObject.href,
segmentsSha256Filename: segmentsSha256UrlObject
? basename(segmentsSha256UrlObject.href)
: null,
segmentsSha256Url: segmentsSha256UrlObject?.href ?? null,
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
videoId: video.id,
tagAPObject: playlistUrlObject.tag
}
attributes.push(attribute)
}
return attributes
}
export function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
return {
saveReplay: videoObject.liveSaveReplay,
permanentLive: videoObject.permanentLive,
latencyMode: videoObject.latencyMode,
videoId: video.id
}
}
export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
return videoObject.subtitleLanguage.map(c => ({
videoId: video.id,
filename: VideoCaptionModel.generateCaptionName(c.identifier),
language: c.identifier,
automaticallyGenerated: c.automaticallyGenerated === true,
fileUrl: c.url
}))
}
export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
if (!isArray(videoObject.preview)) return undefined
const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard'))
if (!storyboard) return undefined
const url = arrayify(storyboard.url).find(u => u.mediaType === 'image/jpeg')
return {
filename: generateImageFilename(extname(url.href)),
totalHeight: url.height,
totalWidth: url.width,
spriteHeight: url.tileHeight,
spriteWidth: url.tileWidth,
spriteDuration: getDurationFromActivityStream(url.tileDuration),
fileUrl: url.href,
videoId: video.id
}
}
export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
const privacy = hasAPPublic(to)
? VideoPrivacy.PUBLIC
: VideoPrivacy.UNLISTED
const language = videoObject.language?.identifier
const category = videoObject.category
? parseInt(videoObject.category.identifier, 10)
: undefined
const licence = videoObject.licence
? parseInt(videoObject.licence.identifier, 10)
: undefined
const description = videoObject.content || null
const support = videoObject.support || null
return {
name: videoObject.name,
uuid: videoObject.uuid,
url: videoObject.id,
category,
licence,
language,
description,
support,
nsfw: videoObject.sensitive,
commentsPolicy: videoObject.commentsPolicy,
downloadEnabled: videoObject.downloadEnabled,
waitTranscoding: videoObject.waitTranscoding,
isLive: videoObject.isLiveBroadcast,
state: videoObject.state,
aspectRatio: videoObject.aspectRatio,
channelId: videoChannel.id,
duration: getDurationFromActivityStream(videoObject.duration),
createdAt: new Date(videoObject.published),
publishedAt: new Date(videoObject.published),
originallyPublishedAt: videoObject.originallyPublishedAt
? new Date(videoObject.originallyPublishedAt)
: null,
inputFileUpdatedAt: videoObject.uploadDate
? new Date(videoObject.uploadDate)
: null,
updatedAt: new Date(videoObject.updated),
views: videoObject.views,
remote: true,
privacy
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
return !!MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType]
}
function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
return url && url.mediaType === 'application/x-mpegURL'
}
function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
}
function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
}
function isAPHashTagObject (url: any): url is ActivityHashTagObject {
return url && url.type === 'Hashtag'
}
function getTorrentRelatedInfo (options: {
videoOrPlaylist: MVideo | MStreamingPlaylistVideo
urls: (ActivityTagObject | ActivityUrlObject)[]
fileUrl: ActivityVideoUrlObject
}) {
const { urls, fileUrl, videoOrPlaylist } = options
// Fetch associated magnet uri
const magnet = urls.filter(isAPMagnetUrlObject)
.find(u => u.height === fileUrl.height)
if (!magnet) {
return {
torrentUrl: null,
torrentFilename: null,
infoHash: null
}
}
const magnetParsed = magnetUriDecode(magnet.href)
if (magnetParsed && isVideoFileInfoHashValid(magnetParsed.infoHash) === false) {
throw new Error('Info hash is not valid in magnet URI ' + magnet.href)
}
const torrentUrl = Array.isArray(magnetParsed.xs)
? magnetParsed.xs[0]
: magnetParsed.xs
return {
torrentUrl,
// Use our own torrent name since we proxify torrent requests
torrentFilename: generateTorrentFileName(videoOrPlaylist, fileUrl.height),
infoHash: magnetParsed.infoHash
}
}
+43
ファイルの表示
@@ -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
ファイルの表示
@@ -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
}
+105
ファイルの表示
@@ -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 })
}
+190
ファイルの表示
@@ -0,0 +1,190 @@
import { Transaction } from 'sequelize'
import { VideoObject, VideoPrivacy } from '@peertube/peertube-models'
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
import { Notifier } from '@server/lib/notifier/index.js'
import { PeerTubeSocket } from '@server/lib/peertube-socket.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import {
MActor,
MChannelAccountLight,
MChannelId,
MVideoAccountLightBlacklistAllFiles,
MVideoFullLight
} from '@server/types/models/index.js'
import { APVideoAbstractBuilder, getVideoAttributesFromObject, updateVideoRates } from './shared/index.js'
export class APVideoUpdater extends APVideoAbstractBuilder {
private readonly wasPrivateVideo: boolean
private readonly wasUnlistedVideo: boolean
private readonly oldVideoChannel: MChannelAccountLight
protected lTags: LoggerTagsFn
constructor (
protected readonly videoObject: VideoObject,
private readonly video: MVideoAccountLightBlacklistAllFiles
) {
super()
this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
this.oldVideoChannel = this.video.VideoChannel
this.lTags = loggerTagsFactory('ap', 'video', 'update', video.uuid, video.url)
}
async update (overrideTo?: string[]) {
logger.debug(
'Updating remote video "%s".', this.videoObject.uuid,
{ videoObject: this.videoObject, ...this.lTags() }
)
const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt
try {
const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
this.checkChannelUpdateOrThrow(channelActor)
const oldState = this.video.state
const oldVideo = { name: this.video.name, description: this.video.description }
const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo)
await runInReadCommittedTransaction(async t => {
await this.setWebVideoFiles(videoUpdated, t)
await this.setStreamingPlaylists(videoUpdated, t)
})
await Promise.all([
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
runInReadCommittedTransaction(t => this.setAutomaticTags({ video: videoUpdated, transaction: t, oldVideo })),
runInReadCommittedTransaction(t => {
return Promise.all([
this.setPreview(videoUpdated, t),
this.setThumbnail(videoUpdated, t)
])
}),
this.setOrDeleteLive(videoUpdated)
])
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
await this.updateChaptersOutsideTransaction(videoUpdated)
await autoBlacklistVideoIfNeeded({
video: videoUpdated,
user: undefined,
isRemote: true,
isNew: false,
isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt,
transaction: undefined
})
await updateVideoRates(videoUpdated, this.videoObject)
// Notify our users?
if (this.wasPrivateVideo || this.wasUnlistedVideo) {
Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(videoUpdated)
}
if (videoUpdated.isLive && oldState !== videoUpdated.state) {
PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
Notifier.Instance.notifyOnNewVideoOrLiveIfNeeded(videoUpdated)
}
Hooks.runAction('action:activity-pub.remote-video.updated', { video: videoUpdated, videoAPObject: this.videoObject })
logger.info('Remote video with uuid %s updated', this.videoObject.uuid, this.lTags())
return videoUpdated
} catch (err) {
await this.catchUpdateError(err)
}
}
// Check we can update the channel: we trust the remote server
private checkChannelUpdateOrThrow (newChannelActor: MActor) {
if (!this.oldVideoChannel.Actor.serverId || !newChannelActor.serverId) {
throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
}
if (this.oldVideoChannel.Actor.serverId !== newChannelActor.serverId) {
throw new Error(`New channel ${newChannelActor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`)
}
}
private updateVideo (channel: MChannelId, transaction?: Transaction, overrideTo?: string[]) {
const to = overrideTo || this.videoObject.to
const videoData = getVideoAttributesFromObject(channel, this.videoObject, to)
this.video.name = videoData.name
this.video.uuid = videoData.uuid
this.video.url = videoData.url
this.video.category = videoData.category
this.video.licence = videoData.licence
this.video.language = videoData.language
this.video.description = videoData.description
this.video.support = videoData.support
this.video.nsfw = videoData.nsfw
this.video.commentsPolicy = videoData.commentsPolicy
this.video.downloadEnabled = videoData.downloadEnabled
this.video.waitTranscoding = videoData.waitTranscoding
this.video.state = videoData.state
this.video.duration = videoData.duration
this.video.createdAt = videoData.createdAt
this.video.publishedAt = videoData.publishedAt
this.video.originallyPublishedAt = videoData.originallyPublishedAt
this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt
this.video.privacy = videoData.privacy
this.video.channelId = videoData.channelId
this.video.views = videoData.views
this.video.isLive = videoData.isLive
this.video.aspectRatio = videoData.aspectRatio
// Ensures we update the updatedAt attribute, even if main attributes did not change
this.video.changed('updatedAt', true)
return this.video.save({ transaction }) as Promise<MVideoFullLight>
}
private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
await this.insertOrReplaceCaptions(videoUpdated, t)
}
private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) {
await this.insertOrReplaceStoryboard(videoUpdated, t)
}
private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
if (!this.video.isLive) return
if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction)
// Delete existing live if it exists
await VideoLiveModel.destroy({
where: {
videoId: this.video.id
},
transaction
})
videoUpdated.VideoLive = null
}
private async catchUpdateError (err: Error) {
if (this.video !== undefined) {
await resetSequelizeInstance(this.video)
}
// This is just a debug because we will retry the insert
logger.debug('Cannot update the remote video.', { err, ...this.lTags() })
throw err
}
}
+86
ファイルの表示
@@ -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
}
+230
ファイルの表示
@@ -0,0 +1,230 @@
import {
isUserAdminFlagsValid,
isUserDisplayNameValid,
isUserRoleValid,
isUserUsernameValid,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid
} from '@server/helpers/custom-validators/users.js'
import { logger } from '@server/helpers/logger.js'
import { generateRandomString } from '@server/helpers/utils.js'
import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants.js'
import { PluginManager } from '@server/lib/plugins/plugin-manager.js'
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
import { MUser } from '@server/types/models/index.js'
import {
RegisterServerAuthenticatedResult,
RegisterServerAuthPassOptions,
RegisterServerExternalAuthenticatedResult
} from '@server/types/plugins/register-server-auth.model.js'
import { UserAdminFlag, UserRole } from '@peertube/peertube-models'
import { BypassLogin } from './oauth-model.js'
export type ExternalUser =
Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
{ displayName: string }
// Token is the key, expiration date is the value
const authBypassTokens = new Map<string, {
expires: Date
user: ExternalUser
userUpdater: RegisterServerAuthenticatedResult['userUpdater']
authName: string
npmName: string
}>()
async function onExternalUserAuthenticated (options: {
npmName: string
authName: string
authResult: RegisterServerExternalAuthenticatedResult
}) {
const { npmName, authName, authResult } = options
if (!authResult.req || !authResult.res) {
logger.error('Cannot authenticate external user for auth %s of plugin %s: no req or res are provided.', authName, npmName)
return
}
const { res } = authResult
if (!isAuthResultValid(npmName, authName, authResult)) {
res.redirect('/login?externalAuthError=true')
return
}
logger.info('Generating auth bypass token for %s in auth %s of plugin %s.', authResult.username, authName, npmName)
const bypassToken = await generateRandomString(32)
const expires = new Date()
expires.setTime(expires.getTime() + PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME)
const user = buildUserResult(authResult)
authBypassTokens.set(bypassToken, {
expires,
user,
npmName,
authName,
userUpdater: authResult.userUpdater
})
// Cleanup expired tokens
const now = new Date()
for (const [ key, value ] of authBypassTokens) {
if (value.expires.getTime() < now.getTime()) {
authBypassTokens.delete(key)
}
}
res.redirect(`/login?externalAuthToken=${bypassToken}&username=${user.username}`)
}
async function getAuthNameFromRefreshGrant (refreshToken?: string) {
if (!refreshToken) return undefined
const tokenModel = await OAuthTokenModel.loadByRefreshToken(refreshToken)
return tokenModel?.authName
}
async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
const plugins = PluginManager.Instance.getIdAndPassAuths()
const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
for (const plugin of plugins) {
const auths = plugin.idAndPassAuths
for (const auth of auths) {
pluginAuths.push({
npmName: plugin.npmName,
registerAuthOptions: auth
})
}
}
pluginAuths.sort((a, b) => {
const aWeight = a.registerAuthOptions.getWeight()
const bWeight = b.registerAuthOptions.getWeight()
// DESC weight order
if (aWeight === bWeight) return 0
if (aWeight < bWeight) return 1
return -1
})
const loginOptions = {
id: username,
password
}
for (const pluginAuth of pluginAuths) {
const authOptions = pluginAuth.registerAuthOptions
const authName = authOptions.authName
const npmName = pluginAuth.npmName
logger.debug(
'Using auth method %s of plugin %s to login %s with weight %d.',
authName, npmName, loginOptions.id, authOptions.getWeight()
)
try {
const loginResult = await authOptions.login(loginOptions)
if (!loginResult) continue
if (!isAuthResultValid(pluginAuth.npmName, authOptions.authName, loginResult)) continue
logger.info(
'Login success with auth method %s of plugin %s for %s.',
authName, npmName, loginOptions.id
)
return {
bypass: true,
pluginName: pluginAuth.npmName,
authName: authOptions.authName,
user: buildUserResult(loginResult),
userUpdater: loginResult.userUpdater
}
} catch (err) {
logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
}
}
return undefined
}
function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
const obj = authBypassTokens.get(externalAuthToken)
if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
const { expires, user, authName, npmName } = obj
const now = new Date()
if (now.getTime() > expires.getTime()) {
throw new Error('Cannot authenticate user with an expired external auth token')
}
if (user.username !== username) {
throw new Error(`Cannot authenticate user ${user.username} with invalid username ${username}`)
}
logger.info(
'Auth success with external auth method %s of plugin %s for %s.',
authName, npmName, user.email
)
return {
bypass: true,
pluginName: npmName,
authName,
userUpdater: obj.userUpdater,
user
}
}
function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
const returnError = (field: string) => {
logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
return false
}
if (!isUserUsernameValid(result.username)) return returnError('username')
if (!result.email) return returnError('email')
// Following fields are optional
if (result.role && !isUserRoleValid(result.role)) return returnError('role')
if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
if (result.userUpdater && typeof result.userUpdater !== 'function') {
logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
return false
}
return true
}
function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
return {
username: pluginResult.username,
email: pluginResult.email,
role: pluginResult.role ?? UserRole.USER,
displayName: pluginResult.displayName || pluginResult.username,
adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
videoQuota: pluginResult.videoQuota,
videoQuotaDaily: pluginResult.videoQuotaDaily
}
}
// ---------------------------------------------------------------------------
export {
onExternalUserAuthenticated,
getBypassFromExternalAuth,
getAuthNameFromRefreshGrant,
getBypassFromPasswordGrant
}
+297
ファイルの表示
@@ -0,0 +1,297 @@
import express from 'express'
import { AccessDeniedError } from '@node-oauth/oauth2-server'
import { PluginManager } from '@server/lib/plugins/plugin-manager.js'
import { AccountModel } from '@server/models/account/account.js'
import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types/index.js'
import { MOAuthClient } from '@server/types/models/index.js'
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
import { MUser, MUserDefault } from '@server/types/models/user/user.js'
import { pick } from '@peertube/peertube-core-utils'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { logger } from '../../helpers/logger.js'
import { CONFIG } from '../../initializers/config.js'
import { OAuthClientModel } from '../../models/oauth/oauth-client.js'
import { OAuthTokenModel } from '../../models/oauth/oauth-token.js'
import { UserModel } from '../../models/user/user.js'
import { findAvailableLocalActorName } from '../local-actor.js'
import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user.js'
import { ExternalUser } from './external-auth.js'
import { TokensCache } from './tokens-cache.js'
type TokenInfo = {
accessToken: string
refreshToken: string
accessTokenExpiresAt: Date
refreshTokenExpiresAt: Date
}
export type BypassLogin = {
bypass: boolean
pluginName: string
authName?: string
user: ExternalUser
userUpdater: RegisterServerAuthenticatedResult['userUpdater']
}
async function getAccessToken (bearerToken: string) {
logger.debug('Getting access token.')
if (!bearerToken) return undefined
let tokenModel: MOAuthTokenUser
if (TokensCache.Instance.hasToken(bearerToken)) {
tokenModel = TokensCache.Instance.getByToken(bearerToken)
} else {
tokenModel = await OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
if (tokenModel) TokensCache.Instance.setToken(tokenModel)
}
if (!tokenModel) return undefined
if (tokenModel.User.pluginAuth) {
const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'access')
if (valid !== true) return undefined
}
return tokenModel
}
function getClient (clientId: string, clientSecret: string) {
logger.debug('Getting Client (clientId: ' + clientId + ', clientSecret: ' + clientSecret + ').')
return OAuthClientModel.getByIdAndSecret(clientId, clientSecret)
}
async function getRefreshToken (refreshToken: string) {
logger.debug('Getting RefreshToken (refreshToken: ' + refreshToken + ').')
const tokenInfo = await OAuthTokenModel.getByRefreshTokenAndPopulateClient(refreshToken)
if (!tokenInfo) return undefined
const tokenModel = tokenInfo.token
if (tokenModel.User.pluginAuth) {
const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'refresh')
if (valid !== true) return undefined
}
return tokenInfo
}
async function getUser (usernameOrEmail?: string, password?: string, bypassLogin?: BypassLogin) {
// Special treatment coming from a plugin
if (bypassLogin && bypassLogin.bypass === true) {
logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
let user = await UserModel.loadByEmail(bypassLogin.user.email)
if (!user) {
user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
} else if (user.pluginAuth === bypassLogin.pluginName) {
user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
}
// Cannot create a user
if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
// If the user does not belongs to a plugin, it was created before its installation
// Then we just go through a regular login process
if (user.pluginAuth !== null) {
// This user does not belong to this plugin, skip it
if (user.pluginAuth !== bypassLogin.pluginName) {
logger.info(
'Cannot bypass oauth login by plugin %s because %s has another plugin auth method (%s).',
bypassLogin.pluginName, bypassLogin.user.email, user.pluginAuth
)
return null
}
checkUserValidityOrThrow(user)
return user
}
}
logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail)
// If we don't find the user, or if the user belongs to a plugin
if (!user || user.pluginAuth !== null || !password) return null
const passwordMatch = await user.isPasswordMatch(password)
if (passwordMatch !== true) return null
checkUserValidityOrThrow(user)
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
throw new AccessDeniedError('User email is not verified.')
}
return user
}
async function revokeToken (
tokenInfo: { refreshToken: string },
options: {
req?: express.Request
explicitLogout?: boolean
} = {}
): Promise<{ success: boolean, redirectUrl?: string }> {
const { req, explicitLogout } = options
const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
if (token) {
let redirectUrl: string
if (explicitLogout === true && token.User.pluginAuth && token.authName) {
redirectUrl = await PluginManager.Instance.onLogout(token.User.pluginAuth, token.authName, token.User, req)
}
TokensCache.Instance.clearCacheByToken(token.accessToken)
token.destroy()
.catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
return { success: true, redirectUrl }
}
return { success: false }
}
async function saveToken (
token: TokenInfo,
client: MOAuthClient,
user: MUser,
options: {
refreshTokenAuthName?: string
bypassLogin?: BypassLogin
} = {}
) {
const { refreshTokenAuthName, bypassLogin } = options
let authName: string = null
if (bypassLogin?.bypass === true) {
authName = bypassLogin.authName
} else if (refreshTokenAuthName) {
authName = refreshTokenAuthName
}
logger.debug('Saving token ' + token.accessToken + ' for client ' + client.id + ' and user ' + user.id + '.')
const tokenToCreate = {
accessToken: token.accessToken,
accessTokenExpiresAt: token.accessTokenExpiresAt,
refreshToken: token.refreshToken,
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
authName,
oAuthClientId: client.id,
userId: user.id
}
const tokenCreated = await OAuthTokenModel.create(tokenToCreate)
user.lastLoginDate = new Date()
await user.save()
return {
accessToken: tokenCreated.accessToken,
accessTokenExpiresAt: tokenCreated.accessTokenExpiresAt,
refreshToken: tokenCreated.refreshToken,
refreshTokenExpiresAt: tokenCreated.refreshTokenExpiresAt,
client,
user,
accessTokenExpiresIn: buildExpiresIn(tokenCreated.accessTokenExpiresAt),
refreshTokenExpiresIn: buildExpiresIn(tokenCreated.refreshTokenExpiresAt)
}
}
export {
getAccessToken,
getClient,
getRefreshToken,
getUser,
revokeToken,
saveToken
}
// ---------------------------------------------------------------------------
async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
const username = await findAvailableLocalActorName(userOptions.username)
const userToCreate = buildUser({
...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
username,
emailVerified: null,
password: null,
pluginAuth
})
const { user } = await createUserAccountAndChannelAndPlaylist({
userToCreate,
userDisplayName: userOptions.displayName
})
return user
}
async function updateUserFromExternal (
user: MUserDefault,
userOptions: ExternalUser,
userUpdater: RegisterServerAuthenticatedResult['userUpdater']
) {
if (!userUpdater) return user
{
type UserAttributeKeys = keyof AttributesOnly<UserModel>
const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
role: 'role',
adminFlags: 'adminFlags',
videoQuota: 'videoQuota',
videoQuotaDaily: 'videoQuotaDaily'
}
for (const modelKey of Object.keys(mappingKeys)) {
const pluginOptionKey = mappingKeys[modelKey]
const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
user.set(modelKey, newValue)
}
}
{
type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
name: 'displayName'
}
for (const modelKey of Object.keys(mappingKeys)) {
const optionKey = mappingKeys[modelKey]
const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
user.Account.set(modelKey, newValue)
}
}
logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
user.Account = await user.Account.save()
return user.save()
}
function checkUserValidityOrThrow (user: MUser) {
if (user.blocked) throw new AccessDeniedError('User is blocked.')
}
function buildExpiresIn (expiresAt: Date) {
return Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000)
}
+230
ファイルの表示
@@ -0,0 +1,230 @@
import express from 'express'
import OAuth2Server, {
InvalidClientError,
InvalidGrantError,
InvalidRequestError,
Request,
Response,
UnauthorizedClientError,
UnsupportedGrantTypeError
} from '@node-oauth/oauth2-server'
import { randomBytesPromise } from '@server/helpers/core-utils.js'
import { isOTPValid } from '@server/helpers/otp.js'
import { CONFIG } from '@server/initializers/config.js'
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import { MOAuthClient } from '@server/types/models/index.js'
import { sha1 } from '@peertube/peertube-node-utils'
import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@peertube/peertube-models'
import { OTP } from '../../initializers/constants.js'
import { BypassLogin, getAccessToken, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model.js'
class MissingTwoFactorError extends Error {
code = HttpStatusCode.UNAUTHORIZED_401
name = ServerErrorCode.MISSING_TWO_FACTOR
}
class InvalidTwoFactorError extends Error {
code = HttpStatusCode.BAD_REQUEST_400
name = ServerErrorCode.INVALID_TWO_FACTOR
}
class RegistrationWaitingForApproval extends Error {
code = HttpStatusCode.BAD_REQUEST_400
name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
}
class RegistrationApprovalRejected extends Error {
code = HttpStatusCode.BAD_REQUEST_400
name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
}
/**
*
* Reimplement some functions of OAuth2Server to inject external auth methods
*
*/
const oAuthServer = new OAuth2Server({
// Wants seconds
accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
// See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
model: {
getAccessToken,
getClient,
getRefreshToken,
getUser,
revokeToken,
saveToken
} as any // FIXME: typings
})
// ---------------------------------------------------------------------------
async function handleOAuthToken (req: express.Request, options: { refreshTokenAuthName?: string, bypassLogin?: BypassLogin }) {
const request = new Request(req)
const { refreshTokenAuthName, bypassLogin } = options
if (request.method !== 'POST') {
throw new InvalidRequestError('Invalid request: method must be POST')
}
if (!request.is([ 'application/x-www-form-urlencoded' ])) {
throw new InvalidRequestError('Invalid request: content must be application/x-www-form-urlencoded')
}
const clientId = request.body.client_id
const clientSecret = request.body.client_secret
if (!clientId || !clientSecret) {
throw new InvalidClientError('Invalid client: cannot retrieve client credentials')
}
const client = await getClient(clientId, clientSecret)
if (!client) {
throw new InvalidClientError('Invalid client: client is invalid')
}
const grantType = request.body.grant_type
if (!grantType) {
throw new InvalidRequestError('Missing parameter: `grant_type`')
}
if (![ 'password', 'refresh_token' ].includes(grantType)) {
throw new UnsupportedGrantTypeError('Unsupported grant type: `grant_type` is invalid')
}
if (!client.grants.includes(grantType)) {
throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
}
if (grantType === 'password') {
return handlePasswordGrant({
request,
client,
bypassLogin
})
}
return handleRefreshGrant({
request,
client,
refreshTokenAuthName
})
}
function handleOAuthAuthenticate (
req: express.Request,
res: express.Response
) {
return oAuthServer.authenticate(new Request(req), new Response(res))
}
export {
MissingTwoFactorError,
InvalidTwoFactorError,
handleOAuthToken,
handleOAuthAuthenticate
}
// ---------------------------------------------------------------------------
async function handlePasswordGrant (options: {
request: Request
client: MOAuthClient
bypassLogin?: BypassLogin
}) {
const { request, client, bypassLogin } = options
if (!request.body.username) {
throw new InvalidRequestError('Missing parameter: `username`')
}
if (!bypassLogin && !request.body.password) {
throw new InvalidRequestError('Missing parameter: `password`')
}
const user = await getUser(request.body.username, request.body.password, bypassLogin)
if (!user) {
const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
if (registration?.state === UserRegistrationState.REJECTED) {
throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
} else if (registration?.state === UserRegistrationState.PENDING) {
throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
}
throw new InvalidGrantError('Invalid grant: user credentials are invalid')
}
if (user.otpSecret) {
if (!request.headers[OTP.HEADER_NAME]) {
throw new MissingTwoFactorError('Missing two factor header')
}
if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
throw new InvalidTwoFactorError('Invalid two factor header')
}
}
const token = await buildToken()
return saveToken(token, client, user, { bypassLogin })
}
async function handleRefreshGrant (options: {
request: Request
client: MOAuthClient
refreshTokenAuthName: string
}) {
const { request, client, refreshTokenAuthName } = options
if (!request.body.refresh_token) {
throw new InvalidRequestError('Missing parameter: `refresh_token`')
}
const refreshToken = await getRefreshToken(request.body.refresh_token)
if (!refreshToken) {
throw new InvalidGrantError('Invalid grant: refresh token is invalid')
}
if (refreshToken.client.id !== client.id) {
throw new InvalidGrantError('Invalid grant: refresh token is invalid')
}
if (refreshToken.refreshTokenExpiresAt && refreshToken.refreshTokenExpiresAt < new Date()) {
throw new InvalidGrantError('Invalid grant: refresh token has expired')
}
await revokeToken({ refreshToken: refreshToken.refreshToken })
const token = await buildToken()
return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })
}
function generateRandomToken () {
return randomBytesPromise(256)
.then(buffer => sha1(buffer))
}
function getTokenExpiresAt (type: 'access' | 'refresh') {
const lifetime = type === 'access'
? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
return new Date(Date.now() + lifetime)
}
async function buildToken () {
const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
return {
accessToken,
refreshToken,
accessTokenExpiresAt: getTokenExpiresAt('access'),
refreshTokenExpiresAt: getTokenExpiresAt('refresh')
}
}
+52
ファイルの表示
@@ -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
ファイルの表示
@@ -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
ファイルの表示
@@ -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
ファイルの表示
@@ -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
}
+355
ファイルの表示
@@ -0,0 +1,355 @@
import { arrayify } from '@peertube/peertube-core-utils'
import { EmailPayload, SendEmailDefaultOptions, UserExportState, UserRegistrationState } from '@peertube/peertube-models'
import { isTestOrDevInstance, root } from '@peertube/peertube-node-utils'
import { readFileSync } from 'fs'
import merge from 'lodash-es/merge.js'
import { Transporter, createTransport } from 'nodemailer'
import { join } from 'path'
import { bunyanLogger, logger } from '../helpers/logger.js'
import { CONFIG, isEmailEnabled } from '../initializers/config.js'
import { WEBSERVER } from '../initializers/constants.js'
import { MRegistration, MUser, MUserExport, MUserImport } from '../types/models/index.js'
import { JobQueue } from './job-queue/index.js'
import { UserModel } from '@server/models/user/user.js'
class Emailer {
private static instance: Emailer
private initialized = false
private transporter: Transporter
private constructor () {
}
init () {
// Already initialized
if (this.initialized === true) return
this.initialized = true
if (!isEmailEnabled()) {
if (!isTestOrDevInstance()) {
logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
}
return
}
if (CONFIG.SMTP.TRANSPORT === 'smtp') this.initSMTPTransport()
else if (CONFIG.SMTP.TRANSPORT === 'sendmail') this.initSendmailTransport()
}
async checkConnection () {
if (!this.transporter || CONFIG.SMTP.TRANSPORT !== 'smtp') return
logger.info('Testing SMTP server...')
try {
const success = await this.transporter.verify()
if (success !== true) this.warnOnConnectionFailure()
logger.info('Successfully connected to SMTP server.')
} catch (err) {
this.warnOnConnectionFailure(err)
}
}
// ---------------------------------------------------------------------------
addPasswordResetEmailJob (username: string, to: string, resetPasswordUrl: string) {
const emailPayload: EmailPayload = {
template: 'password-reset',
to: [ to ],
subject: 'Reset your account password',
locals: {
username,
resetPasswordUrl,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addPasswordCreateEmailJob (username: string, to: string, createPasswordUrl: string) {
const emailPayload: EmailPayload = {
template: 'password-create',
to: [ to ],
subject: 'Create your account password',
locals: {
username,
createPasswordUrl,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addVerifyEmailJob (options: {
username: string
isRegistrationRequest: boolean
to: string
verifyEmailUrl: string
}) {
const { username, isRegistrationRequest, to, verifyEmailUrl } = options
const emailPayload: EmailPayload = {
template: 'verify-email',
to: [ to ],
subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
locals: {
username,
verifyEmailUrl,
isRegistrationRequest,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addUserBlockJob (user: MUser, blocked: boolean, reason?: string) {
const reasonString = reason ? ` for the following reason: ${reason}` : ''
const blockedWord = blocked ? 'blocked' : 'unblocked'
const to = user.email
const emailPayload: EmailPayload = {
to: [ to ],
subject: 'Account ' + blockedWord,
text: `Your account ${user.username} on ${CONFIG.INSTANCE.NAME} has been ${blockedWord}${reasonString}.`
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addContactFormJob (fromEmail: string, fromName: string, subject: string, body: string) {
const emailPayload: EmailPayload = {
template: 'contact-form',
to: [ CONFIG.ADMIN.EMAIL ],
replyTo: `"${fromName}" <${fromEmail}>`,
subject: `(contact form) ${subject}`,
locals: {
fromName,
fromEmail,
body,
// There are not notification preferences for the contact form
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
addUserRegistrationRequestProcessedJob (registration: MRegistration) {
let template: string
let subject: string
if (registration.state === UserRegistrationState.ACCEPTED) {
template = 'user-registration-request-accepted'
subject = `Your registration request for ${registration.username} has been accepted`
} else {
template = 'user-registration-request-rejected'
subject = `Your registration request for ${registration.username} has been rejected`
}
const to = registration.email
const emailPayload: EmailPayload = {
to: [ to ],
template,
subject,
locals: {
username: registration.username,
moderationResponse: registration.moderationResponse,
loginLink: WEBSERVER.URL + '/login',
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
// ---------------------------------------------------------------------------
async addUserExportCompletedOrErroredJob (userExport: MUserExport) {
let template: string
let subject: string
if (userExport.state === UserExportState.COMPLETED) {
template = 'user-export-completed'
subject = `Your export archive has been created`
} else {
template = 'user-export-errored'
subject = `Failed to create your export archive`
}
const user = await UserModel.loadById(userExport.userId)
const emailPayload: EmailPayload = {
to: [ user.email ],
template,
subject,
locals: {
exportsUrl: WEBSERVER.URL + '/my-account/import-export',
errorMessage: userExport.error,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
async addUserImportErroredJob (userImport: MUserImport) {
const user = await UserModel.loadById(userImport.userId)
const emailPayload: EmailPayload = {
to: [ user.email ],
template: 'user-import-errored',
subject: 'Failed to import your archive',
locals: {
errorMessage: userImport.error,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
async addUserImportSuccessJob (userImport: MUserImport) {
const user = await UserModel.loadById(userImport.userId)
const emailPayload: EmailPayload = {
to: [ user.email ],
template: 'user-import-completed',
subject: 'Your archive import has finished',
locals: {
resultStats: userImport.resultSummary.stats,
hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
// ---------------------------------------------------------------------------
async sendMail (options: EmailPayload) {
if (!isEmailEnabled()) {
logger.info('Cannot send mail because SMTP is not configured.')
return
}
const fromDisplayName = options.from
? options.from
: CONFIG.INSTANCE.NAME
const EmailTemplates = (await import('email-templates')).default
const email = new EmailTemplates({
send: true,
htmlToText: {
selectors: [
{ selector: 'img', format: 'skip' },
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true } }
]
},
message: {
from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
},
transport: this.transporter,
views: {
root: join(root(), 'dist', 'core', 'assets', 'email-templates')
},
subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX
})
const toEmails = arrayify(options.to)
for (const to of toEmails) {
const baseOptions: SendEmailDefaultOptions = {
template: 'common',
message: {
to,
from: options.from,
subject: options.subject,
replyTo: options.replyTo
},
locals: { // default variables available in all templates
WEBSERVER,
EMAIL: CONFIG.EMAIL,
instanceName: CONFIG.INSTANCE.NAME,
text: options.text,
subject: options.subject
}
}
// overridden/new variables given for a specific template in the payload
const sendOptions = merge(baseOptions, options)
await email.send(sendOptions)
.then(res => logger.debug('Sent email.', { res }))
.catch(err => logger.error('Error in email sender.', { err }))
}
}
private warnOnConnectionFailure (err?: Error) {
logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, { err })
}
private initSMTPTransport () {
logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
let tls: { ca: [ Buffer ] }
if (CONFIG.SMTP.CA_FILE) {
tls = {
ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ]
}
}
let auth: { user: string, pass: string }
if (CONFIG.SMTP.USERNAME && CONFIG.SMTP.PASSWORD) {
auth = {
user: CONFIG.SMTP.USERNAME,
pass: CONFIG.SMTP.PASSWORD
}
}
this.transporter = createTransport({
host: CONFIG.SMTP.HOSTNAME,
port: CONFIG.SMTP.PORT,
secure: CONFIG.SMTP.TLS,
debug: CONFIG.LOG.LEVEL === 'debug',
logger: bunyanLogger as any,
ignoreTLS: CONFIG.SMTP.DISABLE_STARTTLS,
tls,
auth
})
}
private initSendmailTransport () {
logger.info('Using sendmail to send emails')
this.transporter = createTransport({
sendmail: true,
newline: 'unix',
path: CONFIG.SMTP.SENDMAIL,
logger: bunyanLogger
})
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
Emailer
}
+27
ファイルの表示
@@ -0,0 +1,27 @@
import { CONFIG } from '@server/initializers/config.js'
import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { MActorImage } from '@server/types/models/index.js'
import { AbstractPermanentFileCache } from './shared/index.js'
export class AvatarPermanentFileCache extends AbstractPermanentFileCache<MActorImage> {
constructor () {
super(CONFIG.STORAGE.ACTOR_IMAGES_DIR)
}
protected loadModel (filename: string) {
return ActorImageModel.loadByFilename(filename)
}
protected getImageSize (image: MActorImage): { width: number, height: number } {
if (image.width && image.height) {
return {
height: image.height,
width: image.width
}
}
return ACTOR_IMAGES_SIZE[image.type][0]
}
}
+6
ファイルの表示
@@ -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'
+132
ファイルの表示
@@ -0,0 +1,132 @@
import express from 'express'
import { LRUCache } from 'lru-cache'
import { Model } from 'sequelize'
import { logger } from '@server/helpers/logger.js'
import { CachePromise } from '@server/helpers/promise-cache.js'
import { LRU_CACHE, STATIC_MAX_AGE } from '@server/initializers/constants.js'
import { downloadImageFromWorker } from '@server/lib/worker/parent-process.js'
import { HttpStatusCode } from '@peertube/peertube-models'
type ImageModel = {
fileUrl: string
filename: string
onDisk: boolean
isOwned (): boolean
getPath (): string
save (): Promise<Model>
}
export abstract class AbstractPermanentFileCache <M extends ImageModel> {
// Unsafe because it can return paths that do not exist anymore
private readonly filenameToPathUnsafeCache = new LRUCache<string, string>({
max: LRU_CACHE.FILENAME_TO_PATH_PERMANENT_FILE_CACHE.MAX_SIZE
})
protected abstract getImageSize (image: M): { width: number, height: number }
protected abstract loadModel (filename: string): Promise<M>
constructor (private readonly directory: string) {
}
async lazyServe (options: {
filename: string
res: express.Response
next: express.NextFunction
}) {
const { filename, res, next } = options
if (this.filenameToPathUnsafeCache.has(filename)) {
return res.sendFile(this.filenameToPathUnsafeCache.get(filename), { maxAge: STATIC_MAX_AGE.SERVER })
}
const image = await this.lazyLoadIfNeeded(filename)
if (!image) return res.status(HttpStatusCode.NOT_FOUND_404).end()
const path = image.getPath()
this.filenameToPathUnsafeCache.set(filename, path)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
if (!err) return
this.onServeError({ err, image, next, filename })
})
}
@CachePromise({
keyBuilder: filename => filename
})
private async lazyLoadIfNeeded (filename: string) {
const image = await this.loadModel(filename)
if (!image) return undefined
if (image.onDisk === false) {
if (!image.fileUrl) return undefined
try {
await this.downloadRemoteFile(image)
} catch (err) {
logger.warn('Cannot process remote image %s.', image.fileUrl, { err })
return undefined
}
}
return image
}
async downloadRemoteFile (image: M) {
logger.info('Download remote image %s lazily.', image.fileUrl)
const destination = await this.downloadImage({
filename: image.filename,
fileUrl: image.fileUrl,
size: this.getImageSize(image)
})
image.onDisk = true
image.save()
.catch(err => logger.error('Cannot save new image disk state.', { err }))
return destination
}
private onServeError (options: {
err: any
image: M
filename: string
next: express.NextFunction
}) {
const { err, image, filename, next } = options
// It seems this actor image is not on the disk anymore
if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) {
logger.error('Cannot lazy serve image %s.', filename, { err })
this.filenameToPathUnsafeCache.delete(filename)
image.onDisk = false
image.save()
.catch(err => logger.error('Cannot save new image disk state.', { err }))
}
return next(err)
}
private downloadImage (options: {
fileUrl: string
filename: string
size: { width: number, height: number }
}) {
const downloaderOptions = {
url: options.fileUrl,
destDir: this.directory,
destName: options.filename,
size: options.size
}
return downloadImageFromWorker(downloaderOptions)
}
}
+30
ファイルの表示
@@ -0,0 +1,30 @@
import { remove } from 'fs-extra/esm'
import { logger } from '../../../helpers/logger.js'
import memoizee from 'memoizee'
type GetFilePathResult = { isOwned: boolean, path: string, downloadName?: string } | undefined
export abstract class AbstractSimpleFileCache <T> {
getFilePath: (params: T) => Promise<GetFilePathResult>
abstract getFilePathImpl (params: T): Promise<GetFilePathResult>
// Load and save the remote file, then return the local path from filesystem
protected abstract loadRemoteFile (key: string): Promise<GetFilePathResult>
init (max: number, maxAge: number) {
this.getFilePath = memoizee(this.getFilePathImpl.bind(this), {
maxAge,
max,
promise: true,
dispose: (result?: GetFilePathResult) => {
if (result && result.isOwned !== true) {
remove(result.path)
.then(() => logger.debug('%s removed from %s', result.path, this.constructor.name))
.catch(err => logger.error('Cannot remove %s from cache %s.', result.path, this.constructor.name, { err }))
}
}
})
}
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './abstract-permanent-file-cache.js'
export * from './abstract-simple-file-cache.js'
+60
ファイルの表示
@@ -0,0 +1,60 @@
import { join } from 'path'
import { logger } from '@server/helpers/logger.js'
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
import { FILES_CACHE } from '../../initializers/constants.js'
import { VideoModel } from '../../models/video/video.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
private static instance: VideoCaptionsSimpleFileCache
private constructor () {
super()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
async getFilePathImpl (filename: string) {
const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename)
if (!videoCaption) return undefined
if (videoCaption.isOwned()) {
return { isOwned: true, path: videoCaption.getFSPath() }
}
return this.loadRemoteFile(filename)
}
// Key is the caption filename
protected async loadRemoteFile (key: string) {
const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(key)
if (!videoCaption) return undefined
if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
// Used to fetch the path
const video = await VideoModel.loadFull(videoCaption.videoId)
if (!video) return undefined
const remoteUrl = videoCaption.getFileUrl(video)
const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.filename)
try {
await doRequestAndSaveToFile(remoteUrl, destPath)
return { isOwned: false, path: destPath }
} catch (err) {
logger.info('Cannot fetch remote caption file %s.', remoteUrl, { err })
return undefined
}
}
}
export {
VideoCaptionsSimpleFileCache
}
+28
ファイルの表示
@@ -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
}
}
+58
ファイルの表示
@@ -0,0 +1,58 @@
import { join } from 'path'
import { FILES_CACHE } from '../../initializers/constants.js'
import { VideoModel } from '../../models/video/video.js'
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
import { ThumbnailType } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache <string> {
private static instance: VideoPreviewsSimpleFileCache
private constructor () {
super()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
async getFilePathImpl (filename: string) {
const thumbnail = await ThumbnailModel.loadWithVideoByFilename(filename, ThumbnailType.PREVIEW)
if (!thumbnail) return undefined
if (thumbnail.Video.isOwned()) return { isOwned: true, path: thumbnail.getPath() }
return this.loadRemoteFile(thumbnail.Video.uuid)
}
// Key is the video UUID
protected async loadRemoteFile (key: string) {
const video = await VideoModel.loadFull(key)
if (!video) return undefined
if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
const preview = video.getPreview()
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
const remoteUrl = preview.getOriginFileUrl(video)
try {
await doRequestAndSaveToFile(remoteUrl, destPath)
logger.debug('Fetched remote preview %s to %s.', remoteUrl, destPath)
return { isOwned: false, path: destPath }
} catch (err) {
logger.info('Cannot fetch remote preview file %s.', remoteUrl, { err })
return undefined
}
}
}
export {
VideoPreviewsSimpleFileCache
}
+53
ファイルの表示
@@ -0,0 +1,53 @@
import { join } from 'path'
import { logger } from '@server/helpers/logger.js'
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
import { StoryboardModel } from '@server/models/video/storyboard.js'
import { FILES_CACHE } from '../../initializers/constants.js'
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache <string> {
private static instance: VideoStoryboardsSimpleFileCache
private constructor () {
super()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
async getFilePathImpl (filename: string) {
const storyboard = await StoryboardModel.loadWithVideoByFilename(filename)
if (!storyboard) return undefined
if (storyboard.Video.isOwned()) return { isOwned: true, path: storyboard.getPath() }
return this.loadRemoteFile(storyboard.filename)
}
// Key is the storyboard filename
protected async loadRemoteFile (key: string) {
const storyboard = await StoryboardModel.loadWithVideoByFilename(key)
if (!storyboard) return undefined
const destPath = join(FILES_CACHE.STORYBOARDS.DIRECTORY, storyboard.filename)
const remoteUrl = storyboard.getOriginFileUrl(storyboard.Video)
try {
await doRequestAndSaveToFile(remoteUrl, destPath)
logger.debug('Fetched remote storyboard %s to %s.', remoteUrl, destPath)
return { isOwned: false, path: destPath }
} catch (err) {
logger.info('Cannot fetch remote storyboard file %s.', remoteUrl, { err })
return undefined
}
}
}
export {
VideoStoryboardsSimpleFileCache
}
+70
ファイルの表示
@@ -0,0 +1,70 @@
import { join } from 'path'
import { logger } from '@server/helpers/logger.js'
import { doRequestAndSaveToFile } from '@server/helpers/requests.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { MVideo, MVideoFile } from '@server/types/models/index.js'
import { CONFIG } from '../../initializers/config.js'
import { FILES_CACHE } from '../../initializers/constants.js'
import { VideoModel } from '../../models/video/video.js'
import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js'
class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache <string> {
private static instance: VideoTorrentsSimpleFileCache
private constructor () {
super()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
async getFilePathImpl (filename: string) {
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
if (!file) return undefined
if (file.getVideo().isOwned()) {
const downloadName = this.buildDownloadName(file.getVideo(), file)
return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName }
}
return this.loadRemoteFile(filename)
}
// Key is the torrent filename
protected async loadRemoteFile (key: string) {
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(key)
if (!file) return undefined
if (file.getVideo().isOwned()) throw new Error('Cannot load remote file of owned video.')
// Used to fetch the path
const video = await VideoModel.loadFull(file.getVideo().id)
if (!video) return undefined
const remoteUrl = file.getRemoteTorrentUrl(video)
const destPath = join(FILES_CACHE.TORRENTS.DIRECTORY, file.torrentFilename)
try {
await doRequestAndSaveToFile(remoteUrl, destPath)
const downloadName = this.buildDownloadName(video, file)
return { isOwned: false, path: destPath, downloadName }
} catch (err) {
logger.info('Cannot fetch remote torrent file %s.', remoteUrl, { err })
return undefined
}
}
private buildDownloadName (video: MVideo, file: MVideoFile) {
return `${video.name}-${file.resolution}p.torrent`
}
}
export {
VideoTorrentsSimpleFileCache
}
+286
ファイルの表示
@@ -0,0 +1,286 @@
import { uniqify, uuidRegex } from '@peertube/peertube-core-utils'
import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
import { FileStorage } from '@peertube/peertube-models'
import { sha256 } from '@peertube/peertube-node-utils'
import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models/index.js'
import { ensureDir, move, outputJSON, remove } from 'fs-extra/esm'
import { open, readFile, stat, writeFile } from 'fs/promises'
import flatten from 'lodash-es/flatten.js'
import PQueue from 'p-queue'
import { basename, dirname, join } from 'path'
import { getAudioStreamCodec, getVideoStreamCodec } from '../helpers/ffmpeg/index.js'
import { logger, loggerTagsFactory } from '../helpers/logger.js'
import { doRequest, doRequestAndSaveToFile } from '../helpers/requests.js'
import { generateRandomString } from '../helpers/utils.js'
import { CONFIG } from '../initializers/config.js'
import { P2P_MEDIA_LOADER_PEER_VERSION, REQUEST_TIMEOUTS } from '../initializers/constants.js'
import { sequelizeTypescript } from '../initializers/database.js'
import { VideoFileModel } from '../models/video/video-file.js'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js'
import { storeHLSFileFromFilename } from './object-storage/index.js'
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths.js'
import { VideoPathManager } from './video-path-manager.js'
const lTags = loggerTagsFactory('hls')
async function updateStreamingPlaylistsInfohashesIfNeeded () {
const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
// Use separate SQL queries, because we could have many videos to update
for (const playlist of playlistsToUpdate) {
await sequelizeTypescript.transaction(async t => {
const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlist.id, t)
playlist.assignP2PMediaLoaderInfoHashes(playlist.Video, videoFiles)
playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
await playlist.save({ transaction: t })
})
}
}
async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
try {
let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
playlistWithFiles = await updateSha256VODSegments(video, playlist)
// Refresh playlist, operations can take some time
playlistWithFiles = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlist.id)
playlistWithFiles.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
await playlistWithFiles.save()
video.setHLSPlaylist(playlistWithFiles)
} catch (err) {
logger.warn('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err })
}
}
// ---------------------------------------------------------------------------
// Avoid concurrency issues when updating streaming playlist files
const playlistFilesQueue = new PQueue({ concurrency: 1 })
function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
return playlistFilesQueue.add(async () => {
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
for (const file of playlist.VideoFiles) {
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
const size = await getVideoStreamDimensionsInfo(videoFilePath)
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
if (file.fps) line += ',FRAME-RATE=' + file.fps
const codecs = await Promise.all([
getVideoStreamCodec(videoFilePath),
getAudioStreamCodec(videoFilePath)
])
line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
masterPlaylists.push(line)
masterPlaylists.push(playlistFilename)
})
}
if (playlist.playlistFilename) {
await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
}
playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
logger.info('Updating %s master playlist file of video %s', masterPlaylistPath, video.uuid, lTags(video.uuid))
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename)
await remove(masterPlaylistPath)
}
return playlist.save()
}, { throwOnTimeout: true })
}
// ---------------------------------------------------------------------------
function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
return playlistFilesQueue.add(async () => {
const json: { [filename: string]: { [range: string]: string } } = {}
const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
// For all the resolutions available for this video
for (const file of playlist.VideoFiles) {
const rangeHashes: { [range: string]: string } = {}
const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
const playlistContent = await readFile(resolutionPlaylistPath)
const ranges = getRangesFromPlaylist(playlistContent.toString())
const fd = await open(videoPath, 'r')
for (const range of ranges) {
const buf = Buffer.alloc(range.length)
await fd.read(buf, 0, range.length, range.offset)
rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
}
await fd.close()
const videoFilename = file.filename
json[videoFilename] = rangeHashes
})
})
}
if (playlist.segmentsSha256Filename) {
await video.removeStreamingPlaylistFile(playlist, playlist.segmentsSha256Filename)
}
playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
await outputJSON(outputPath, json)
if (playlist.storage === FileStorage.OBJECT_STORAGE) {
playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlist, playlist.segmentsSha256Filename)
await remove(outputPath)
}
return playlist.save()
}, { throwOnTimeout: true })
}
// ---------------------------------------------------------------------------
async function buildSha256Segment (segmentPath: string) {
const buf = await readFile(segmentPath)
return sha256(buf)
}
function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
let timer
let remainingBodyKBLimit = bodyKBLimit
logger.info('Importing HLS playlist %s', playlistUrl)
return new Promise<void>(async (res, rej) => {
const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
await ensureDir(tmpDirectory)
timer = setTimeout(() => {
deleteTmpDirectory(tmpDirectory)
return rej(new Error('HLS download timeout.'))
}, timeout)
try {
// Fetch master playlist
const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
const fileUrls = uniqify(flatten(await Promise.all(subRequests)))
logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
for (const fileUrl of fileUrls) {
const destPath = join(tmpDirectory, basename(fileUrl))
await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
const { size } = await stat(destPath)
remainingBodyKBLimit -= (size / 1000)
logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit))
}
clearTimeout(timer)
await move(tmpDirectory, destinationDir, { overwrite: true })
return res()
} catch (err) {
deleteTmpDirectory(tmpDirectory)
return rej(err)
}
})
function deleteTmpDirectory (directory: string) {
remove(directory)
.catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
}
async function fetchUniqUrls (playlistUrl: string) {
const { body } = await doRequest(playlistUrl)
if (!body) return []
const urls = body.split('\n')
.filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
.map(url => {
if (url.startsWith('http://') || url.startsWith('https://')) return url
return `${dirname(playlistUrl)}/${url}`
})
return uniqify(urls)
}
}
// ---------------------------------------------------------------------------
async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) {
const content = await readFile(playlistPath, 'utf8')
const newContent = content.replace(new RegExp(`${uuidRegex}-\\d+-fragmented.mp4`, 'g'), newVideoFilename)
await writeFile(playlistPath, newContent, 'utf8')
}
// ---------------------------------------------------------------------------
function injectQueryToPlaylistUrls (content: string, queryString: string) {
return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
}
// ---------------------------------------------------------------------------
export {
updateMasterHLSPlaylist,
updateSha256VODSegments,
buildSha256Segment,
downloadPlaylistSegments,
updateStreamingPlaylistsInfohashesIfNeeded,
updatePlaylistAfterFileChange,
injectQueryToPlaylistUrls,
renameVideoFileInPlaylist
}
// ---------------------------------------------------------------------------
function getRangesFromPlaylist (playlistContent: string) {
const ranges: { offset: number, length: number }[] = []
const lines = playlistContent.split('\n')
const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
for (const line of lines) {
const captured = regex.exec(line)
if (captured) {
ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
}
}
return ranges
}
+96
ファイルの表示
@@ -0,0 +1,96 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { logger } from '../../helpers/logger.js'
import { ACCEPT_HEADERS } from '../../initializers/constants.js'
import { VideoHtml } from './shared/video-html.js'
import { PlaylistHtml } from './shared/playlist-html.js'
import { ActorHtml } from './shared/actor-html.js'
import { PageHtml } from './shared/page-html.js'
class ClientHtml {
static invalidateCache () {
PageHtml.invalidateCache()
}
static getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
return PageHtml.getDefaultHTML(req, res, paramLang)
}
// ---------------------------------------------------------------------------
static getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
return VideoHtml.getWatchVideoHTML(videoIdArg, req, res)
}
static getVideoEmbedHTML (videoIdArg: string) {
return VideoHtml.getEmbedVideoHTML(videoIdArg)
}
// ---------------------------------------------------------------------------
static getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
return PlaylistHtml.getWatchPlaylistHTML(videoPlaylistIdArg, req, res)
}
static getVideoPlaylistEmbedHTML (playlistIdArg: string) {
return PlaylistHtml.getEmbedPlaylistHTML(playlistIdArg)
}
// ---------------------------------------------------------------------------
static getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
return ActorHtml.getAccountHTMLPage(nameWithHost, req, res)
}
static getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
return ActorHtml.getVideoChannelHTMLPage(nameWithHost, req, res)
}
static getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
return ActorHtml.getActorHTMLPage(nameWithHost, req, res)
}
}
function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
res.set('Content-Type', 'text/html; charset=UTF-8')
res.set('Cache-Control', 'max-age=0, no-cache, must-revalidate')
if (localizedHTML) {
res.set('Vary', 'Accept-Language')
}
return res.send(html)
}
async function serveIndexHTML (req: express.Request, res: express.Response) {
if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
try {
await generateHTMLPage(req, res, req.params.language)
return
} catch (err) {
logger.error('Cannot generate HTML page.', { err })
return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
}
}
return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
}
// ---------------------------------------------------------------------------
export {
ClientHtml,
sendHTML,
serveIndexHTML
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
return sendHTML(html, res, true)
}
+94
ファイルの表示
@@ -0,0 +1,94 @@
import { escapeHTML, maxBy } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { AccountModel } from '@server/models/account/account.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccountHost, MChannelHost } from '@server/types/models/index.js'
import express from 'express'
import { CONFIG } from '../../../initializers/config.js'
import { PageHtml } from './page-html.js'
import { TagsHtml } from './tags-html.js'
export class ActorHtml {
static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
}
static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
}
static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
const [ account, channel ] = await Promise.all([
AccountModel.loadByNameWithHost(nameWithHost),
VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
])
return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
}
// ---------------------------------------------------------------------------
private static async getAccountOrChannelHTMLPage (
loader: () => Promise<MAccountHost | MChannelHost>,
req: express.Request,
res: express.Response
) {
const [ html, entity ] = await Promise.all([
PageHtml.getIndexHTML(req, res),
loader()
])
// Let Angular application handle errors
if (!entity) {
res.status(HttpStatusCode.NOT_FOUND_404)
return PageHtml.getIndexHTML(req, res)
}
const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(entity.description)
let customHTML = TagsHtml.addTitleTag(html, entity.getDisplayName())
customHTML = TagsHtml.addDescriptionTag(customHTML, escapedTruncatedDescription)
const url = entity.getClientUrl()
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()
const avatar = maxBy(entity.Actor.Avatars, 'width')
const image = {
url: ActorImageModel.getImageUrl(avatar),
width: avatar?.width,
height: avatar?.height
}
const ogType = 'website'
const twitterCard = 'summary'
const schemaType = 'ProfilePage'
customHTML = await TagsHtml.addTags(customHTML, {
url,
escapedTitle: escapeHTML(title),
escapedSiteName: escapeHTML(siteName),
escapedTruncatedDescription,
image,
ogType,
twitterCard,
schemaType,
jsonldProfile: {
createdAt: entity.createdAt,
updatedAt: entity.updatedAt
},
indexationPolicy: entity.Actor.isOwned()
? 'always'
: 'never'
}, {})
return customHTML
}
}

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示