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

View File

@ -50,7 +50,7 @@
"SwitchCase": 1,
"MemberExpression": "off",
// https://github.com/eslint/eslint/issues/15299
"ignoredNodes": ["PropertyDefinition", "TSTypeParameterInstantiation"]
"ignoredNodes": ["PropertyDefinition", "TSTypeParameterInstantiation", "TSConditionalType *"]
}
],
"@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'
@ -12,7 +12,7 @@ export interface ActivityPubActor {
inbox: string
outbox: string
preferredUsername: string
url: string
url: ActivityUrlObject[] | string
name: string
endpoints: {
sharedInbox: string

View File

@ -1,8 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { processViewersStats } from '@tests/shared/views.js'
import { HttpStatusCode, VideoComment, VideoPlaylistPrivacy, WatchActionObject } from '@peertube/peertube-models'
import {
ActivityPubActor,
HttpStatusCode,
VideoComment,
VideoObject,
VideoPlaylistPrivacy,
WatchActionObject
} from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
@ -12,8 +17,10 @@ import {
setAccessTokensToServers,
setDefaultVideoChannel
} 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 video: { 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) {
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.id).to.equal(servers[0].url + '/accounts/root')
expect(object.name).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) {
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.id).to.equal(servers[0].url + '/video-channels/root_channel')
expect(object.name).to.equal('Main 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) {
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.id).to.equal(servers[0].url + '/videos/watch/' + video.uuid)
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) {

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 { ActivityPubActor } from 'packages/models/src/activitypub/activitypub-actor.js'
import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { exists, isArray, isDateValid } from '../misc.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) {
if (endpointObject?.sharedInbox) {
@ -62,7 +63,7 @@ export function isActorDeleteActivityValid (activity: any) {
return isBaseActivityValid(activity, 'Delete')
}
export function sanitizeAndCheckActorObject (actor: any) {
export function sanitizeAndCheckActorObject (actor: ActivityPubActor) {
if (!isActorTypeValid(actor.type)) return false
normalizeActor(actor)
@ -71,43 +72,16 @@ export function sanitizeAndCheckActorObject (actor: any) {
isActivityPubUrlValid(actor.id) &&
isActivityPubUrlValid(actor.inbox) &&
isActorPreferredUsernameValid(actor.preferredUsername) &&
isActivityPubUrlValid(actor.url) &&
isActorPublicKeyObjectValid(actor.publicKey) &&
isActorEndpointsObjectValid(actor.endpoints) &&
(!actor.outbox || isActivityPubUrlValid(actor.outbox)) &&
(!actor.following || isActivityPubUrlValid(actor.following)) &&
(!actor.followers || isActivityPubUrlValid(actor.followers)) &&
setValidAttributedTo(actor) &&
setValidDescription(actor) &&
// 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
(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) {
if (!exists(handle)) return false
@ -121,8 +95,38 @@ export function areValidActorHandles (handles: string[]) {
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 { ActivityHtmlUrlObject } from 'packages/models/src/activitypub/index.js'
import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { exists } from '../misc.js'
function isUrlValid (url: string) {
export function isUrlValid (url: string) {
const isURLOptions = {
require_host: true,
require_tld: true,
@ -20,11 +21,11 @@ function isUrlValid (url: string) {
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)
}
function isBaseActivityValid (activity: any, type: string) {
export function isBaseActivityValid (activity: any, type: string) {
return activity.type === type &&
isActivityPubUrlValid(activity.id) &&
isObjectValid(activity.actor) &&
@ -32,19 +33,26 @@ function isBaseActivityValid (activity: any, type: string) {
isUrlCollectionValid(activity.cc)
}
function isUrlCollectionValid (collection: any) {
export function isUrlCollectionValid (collection: any) {
return collection === undefined ||
(Array.isArray(collection) && collection.every(t => isActivityPubUrlValid(t)))
}
function isObjectValid (object: any) {
export function isObjectValid (object: any) {
return exists(object) &&
(
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) {
obj.attributedTo = []
return true
@ -58,19 +66,10 @@ function setValidAttributedTo (obj: any) {
return true
}
function isActivityPubVideoDurationValid (value: string) {
export function isActivityPubVideoDurationValid (value: string) {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return exists(value) &&
typeof value === 'string' &&
value.startsWith('PT') &&
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
if (!actor) {
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
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
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 schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
@ -75,10 +75,10 @@ async function getOrCreateAPActor (
return actorRefreshed
}
async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) {
const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person')
async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorId: string) {
const accountAttributedTo = await findOwner(actorId, actorObject.attributedTo, 'Person')
if (!accountAttributedTo) {
throw new Error(`Cannot find account attributed to video channel ${actorUrl}`)
throw new Error(`Cannot find account attributed to video channel ${actorId}`)
}
try {
@ -86,22 +86,22 @@ async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: stri
const recurseIfNeeded = false
return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
} 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)
}
}
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)) {
const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck))
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
}
if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) {
logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`)
if (checkUrlsSameHost(actorObject.id, rootId) !== true) {
logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootId}`)
continue
}

View File

@ -1,16 +1,15 @@
import { ActivityPubActor, ActorImageType } from '@peertube/peertube-models'
import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models/index.js'
import { 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 (
@ -32,7 +31,7 @@ export class APActorUpdater {
this.accountOrChannel.description = this.actorObject.summary
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.Account = owner.Account as AccountModel
@ -49,7 +48,8 @@ export class APActorUpdater {
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) {
if (this.actor !== undefined) {
await resetSequelizeInstance(this.actor)

View File

@ -113,7 +113,7 @@ async function processUpdateCacheFile (
}
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)
return updater.update()

View File

@ -18,9 +18,8 @@ async function processActivityPubFollow (job: Job) {
const payload = job.data as ActivitypubFollowPayload
const host = payload.host
const handle = host
? `${payload.name}@${host}`
: payload.name
const identifier = [ payload.name || SERVER_ACTOR_NAME, host ]
.filter(v => !!v).join('@')
logger.info('Processing ActivityPub follow in job %s.', job.id)
@ -40,18 +39,18 @@ async function processActivityPubFollow (job: Job) {
targetActor = await getOrCreateAPActor(actorUrl, 'all')
} 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
}
}
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
}
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
}

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 { FindOptions, IncludeOptions, Includeable, Op, Transaction, WhereOptions, literal } from 'sequelize'
import {
AfterDestroy,
AllowNull,
BeforeDestroy,
BelongsTo, Column,
BelongsTo,
Column,
CreatedAt,
DataType,
Default,
DefaultScope,
ForeignKey,
HasMany,
Is, Scopes,
Is,
Scopes,
Table,
UpdatedAt
} 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 { sendDeleteActor } from '../../lib/activitypub/send/send-delete.js'
import {
MAccount, MAccountAP,
MAccount,
MAccountAP,
MAccountDefault,
MAccountFormattable,
MAccountHost,
MAccountIdHost,
MAccountSummaryFormattable,
MChannelHost
MChannelIdHost
} from '../../types/models/index.js'
import { ActorFollowModel } from '../actor/actor-follow.js'
import { ActorImageModel } from '../actor/actor-image.js'
@ -145,7 +149,6 @@ export type SummaryOptions = {
]
})
export class AccountModel extends SequelizeModel<AccountModel> {
@AllowNull(false)
@Column
name: string
@ -423,7 +426,7 @@ export class AccountModel extends SequelizeModel<AccountModel> {
static listLocalsForSitemap (sort: string): Promise<MAccountHost[]> {
return AccountModel.unscoped().findAll({
attributes: [ ],
attributes: [],
offset: 0,
order: getSort(sort),
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)
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
})
}
@ -495,8 +518,12 @@ export class AccountModel extends SequelizeModel<AccountModel> {
}
// Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) {
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier() + '/video-channels'
getClientUrl (this: MAccountIdHost | MChannelIdHost, channelsSuffix = true) {
const suffix = channelsSuffix
? '/video-channels'
: ''
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier() + suffix
}
isBlocked () {

View File

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

View File

@ -4,7 +4,9 @@ import {
ActivityPubStoryboard,
ActivityTagObject,
ActivityTrackerUrlObject,
ActivityUrlObject, VideoCommentPolicy, VideoObject
ActivityUrlObject,
VideoCommentPolicy,
VideoObject
} from '@peertube/peertube-models'
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
import { isArray } from '@server/helpers/custom-validators/misc.js'
@ -41,7 +43,13 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
{
type: 'Link',
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,
...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 { CONFIG } from '@server/initializers/config.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 {
AfterCreate,
@ -18,7 +18,8 @@ import {
DefaultScope,
ForeignKey,
HasMany,
Is, Scopes,
Is,
Scopes,
Sequelize,
Table,
UpdatedAt
@ -36,6 +37,7 @@ import {
MChannelDefault,
MChannelFormattable,
MChannelHost,
MChannelIdHost,
MChannelSummaryFormattable,
type MChannel
} from '../../types/models/video/index.js'
@ -142,7 +144,7 @@ export type SummaryOptions = {
`(` +
`LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` +
`AND "host" = ${sanitizedHost}` +
`)`
`)`
)
}
}
@ -303,33 +305,33 @@ export type SummaryOptions = {
[
literal(
'(' +
`SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
'FROM ( ' +
`SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
'FROM ( ' +
'WITH ' +
'days AS ( ' +
`SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
`date_trunc('day', now()), '1 day'::interval) AS day ` +
') ' +
'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
'FROM days ' +
'LEFT JOIN (' +
'"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
'AND "video"."channelId" = "VideoChannelModel"."id"' +
`) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
'GROUP BY day ' +
'ORDER BY day ' +
'days AS ( ' +
`SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
`date_trunc('day', now()), '1 day'::interval) AS day ` +
') ' +
'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
'FROM days ' +
'LEFT JOIN (' +
'"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
'AND "video"."channelId" = "VideoChannelModel"."id"' +
`) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
'GROUP BY day ' +
'ORDER BY day ' +
') t' +
')'
')'
),
'viewsPerDay'
],
[
literal(
'(' +
'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
'FROM "video" ' +
'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
')'
'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
'FROM "video" ' +
'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
')'
),
'totalViews'
]
@ -352,7 +354,6 @@ export type SummaryOptions = {
]
})
export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
@AllowNull(false)
@Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
@Column
@ -471,7 +472,6 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
}
static async getStats () {
function getLocalVideoChannelStats (days?: number) {
const options = {
type: QueryTypes.SELECT as QueryTypes.SELECT,
@ -480,7 +480,7 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
const videoJoin = days
? `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 = `
@ -492,7 +492,7 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
AND "Account->Actor"."serverId" IS NULL`
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()
@ -512,7 +512,7 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
static listLocalsForSitemap (sort: string): Promise<MChannelHost[]> {
const query = {
attributes: [ ],
attributes: [],
offset: 0,
order: getSort(sort),
include: [
@ -536,11 +536,13 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
.findAll(query)
}
static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
start: number
count: number
sort: string
}) {
static listForApi (
parameters: Pick<AvailableForListOptions, 'actorId'> & {
start: number
count: number
sort: string
}
) {
const { actorId } = parameters
const query = {
@ -559,11 +561,13 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
]).then(([ total, data ]) => ({ total, data }))
}
static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
start: number
count: number
sort: string
}) {
static searchForApi (
options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
start: number
count: number
sort: string
}
) {
let attributesInclude: any[] = [ literal('0 as similarity') ]
let where: WhereOptions
@ -597,7 +601,8 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
const getScope = (forCount: boolean) => {
return {
method: [
ScopeNames.FOR_API, {
ScopeNames.FOR_API,
{
...pick(options, [ 'actorId', 'host', 'handles' ]),
forCount
@ -846,6 +851,27 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
return {
...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,
support: this.support,
postingRestrictedToMods: true,
@ -859,8 +885,12 @@ export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
}
// Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) {
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier() + '/videos'
getClientUrl (this: MAccountIdHost | MChannelIdHost, videosSuffix = true) {
const suffix = videosSuffix
? '/videos'
: ''
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier() + suffix
}
getDisplayName () {

View File

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

View File

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

View File

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