1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Add public links to AP representation

See https://github.com/Chocobozzz/PeerTube/issues/6389
This commit is contained in:
Chocobozzz 2025-02-21 07:52:33 +01:00
parent de5adc09b2
commit e81b6eba74
No known key found for this signature in database
GPG key ID: 583A612D890159BE
17 changed files with 267 additions and 152 deletions

View file

@ -12,8 +12,7 @@
"singleBodyPosition": "maintain", "singleBodyPosition": "maintain",
"nextControlFlowPosition": "sameLine", "nextControlFlowPosition": "sameLine",
"trailingCommas": "never", "trailingCommas": "never",
"operatorPosition": "sameLine", "operatorPosition": "maintain",
"conditionalExpression.operatorPosition": "nextLine",
"preferHanging": false, "preferHanging": false,
"preferSingleLine": false, "preferSingleLine": false,
"arrowFunction.useParentheses": "preferNone", "arrowFunction.useParentheses": "preferNone",
@ -64,12 +63,9 @@
"arrayExpression.spaceAround": true, "arrayExpression.spaceAround": true,
"arrayPattern.spaceAround": true "arrayPattern.spaceAround": true
}, },
"json": { "json": {},
}, "markdown": {},
"markdown": { "toml": {},
},
"toml": {
},
"excludes": [ "excludes": [
"**/node_modules", "**/node_modules",
"**/*-lock.json", "**/*-lock.json",

View file

@ -50,7 +50,7 @@
"SwitchCase": 1, "SwitchCase": 1,
"MemberExpression": "off", "MemberExpression": "off",
// https://github.com/eslint/eslint/issues/15299 // https://github.com/eslint/eslint/issues/15299
"ignoredNodes": ["PropertyDefinition", "TSTypeParameterInstantiation"] "ignoredNodes": ["PropertyDefinition", "TSTypeParameterInstantiation", "TSConditionalType *"]
} }
], ],
"@typescript-eslint/consistent-type-assertions": [ "@typescript-eslint/consistent-type-assertions": [

View file

@ -1,4 +1,4 @@
import { ActivityIconObject, ActivityPubAttributedTo } from './objects/common-objects.js' import { ActivityIconObject, ActivityPubAttributedTo, ActivityUrlObject } from './objects/common-objects.js'
export type ActivityPubActorType = 'Person' | 'Application' | 'Group' | 'Service' | 'Organization' export type ActivityPubActorType = 'Person' | 'Application' | 'Group' | 'Service' | 'Organization'
@ -12,7 +12,7 @@ export interface ActivityPubActor {
inbox: string inbox: string
outbox: string outbox: string
preferredUsername: string preferredUsername: string
url: string url: ActivityUrlObject[] | string
name: string name: string
endpoints: { endpoints: {
sharedInbox: string sharedInbox: string

View file

@ -1,8 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai' import {
import { processViewersStats } from '@tests/shared/views.js' ActivityPubActor,
import { HttpStatusCode, VideoComment, VideoPlaylistPrivacy, WatchActionObject } from '@peertube/peertube-models' HttpStatusCode,
VideoComment,
VideoObject,
VideoPlaylistPrivacy,
WatchActionObject
} from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
createMultipleServers, createMultipleServers,
@ -12,8 +17,10 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
setDefaultVideoChannel setDefaultVideoChannel
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { processViewersStats } from '@tests/shared/views.js'
import { expect } from 'chai'
describe('Test activitypub', function () { describe('Test ActivityPub', function () {
let servers: PeerTubeServer[] = [] let servers: PeerTubeServer[] = []
let video: { id: number, uuid: string, shortUUID: string } let video: { id: number, uuid: string, shortUUID: string }
let playlist: { id: number, uuid: string, shortUUID: string } let playlist: { id: number, uuid: string, shortUUID: string }
@ -21,31 +28,62 @@ describe('Test activitypub', function () {
async function testAccount (path: string) { async function testAccount (path: string) {
const res = await makeActivityPubGetRequest(servers[0].url, path) const res = await makeActivityPubGetRequest(servers[0].url, path)
const object = res.body const object = res.body as ActivityPubActor
expect(object.type).to.equal('Person') expect(object.type).to.equal('Person')
expect(object.id).to.equal(servers[0].url + '/accounts/root') expect(object.id).to.equal(servers[0].url + '/accounts/root')
expect(object.name).to.equal('root') expect(object.name).to.equal('root')
expect(object.preferredUsername).to.equal('root') expect(object.preferredUsername).to.equal('root')
// TODO: enable in v8
// const htmlURLs = [
// servers[0].url + '/accounts/root',
// servers[0].url + '/a/root',
// servers[0].url + '/a/root/video-channels'
// ]
// for (const htmlURL of htmlURLs) {
// expect(object.url.find(u => u.href === htmlURL), htmlURL).to.exist
// }
} }
async function testChannel (path: string) { async function testChannel (path: string) {
const res = await makeActivityPubGetRequest(servers[0].url, path) const res = await makeActivityPubGetRequest(servers[0].url, path)
const object = res.body const object = res.body as ActivityPubActor
expect(object.type).to.equal('Group') expect(object.type).to.equal('Group')
expect(object.id).to.equal(servers[0].url + '/video-channels/root_channel') expect(object.id).to.equal(servers[0].url + '/video-channels/root_channel')
expect(object.name).to.equal('Main root channel') expect(object.name).to.equal('Main root channel')
expect(object.preferredUsername).to.equal('root_channel') expect(object.preferredUsername).to.equal('root_channel')
// TODO: enable in v8
// const htmlURLs = [
// servers[0].url + '/video-channels/root_channel',
// servers[0].url + '/c/root_channel',
// servers[0].url + '/c/root_channel/videos'
// ]
// for (const htmlURL of htmlURLs) {
// expect(object.url.find(u => u.href === htmlURL), htmlURL).to.exist
// }
} }
async function testVideo (path: string) { async function testVideo (path: string) {
const res = await makeActivityPubGetRequest(servers[0].url, path) const res = await makeActivityPubGetRequest(servers[0].url, path)
const object = res.body const object = res.body as VideoObject
expect(object.type).to.equal('Video') expect(object.type).to.equal('Video')
expect(object.id).to.equal(servers[0].url + '/videos/watch/' + video.uuid) expect(object.id).to.equal(servers[0].url + '/videos/watch/' + video.uuid)
expect(object.name).to.equal('video') expect(object.name).to.equal('video')
const htmlURLs = [
servers[0].url + '/videos/watch/' + video.uuid,
servers[0].url + '/w/' + video.shortUUID
]
for (const htmlURL of htmlURLs) {
expect(object.url.find(u => u.href === htmlURL), htmlURL).to.exist
}
} }
async function testComment (path: string) { async function testComment (path: string) {

View file

@ -1,10 +1,11 @@
import { unarray } from '@peertube/peertube-core-utils' import { arrayify } from '@peertube/peertube-core-utils'
import { peertubeTruncate } from '@server/helpers/core-utils.js' import { peertubeTruncate } from '@server/helpers/core-utils.js'
import { ActivityPubActor } from 'packages/models/src/activitypub/activitypub-actor.js'
import validator from 'validator' import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js' import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { exists, isArray, isDateValid } from '../misc.js' import { exists, isArray, isDateValid } from '../misc.js'
import { isHostValid } from '../servers.js' import { isHostValid } from '../servers.js'
import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc.js' import { isActivityPubHTMLUrlValid, isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc.js'
export function isActorEndpointsObjectValid (endpointObject: any) { export function isActorEndpointsObjectValid (endpointObject: any) {
if (endpointObject?.sharedInbox) { if (endpointObject?.sharedInbox) {
@ -62,7 +63,7 @@ export function isActorDeleteActivityValid (activity: any) {
return isBaseActivityValid(activity, 'Delete') return isBaseActivityValid(activity, 'Delete')
} }
export function sanitizeAndCheckActorObject (actor: any) { export function sanitizeAndCheckActorObject (actor: ActivityPubActor) {
if (!isActorTypeValid(actor.type)) return false if (!isActorTypeValid(actor.type)) return false
normalizeActor(actor) normalizeActor(actor)
@ -71,43 +72,16 @@ export function sanitizeAndCheckActorObject (actor: any) {
isActivityPubUrlValid(actor.id) && isActivityPubUrlValid(actor.id) &&
isActivityPubUrlValid(actor.inbox) && isActivityPubUrlValid(actor.inbox) &&
isActorPreferredUsernameValid(actor.preferredUsername) && isActorPreferredUsernameValid(actor.preferredUsername) &&
isActivityPubUrlValid(actor.url) &&
isActorPublicKeyObjectValid(actor.publicKey) && isActorPublicKeyObjectValid(actor.publicKey) &&
isActorEndpointsObjectValid(actor.endpoints) && isActorEndpointsObjectValid(actor.endpoints) &&
(!actor.outbox || isActivityPubUrlValid(actor.outbox)) && (!actor.outbox || isActivityPubUrlValid(actor.outbox)) &&
(!actor.following || isActivityPubUrlValid(actor.following)) && (!actor.following || isActivityPubUrlValid(actor.following)) &&
(!actor.followers || isActivityPubUrlValid(actor.followers)) && (!actor.followers || isActivityPubUrlValid(actor.followers)) &&
setValidAttributedTo(actor) &&
setValidDescription(actor) &&
// If this is a group (a channel), it should be attributed to an account // If this is a group (a channel), it should be attributed to an account
// In PeerTube we use this to attach a video channel to a specific account // In PeerTube we use this to attach a video channel to a specific account
(actor.type !== 'Group' || actor.attributedTo.length !== 0) (actor.type !== 'Group' || actor.attributedTo.length !== 0)
} }
export function normalizeActor (actor: any) {
if (!actor) return
if (!actor.url) {
actor.url = actor.id
} else if (isArray(actor.url)) {
actor.url = unarray(actor.url)
} else if (typeof actor.url !== 'string') {
actor.url = actor.url.href || actor.url.url
}
if (!isDateValid(actor.published)) actor.published = undefined
if (actor.summary && typeof actor.summary === 'string') {
actor.summary = peertubeTruncate(actor.summary, { length: CONSTRAINTS_FIELDS.USERS.DESCRIPTION.max })
if (actor.summary.length < CONSTRAINTS_FIELDS.USERS.DESCRIPTION.min) {
actor.summary = null
}
}
}
export function isValidActorHandle (handle: string) { export function isValidActorHandle (handle: string) {
if (!exists(handle)) return false if (!exists(handle)) return false
@ -121,8 +95,38 @@ export function areValidActorHandles (handles: string[]) {
return isArray(handles) && handles.every(h => isValidActorHandle(h)) return isArray(handles) && handles.every(h => isValidActorHandle(h))
} }
export function setValidDescription (obj: any) { // ---------------------------------------------------------------------------
if (!obj.summary) obj.summary = null // Private
// ---------------------------------------------------------------------------
return true function normalizeActor (actor: ActivityPubActor) {
if (!actor) return
setValidUrls(actor)
setValidAttributedTo(actor)
setValidDescription(actor)
if (!isDateValid(actor.published)) actor.published = undefined
if (actor.summary && typeof actor.summary === 'string') {
actor.summary = peertubeTruncate(actor.summary, { length: CONSTRAINTS_FIELDS.USERS.DESCRIPTION.max })
if (actor.summary.length < CONSTRAINTS_FIELDS.USERS.DESCRIPTION.min) {
actor.summary = null
}
}
}
function setValidDescription (actor: ActivityPubActor) {
if (!actor.summary) actor.summary = null
}
function setValidUrls (actor: any) {
if (!actor.url) {
actor.url = []
return
}
actor.url = arrayify(actor.url)
.filter(u => isActivityPubHTMLUrlValid(u))
} }

View file

@ -1,9 +1,10 @@
import validator from 'validator'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { ActivityHtmlUrlObject } from 'packages/models/src/activitypub/index.js'
import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js' import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { exists } from '../misc.js' import { exists } from '../misc.js'
function isUrlValid (url: string) { export function isUrlValid (url: string) {
const isURLOptions = { const isURLOptions = {
require_host: true, require_host: true,
require_tld: true, require_tld: true,
@ -20,11 +21,11 @@ function isUrlValid (url: string) {
return exists(url) && validator.default.isURL('' + url, isURLOptions) return exists(url) && validator.default.isURL('' + url, isURLOptions)
} }
function isActivityPubUrlValid (url: string) { export function isActivityPubUrlValid (url: string) {
return isUrlValid(url) && validator.default.isLength('' + url, CONSTRAINTS_FIELDS.ACTORS.URL) return isUrlValid(url) && validator.default.isLength('' + url, CONSTRAINTS_FIELDS.ACTORS.URL)
} }
function isBaseActivityValid (activity: any, type: string) { export function isBaseActivityValid (activity: any, type: string) {
return activity.type === type && return activity.type === type &&
isActivityPubUrlValid(activity.id) && isActivityPubUrlValid(activity.id) &&
isObjectValid(activity.actor) && isObjectValid(activity.actor) &&
@ -32,19 +33,26 @@ function isBaseActivityValid (activity: any, type: string) {
isUrlCollectionValid(activity.cc) isUrlCollectionValid(activity.cc)
} }
function isUrlCollectionValid (collection: any) { export function isUrlCollectionValid (collection: any) {
return collection === undefined || return collection === undefined ||
(Array.isArray(collection) && collection.every(t => isActivityPubUrlValid(t))) (Array.isArray(collection) && collection.every(t => isActivityPubUrlValid(t)))
} }
function isObjectValid (object: any) { export function isObjectValid (object: any) {
return exists(object) && return exists(object) &&
( (
isActivityPubUrlValid(object) || isActivityPubUrlValid(object.id) isActivityPubUrlValid(object) || isActivityPubUrlValid(object.id)
) )
} }
function setValidAttributedTo (obj: any) { export function isActivityPubHTMLUrlValid (url: ActivityHtmlUrlObject) {
return url &&
url.type === 'Link' &&
url.mediaType === 'text/html' &&
isActivityPubUrlValid(url.href)
}
export function setValidAttributedTo (obj: any) {
if (Array.isArray(obj.attributedTo) === false) { if (Array.isArray(obj.attributedTo) === false) {
obj.attributedTo = [] obj.attributedTo = []
return true return true
@ -58,19 +66,10 @@ function setValidAttributedTo (obj: any) {
return true return true
} }
function isActivityPubVideoDurationValid (value: string) { export function isActivityPubVideoDurationValid (value: string) {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return exists(value) && return exists(value) &&
typeof value === 'string' && typeof value === 'string' &&
value.startsWith('PT') && value.startsWith('PT') &&
value.endsWith('S') value.endsWith('S')
} }
export {
isUrlValid,
isActivityPubUrlValid,
isBaseActivityValid,
setValidAttributedTo,
isObjectValid,
isActivityPubVideoDurationValid
}

View file

@ -45,7 +45,7 @@ async function getOrCreateAPActor (
// We don't have this actor in our database, fetch it on remote // We don't have this actor in our database, fetch it on remote
if (!actor) { if (!actor) {
const { actorObject } = await fetchRemoteActor(actorUrl) const { actorObject } = await fetchRemoteActor(actorUrl)
if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl) if (actorObject === undefined) throw new Error(`Cannot fetch remote actor ${actorUrl}`)
// actorUrl is just an alias/redirection, so process object id instead // actorUrl is just an alias/redirection, so process object id instead
if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections) if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
@ -67,7 +67,7 @@ async function getOrCreateAPActor (
if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType }) const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType })
if (!actorRefreshed) throw new Error('Actor ' + actor.url + ' does not exist anymore.') if (!actorRefreshed) throw new Error(`Actor ${actor.url} does not exist anymore.`)
await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections) await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections)
await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl) await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
@ -75,10 +75,10 @@ async function getOrCreateAPActor (
return actorRefreshed return actorRefreshed
} }
async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) { async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorId: string) {
const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person') const accountAttributedTo = await findOwner(actorId, actorObject.attributedTo, 'Person')
if (!accountAttributedTo) { if (!accountAttributedTo) {
throw new Error(`Cannot find account attributed to video channel ${actorUrl}`) throw new Error(`Cannot find account attributed to video channel ${actorId}`)
} }
try { try {
@ -86,22 +86,22 @@ async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: stri
const recurseIfNeeded = false const recurseIfNeeded = false
return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded) return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
} catch (err) { } catch (err) {
logger.error('Cannot get or create account attributed to video channel ' + actorUrl) logger.error(`Cannot get or create account attributed to video channel ${actorId}`)
throw new Error(err) throw new Error(err)
} }
} }
async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') { async function findOwner (rootId: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') {
for (const actorToCheck of arrayify(attributedTo)) { for (const actorToCheck of arrayify(attributedTo)) {
const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck)) const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck))
if (!actorObject) { if (!actorObject) {
logger.warn('Unknown attributed to actor %s for owner %s', actorToCheck, rootUrl) logger.warn(`Unknown attributed to actor ${actorToCheck} for owner ${rootId}`)
continue continue
} }
if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) { if (checkUrlsSameHost(actorObject.id, rootId) !== true) {
logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`) logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootId}`)
continue continue
} }

View file

@ -1,16 +1,15 @@
import { ActivityPubActor, ActorImageType } from '@peertube/peertube-models'
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js' import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { AccountModel } from '@server/models/account/account.js' import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.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 { getOrCreateAPOwner } from './get.js'
import { updateActorImages } from './image.js' import { updateActorImages } from './image.js'
import { fetchActorFollowsCount } from './shared/index.js' import { fetchActorFollowsCount } from './shared/index.js'
import { getImagesInfoFromObject } from './shared/object-to-model-attributes.js' import { getImagesInfoFromObject } from './shared/object-to-model-attributes.js'
export class APActorUpdater { export class APActorUpdater {
private readonly accountOrChannel: MAccount | MChannel private readonly accountOrChannel: MAccount | MChannel
constructor ( constructor (
@ -32,7 +31,7 @@ export class APActorUpdater {
this.accountOrChannel.description = this.actorObject.summary this.accountOrChannel.description = this.actorObject.summary
if (this.accountOrChannel instanceof VideoChannelModel) { if (this.accountOrChannel instanceof VideoChannelModel) {
const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.url) const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.id)
this.accountOrChannel.accountId = owner.Account.id this.accountOrChannel.accountId = owner.Account.id
this.accountOrChannel.Account = owner.Account as AccountModel this.accountOrChannel.Account = owner.Account as AccountModel
@ -49,7 +48,8 @@ export class APActorUpdater {
await this.accountOrChannel.save({ transaction: t }) await this.accountOrChannel.save({ transaction: t })
}) })
logger.info('Remote account %s updated', this.actorObject.url) // Update the following line to template string
logger.info(`Remote account ${this.actorObject.id} updated`)
} catch (err) { } catch (err) {
if (this.actor !== undefined) { if (this.actor !== undefined) {
await resetSequelizeInstance(this.actor) await resetSequelizeInstance(this.actor)

View file

@ -113,7 +113,7 @@ async function processUpdateCacheFile (
} }
async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) { async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
logger.debug('Updating remote account "%s".', actorObject.url) logger.debug(`Updating remote account "${actorObject.id}".`)
const updater = new APActorUpdater(actorObject, actor) const updater = new APActorUpdater(actorObject, actor)
return updater.update() return updater.update()

View file

@ -18,9 +18,8 @@ async function processActivityPubFollow (job: Job) {
const payload = job.data as ActivitypubFollowPayload const payload = job.data as ActivitypubFollowPayload
const host = payload.host const host = payload.host
const handle = host const identifier = [ payload.name || SERVER_ACTOR_NAME, host ]
? `${payload.name}@${host}` .filter(v => !!v).join('@')
: payload.name
logger.info('Processing ActivityPub follow in job %s.', job.id) logger.info('Processing ActivityPub follow in job %s.', job.id)
@ -40,18 +39,18 @@ async function processActivityPubFollow (job: Job) {
targetActor = await getOrCreateAPActor(actorUrl, 'all') targetActor = await getOrCreateAPActor(actorUrl, 'all')
} catch (err) { } catch (err) {
logger.warn(`Do not follow ${handle} because we could not find the actor URL (in database or using webfinger)`, { err }) logger.warn(`Do not follow ${identifier} because we could not find the actor URL (in database or using webfinger)`, { err })
return return
} }
} }
if (!targetActor) { if (!targetActor) {
logger.warn(`Do not follow ${handle} because we could not fetch/load the actor`) logger.warn(`Do not follow ${identifier} because we could not fetch/load the actor`)
return return
} }
if (payload.assertIsChannel && !targetActor.VideoChannel) { if (payload.assertIsChannel && !targetActor.VideoChannel) {
logger.warn(`Do not follow ${handle} because it is not a channel.`) logger.warn(`Do not follow ${identifier} because it is not a channel.`)
return return
} }

View file

@ -1,18 +1,20 @@
import { Account, AccountSummary, VideoPrivacy } from '@peertube/peertube-models' import { Account, AccountSummary, ActivityPubActor, VideoPrivacy } from '@peertube/peertube-models'
import { ModelCache } from '@server/models/shared/model-cache.js' import { ModelCache } from '@server/models/shared/model-cache.js'
import { FindOptions, IncludeOptions, Includeable, Op, Transaction, WhereOptions, literal } from 'sequelize' import { FindOptions, IncludeOptions, Includeable, Op, Transaction, WhereOptions, literal } from 'sequelize'
import { import {
AfterDestroy, AfterDestroy,
AllowNull, AllowNull,
BeforeDestroy, BeforeDestroy,
BelongsTo, Column, BelongsTo,
Column,
CreatedAt, CreatedAt,
DataType, DataType,
Default, Default,
DefaultScope, DefaultScope,
ForeignKey, ForeignKey,
HasMany, HasMany,
Is, Scopes, Is,
Scopes,
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
@ -20,12 +22,14 @@ import { isAccountDescriptionValid } from '../../helpers/custom-validators/accou
import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants.js' import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants.js'
import { sendDeleteActor } from '../../lib/activitypub/send/send-delete.js' import { sendDeleteActor } from '../../lib/activitypub/send/send-delete.js'
import { import {
MAccount, MAccountAP, MAccount,
MAccountAP,
MAccountDefault, MAccountDefault,
MAccountFormattable, MAccountFormattable,
MAccountHost, MAccountHost,
MAccountIdHost,
MAccountSummaryFormattable, MAccountSummaryFormattable,
MChannelHost MChannelIdHost
} from '../../types/models/index.js' } from '../../types/models/index.js'
import { ActorFollowModel } from '../actor/actor-follow.js' import { ActorFollowModel } from '../actor/actor-follow.js'
import { ActorImageModel } from '../actor/actor-image.js' import { ActorImageModel } from '../actor/actor-image.js'
@ -145,7 +149,6 @@ export type SummaryOptions = {
] ]
}) })
export class AccountModel extends SequelizeModel<AccountModel> { export class AccountModel extends SequelizeModel<AccountModel> {
@AllowNull(false) @AllowNull(false)
@Column @Column
name: string name: string
@ -423,7 +426,7 @@ export class AccountModel extends SequelizeModel<AccountModel> {
static listLocalsForSitemap (sort: string): Promise<MAccountHost[]> { static listLocalsForSitemap (sort: string): Promise<MAccountHost[]> {
return AccountModel.unscoped().findAll({ return AccountModel.unscoped().findAll({
attributes: [ ], attributes: [],
offset: 0, offset: 0,
order: getSort(sort), order: getSort(sort),
include: [ include: [
@ -474,10 +477,30 @@ export class AccountModel extends SequelizeModel<AccountModel> {
} }
} }
async toActivityPubObject (this: MAccountAP) { async toActivityPubObject (this: MAccountAP): Promise<ActivityPubActor> {
const obj = await this.Actor.toActivityPubObject(this.name) const obj = await this.Actor.toActivityPubObject(this.name)
return Object.assign(obj, { return Object.assign(obj, {
// // TODO: Uncomment in v8 for backward compatibility
// url: [
// {
// type: 'Link',
// mediaType: 'text/html',
// href: this.getClientUrl(true)
// },
// {
// type: 'Link',
// mediaType: 'text/html',
// href: this.getClientUrl(false)
// },
// {
// type: 'Link',
// mediaType: 'text/html',
// href: this.Actor.url
// }
// ] as ActivityUrlObject[],
url: this.Actor.url,
summary: this.description summary: this.description
}) })
} }
@ -495,8 +518,12 @@ export class AccountModel extends SequelizeModel<AccountModel> {
} }
// Avoid error when running this method on MAccount... | MChannel... // Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) { getClientUrl (this: MAccountIdHost | MChannelIdHost, channelsSuffix = true) {
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier() + '/video-channels' const suffix = channelsSuffix
? '/video-channels'
: ''
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier() + suffix
} }
isBlocked () { isBlocked () {

View file

@ -1,5 +1,10 @@
import { forceNumber, maxBy } from '@peertube/peertube-core-utils' import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
import { ActivityIconObject, ActorImageType, ActorImageType_Type, type ActivityPubActorType } from '@peertube/peertube-models' import {
ActivityIconObject,
ActorImageType,
ActorImageType_Type,
type ActivityPubActorType
} from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { activityPubContextify } from '@server/helpers/activity-pub-utils.js' import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
import { getContextFilter } from '@server/lib/activitypub/context.js' import { getContextFilter } from '@server/lib/activitypub/context.js'
@ -15,7 +20,8 @@ import {
ForeignKey, ForeignKey,
HasMany, HasMany,
HasOne, HasOne,
Is, Scopes, Is,
Scopes,
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
@ -31,7 +37,8 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
import { import {
ACTIVITY_PUB, ACTIVITY_PUB,
ACTIVITY_PUB_ACTOR_TYPES, ACTIVITY_PUB_ACTOR_TYPES,
CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, CONSTRAINTS_FIELDS,
SERVER_ACTOR_NAME,
WEBSERVER WEBSERVER
} from '../../initializers/constants.js' } from '../../initializers/constants.js'
import { import {
@ -159,7 +166,6 @@ export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] =
] ]
}) })
export class ActorModel extends SequelizeModel<ActorModel> { export class ActorModel extends SequelizeModel<ActorModel> {
@AllowNull(false) @AllowNull(false)
@Column(DataType.ENUM(...Object.values(ACTIVITY_PUB_ACTOR_TYPES))) @Column(DataType.ENUM(...Object.values(ACTIVITY_PUB_ACTOR_TYPES)))
type: ActivityPubActorType type: ActivityPubActorType
@ -346,10 +352,10 @@ export class ActorModel extends SequelizeModel<ActorModel> {
static loadAccountActorFollowerUrlByVideoId (videoId: number, transaction: Transaction) { static loadAccountActorFollowerUrlByVideoId (videoId: number, transaction: Transaction) {
const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` + const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
`FROM "actor" ` + `FROM "actor" ` +
`INNER JOIN "account" ON "actor"."id" = "account"."actorId" ` + `INNER JOIN "account" ON "actor"."id" = "account"."actorId" ` +
`INNER JOIN "videoChannel" ON "videoChannel"."accountId" = "account"."id" ` + `INNER JOIN "videoChannel" ON "videoChannel"."accountId" = "account"."id" ` +
`INNER JOIN "video" ON "video"."channelId" = "videoChannel"."id" AND "video"."id" = :videoId` `INNER JOIN "video" ON "video"."channelId" = "videoChannel"."id" AND "video"."id" = :videoId`
const options = { const options = {
type: QueryTypes.SELECT as QueryTypes.SELECT, type: QueryTypes.SELECT as QueryTypes.SELECT,
@ -584,7 +590,6 @@ export class ActorModel extends SequelizeModel<ActorModel> {
inbox: this.inboxUrl, inbox: this.inboxUrl,
outbox: this.outboxUrl, outbox: this.outboxUrl,
preferredUsername: this.preferredUsername, preferredUsername: this.preferredUsername,
url: this.url,
name, name,
endpoints: { endpoints: {
sharedInbox: this.sharedInboxUrl sharedInbox: this.sharedInboxUrl

View file

@ -4,7 +4,9 @@ import {
ActivityPubStoryboard, ActivityPubStoryboard,
ActivityTagObject, ActivityTagObject,
ActivityTrackerUrlObject, ActivityTrackerUrlObject,
ActivityUrlObject, VideoCommentPolicy, VideoObject ActivityUrlObject,
VideoCommentPolicy,
VideoObject
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js' import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
import { isArray } from '@server/helpers/custom-validators/misc.js' import { isArray } from '@server/helpers/custom-validators/misc.js'
@ -41,7 +43,13 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
{ {
type: 'Link', type: 'Link',
mediaType: 'text/html', mediaType: 'text/html',
href: WEBSERVER.URL + '/videos/watch/' + video.uuid href: WEBSERVER.URL + video.getWatchStaticPath()
} as ActivityUrlObject,
{
type: 'Link',
mediaType: 'text/html',
href: video.url
} as ActivityUrlObject, } as ActivityUrlObject,
...buildVideoFileUrls({ video, files: video.VideoFiles }), ...buildVideoFileUrls({ video, files: video.VideoFiles }),

View file

@ -2,7 +2,7 @@ import { forceNumber, pick } from '@peertube/peertube-core-utils'
import { ActivityPubActor, VideoChannel, VideoChannelSummary, VideoPrivacy } from '@peertube/peertube-models' import { ActivityPubActor, VideoChannel, VideoChannelSummary, VideoPrivacy } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { MAccountHost } from '@server/types/models/index.js' import { MAccountIdHost } from '@server/types/models/index.js'
import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
import { import {
AfterCreate, AfterCreate,
@ -18,7 +18,8 @@ import {
DefaultScope, DefaultScope,
ForeignKey, ForeignKey,
HasMany, HasMany,
Is, Scopes, Is,
Scopes,
Sequelize, Sequelize,
Table, Table,
UpdatedAt UpdatedAt
@ -36,6 +37,7 @@ import {
MChannelDefault, MChannelDefault,
MChannelFormattable, MChannelFormattable,
MChannelHost, MChannelHost,
MChannelIdHost,
MChannelSummaryFormattable, MChannelSummaryFormattable,
type MChannel type MChannel
} from '../../types/models/video/index.js' } from '../../types/models/video/index.js'
@ -142,7 +144,7 @@ export type SummaryOptions = {
`(` + `(` +
`LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` + `LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` +
`AND "host" = ${sanitizedHost}` + `AND "host" = ${sanitizedHost}` +
`)` `)`
) )
} }
} }
@ -303,33 +305,33 @@ export type SummaryOptions = {
[ [
literal( literal(
'(' + '(' +
`SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` + `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
'FROM ( ' + 'FROM ( ' +
'WITH ' + 'WITH ' +
'days AS ( ' + 'days AS ( ' +
`SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` + `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
`date_trunc('day', now()), '1 day'::interval) AS day ` + `date_trunc('day', now()), '1 day'::interval) AS day ` +
') ' + ') ' +
'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' + 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
'FROM days ' + 'FROM days ' +
'LEFT JOIN (' + 'LEFT JOIN (' +
'"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' + '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
'AND "video"."channelId" = "VideoChannelModel"."id"' + 'AND "video"."channelId" = "VideoChannelModel"."id"' +
`) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` + `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
'GROUP BY day ' + 'GROUP BY day ' +
'ORDER BY day ' + 'ORDER BY day ' +
') t' + ') t' +
')' ')'
), ),
'viewsPerDay' 'viewsPerDay'
], ],
[ [
literal( literal(
'(' + '(' +
'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' + 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
'FROM "video" ' + 'FROM "video" ' +
'WHERE "video"."channelId" = "VideoChannelModel"."id"' + 'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
')' ')'
), ),
'totalViews' 'totalViews'
] ]
@ -352,7 +354,6 @@ export type SummaryOptions = {
] ]
}) })
export class VideoChannelModel extends SequelizeModel<VideoChannelModel> { export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
@AllowNull(false) @AllowNull(false)
@Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name')) @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
@Column @Column
@ -471,7 +472,6 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
} }
static async getStats () { static async getStats () {
function getLocalVideoChannelStats (days?: number) { function getLocalVideoChannelStats (days?: number) {
const options = { const options = {
type: QueryTypes.SELECT as QueryTypes.SELECT, type: QueryTypes.SELECT as QueryTypes.SELECT,
@ -480,7 +480,7 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
const videoJoin = days const videoJoin = days
? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` + ? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` +
`AND ("Videos"."publishedAt" > Now() - interval '${days}d')` `AND ("Videos"."publishedAt" > Now() - interval '${days}d')`
: '' : ''
const query = ` const query = `
@ -492,7 +492,7 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
AND "Account->Actor"."serverId" IS NULL` AND "Account->Actor"."serverId" IS NULL`
return VideoChannelModel.sequelize.query<{ count: string }>(query, options) return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
.then(r => parseInt(r[0].count, 10)) .then(r => parseInt(r[0].count, 10))
} }
const totalLocalVideoChannels = await getLocalVideoChannelStats() const totalLocalVideoChannels = await getLocalVideoChannelStats()
@ -512,7 +512,7 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
static listLocalsForSitemap (sort: string): Promise<MChannelHost[]> { static listLocalsForSitemap (sort: string): Promise<MChannelHost[]> {
const query = { const query = {
attributes: [ ], attributes: [],
offset: 0, offset: 0,
order: getSort(sort), order: getSort(sort),
include: [ include: [
@ -536,11 +536,13 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
.findAll(query) .findAll(query)
} }
static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & { static listForApi (
start: number parameters: Pick<AvailableForListOptions, 'actorId'> & {
count: number start: number
sort: string count: number
}) { sort: string
}
) {
const { actorId } = parameters const { actorId } = parameters
const query = { const query = {
@ -559,11 +561,13 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
]).then(([ total, data ]) => ({ total, data })) ]).then(([ total, data ]) => ({ total, data }))
} }
static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & { static searchForApi (
start: number options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
count: number start: number
sort: string count: number
}) { sort: string
}
) {
let attributesInclude: any[] = [ literal('0 as similarity') ] let attributesInclude: any[] = [ literal('0 as similarity') ]
let where: WhereOptions let where: WhereOptions
@ -597,7 +601,8 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
const getScope = (forCount: boolean) => { const getScope = (forCount: boolean) => {
return { return {
method: [ method: [
ScopeNames.FOR_API, { ScopeNames.FOR_API,
{
...pick(options, [ 'actorId', 'host', 'handles' ]), ...pick(options, [ 'actorId', 'host', 'handles' ]),
forCount forCount
@ -846,6 +851,27 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
return { return {
...obj, ...obj,
// // TODO: Uncomment in v8 for backward compatibility
// url: [
// {
// type: 'Link',
// mediaType: 'text/html',
// href: this.getClientUrl(true)
// },
// {
// type: 'Link',
// mediaType: 'text/html',
// href: this.getClientUrl(false)
// },
// {
// type: 'Link',
// mediaType: 'text/html',
// href: this.Actor.url
// }
// ] as ActivityUrlObject[],
url: this.Actor.url,
summary: this.description, summary: this.description,
support: this.support, support: this.support,
postingRestrictedToMods: true, postingRestrictedToMods: true,
@ -859,8 +885,12 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
} }
// Avoid error when running this method on MAccount... | MChannel... // Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) { getClientUrl (this: MAccountIdHost | MChannelIdHost, videosSuffix = true) {
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier() + '/videos' const suffix = videosSuffix
? '/videos'
: ''
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier() + suffix
} }
getDisplayName () { getDisplayName () {

View file

@ -69,6 +69,10 @@ export type MAccountActor =
& MAccount & MAccount
& Use<'Actor', MActor> & Use<'Actor', MActor>
export type MAccountIdHost =
& MAccountId
& Use<'Actor', MActorHost>
export type MAccountHost = export type MAccountHost =
& MAccount & MAccount
& Use<'Actor', MActorHost> & Use<'Actor', MActorHost>
@ -105,5 +109,5 @@ export type MAccountFormattable =
& Use<'Actor', MActorFormattable> & Use<'Actor', MActorFormattable>
export type MAccountAP = export type MAccountAP =
& Pick<MAccount, 'name' | 'description'> & Pick<MAccount, 'id' | 'name' | 'description' | 'getClientUrl'>
& Use<'Actor', MActorAPAccount> & Use<'Actor', MActorAPAccount>

View file

@ -170,6 +170,7 @@ export type MActorFormattable =
type MActorAPBase = type MActorAPBase =
& MActor & MActor
& MActorHost
& Use<'Avatars', MActorImage[]> & Use<'Avatars', MActorImage[]>
export type MActorAPAccount = MActorAPBase export type MActorAPAccount = MActorAPBase

View file

@ -88,6 +88,10 @@ export type MChannelHost =
& MChannel & MChannel
& Use<'Actor', MActorHost> & Use<'Actor', MActorHost>
export type MChannelIdHost =
& MChannelId
& Use<'Actor', MActorHost>
export type MChannelHostOnly = export type MChannelHostOnly =
& MChannelId & MChannelId
& Use<'Actor', MActorHostOnly> & Use<'Actor', MActorHostOnly>
@ -155,6 +159,6 @@ export type MChannelFormattable =
& PickWithOpt<VideoChannelModel, 'Account', MAccountFormattable> & PickWithOpt<VideoChannelModel, 'Account', MAccountFormattable>
export type MChannelAP = export type MChannelAP =
& Pick<MChannel, 'name' | 'description' | 'support'> & Pick<MChannel, 'id' | 'name' | 'description' | 'support' | 'getClientUrl'>
& Use<'Actor', MActorAPChannel> & Use<'Actor', MActorAPChannel>
& Use<'Account', MAccountUrl> & Use<'Account', MAccountUrl>