diff options
Diffstat (limited to 'server/lib/activitypub')
75 files changed, 0 insertions, 6318 deletions
diff --git a/server/lib/activitypub/activity.ts b/server/lib/activitypub/activity.ts deleted file mode 100644 index 391bcd9c6..000000000 --- a/server/lib/activitypub/activity.ts +++ /dev/null | |||
@@ -1,74 +0,0 @@ | |||
1 | import { doJSONRequest, PeerTubeRequestOptions } from '@server/helpers/requests' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { ActivityObject, ActivityPubActor, ActivityType, APObjectId } from '@shared/models' | ||
4 | import { buildSignedRequestOptions } from './send' | ||
5 | |||
6 | export function getAPId (object: string | { id: string }) { | ||
7 | if (typeof object === 'string') return object | ||
8 | |||
9 | return object.id | ||
10 | } | ||
11 | |||
12 | export function getActivityStreamDuration (duration: number) { | ||
13 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration | ||
14 | return 'PT' + duration + 'S' | ||
15 | } | ||
16 | |||
17 | export function getDurationFromActivityStream (duration: string) { | ||
18 | return parseInt(duration.replace(/[^\d]+/, '')) | ||
19 | } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | export function buildAvailableActivities (): ActivityType[] { | ||
24 | return [ | ||
25 | 'Create', | ||
26 | 'Update', | ||
27 | 'Delete', | ||
28 | 'Follow', | ||
29 | 'Accept', | ||
30 | 'Announce', | ||
31 | 'Undo', | ||
32 | 'Like', | ||
33 | 'Reject', | ||
34 | 'View', | ||
35 | 'Dislike', | ||
36 | 'Flag' | ||
37 | ] | ||
38 | } | ||
39 | |||
40 | // --------------------------------------------------------------------------- | ||
41 | |||
42 | export async function fetchAP <T> (url: string, moreOptions: PeerTubeRequestOptions = {}) { | ||
43 | const options = { | ||
44 | activityPub: true, | ||
45 | |||
46 | httpSignature: CONFIG.FEDERATION.SIGN_FEDERATED_FETCHES | ||
47 | ? await buildSignedRequestOptions({ hasPayload: false }) | ||
48 | : undefined, | ||
49 | |||
50 | ...moreOptions | ||
51 | } | ||
52 | |||
53 | return doJSONRequest<T>(url, options) | ||
54 | } | ||
55 | |||
56 | export async function fetchAPObjectIfNeeded <T extends (ActivityObject | ActivityPubActor)> (object: APObjectId) { | ||
57 | if (typeof object === 'string') { | ||
58 | const { body } = await fetchAP<Exclude<T, string>>(object) | ||
59 | |||
60 | return body | ||
61 | } | ||
62 | |||
63 | return object as Exclude<T, string> | ||
64 | } | ||
65 | |||
66 | export async function findLatestAPRedirection (url: string, iteration = 1) { | ||
67 | if (iteration > 10) throw new Error('Too much iterations to find final URL ' + url) | ||
68 | |||
69 | const { headers } = await fetchAP(url, { followRedirect: false }) | ||
70 | |||
71 | if (headers.location) return findLatestAPRedirection(headers.location, iteration + 1) | ||
72 | |||
73 | return url | ||
74 | } | ||
diff --git a/server/lib/activitypub/actors/get.ts b/server/lib/activitypub/actors/get.ts deleted file mode 100644 index dd2bc9f03..000000000 --- a/server/lib/activitypub/actors/get.ts +++ /dev/null | |||
@@ -1,143 +0,0 @@ | |||
1 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { JobQueue } from '@server/lib/job-queue' | ||
4 | import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders' | ||
5 | import { MActor, MActorAccountChannelId, MActorAccountChannelIdActor, MActorAccountId, MActorFullActor } from '@server/types/models' | ||
6 | import { arrayify } from '@shared/core-utils' | ||
7 | import { ActivityPubActor, APObjectId } from '@shared/models' | ||
8 | import { fetchAPObjectIfNeeded, getAPId } from '../activity' | ||
9 | import { checkUrlsSameHost } from '../url' | ||
10 | import { refreshActorIfNeeded } from './refresh' | ||
11 | import { APActorCreator, fetchRemoteActor } from './shared' | ||
12 | |||
13 | function getOrCreateAPActor ( | ||
14 | activityActor: string | ActivityPubActor, | ||
15 | fetchType: 'all', | ||
16 | recurseIfNeeded?: boolean, | ||
17 | updateCollections?: boolean | ||
18 | ): Promise<MActorFullActor> | ||
19 | |||
20 | function getOrCreateAPActor ( | ||
21 | activityActor: string | ActivityPubActor, | ||
22 | fetchType?: 'association-ids', | ||
23 | recurseIfNeeded?: boolean, | ||
24 | updateCollections?: boolean | ||
25 | ): Promise<MActorAccountChannelId> | ||
26 | |||
27 | async function getOrCreateAPActor ( | ||
28 | activityActor: string | ActivityPubActor, | ||
29 | fetchType: ActorLoadByUrlType = 'association-ids', | ||
30 | recurseIfNeeded = true, | ||
31 | updateCollections = false | ||
32 | ): Promise<MActorFullActor | MActorAccountChannelId> { | ||
33 | const actorUrl = getAPId(activityActor) | ||
34 | let actor = await loadActorFromDB(actorUrl, fetchType) | ||
35 | |||
36 | let created = false | ||
37 | let accountPlaylistsUrl: string | ||
38 | |||
39 | // We don't have this actor in our database, fetch it on remote | ||
40 | if (!actor) { | ||
41 | const { actorObject } = await fetchRemoteActor(actorUrl) | ||
42 | if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl) | ||
43 | |||
44 | // actorUrl is just an alias/redirection, so process object id instead | ||
45 | if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections) | ||
46 | |||
47 | // Create the attributed to actor | ||
48 | // In PeerTube a video channel is owned by an account | ||
49 | let ownerActor: MActorFullActor | ||
50 | if (recurseIfNeeded === true && actorObject.type === 'Group') { | ||
51 | ownerActor = await getOrCreateAPOwner(actorObject, actorUrl) | ||
52 | } | ||
53 | |||
54 | const creator = new APActorCreator(actorObject, ownerActor) | ||
55 | actor = await retryTransactionWrapper(creator.create.bind(creator)) | ||
56 | created = true | ||
57 | accountPlaylistsUrl = actorObject.playlists | ||
58 | } | ||
59 | |||
60 | if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor | ||
61 | if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor | ||
62 | |||
63 | const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType }) | ||
64 | if (!actorRefreshed) throw new Error('Actor ' + actor.url + ' does not exist anymore.') | ||
65 | |||
66 | await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections) | ||
67 | await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl) | ||
68 | |||
69 | return actorRefreshed | ||
70 | } | ||
71 | |||
72 | async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) { | ||
73 | const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person') | ||
74 | if (!accountAttributedTo) { | ||
75 | throw new Error(`Cannot find account attributed to video channel ${actorUrl}`) | ||
76 | } | ||
77 | |||
78 | try { | ||
79 | // Don't recurse another time | ||
80 | const recurseIfNeeded = false | ||
81 | return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded) | ||
82 | } catch (err) { | ||
83 | logger.error('Cannot get or create account attributed to video channel ' + actorUrl) | ||
84 | throw new Error(err) | ||
85 | } | ||
86 | } | ||
87 | |||
88 | async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') { | ||
89 | for (const actorToCheck of arrayify(attributedTo)) { | ||
90 | const actorObject = await fetchAPObjectIfNeeded<ActivityPubActor>(getAPId(actorToCheck)) | ||
91 | |||
92 | if (!actorObject) { | ||
93 | logger.warn('Unknown attributed to actor %s for owner %s', actorToCheck, rootUrl) | ||
94 | continue | ||
95 | } | ||
96 | |||
97 | if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) { | ||
98 | logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`) | ||
99 | continue | ||
100 | } | ||
101 | |||
102 | if (actorObject.type === type) return actorObject | ||
103 | } | ||
104 | |||
105 | return undefined | ||
106 | } | ||
107 | |||
108 | // --------------------------------------------------------------------------- | ||
109 | |||
110 | export { | ||
111 | getOrCreateAPOwner, | ||
112 | getOrCreateAPActor, | ||
113 | findOwner | ||
114 | } | ||
115 | |||
116 | // --------------------------------------------------------------------------- | ||
117 | |||
118 | async function loadActorFromDB (actorUrl: string, fetchType: ActorLoadByUrlType) { | ||
119 | let actor = await loadActorByUrl(actorUrl, fetchType) | ||
120 | |||
121 | // Orphan actor (not associated to an account of channel) so recreate it | ||
122 | if (actor && (!actor.Account && !actor.VideoChannel)) { | ||
123 | await actor.destroy() | ||
124 | actor = null | ||
125 | } | ||
126 | |||
127 | return actor | ||
128 | } | ||
129 | |||
130 | async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, refreshed: boolean, updateCollections: boolean) { | ||
131 | if ((created === true || refreshed === true) && updateCollections === true) { | ||
132 | const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' } | ||
133 | await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) | ||
134 | } | ||
135 | } | ||
136 | |||
137 | async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) { | ||
138 | // We created a new account: fetch the playlists | ||
139 | if (created === true && actor.Account && accountPlaylistsUrl) { | ||
140 | const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' } | ||
141 | await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) | ||
142 | } | ||
143 | } | ||
diff --git a/server/lib/activitypub/actors/image.ts b/server/lib/activitypub/actors/image.ts deleted file mode 100644 index e1d29af5b..000000000 --- a/server/lib/activitypub/actors/image.ts +++ /dev/null | |||
@@ -1,112 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize/types' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { ActorImageModel } from '@server/models/actor/actor-image' | ||
4 | import { MActorImage, MActorImages } from '@server/types/models' | ||
5 | import { ActorImageType } from '@shared/models' | ||
6 | |||
7 | type ImageInfo = { | ||
8 | name: string | ||
9 | fileUrl: string | ||
10 | height: number | ||
11 | width: number | ||
12 | onDisk?: boolean | ||
13 | } | ||
14 | |||
15 | async function updateActorImages (actor: MActorImages, type: ActorImageType, imagesInfo: ImageInfo[], t: Transaction) { | ||
16 | const getAvatarsOrBanners = () => { | ||
17 | const result = type === ActorImageType.AVATAR | ||
18 | ? actor.Avatars | ||
19 | : actor.Banners | ||
20 | |||
21 | return result || [] | ||
22 | } | ||
23 | |||
24 | if (imagesInfo.length === 0) { | ||
25 | await deleteActorImages(actor, type, t) | ||
26 | } | ||
27 | |||
28 | // Cleanup old images that did not have a width | ||
29 | for (const oldImageModel of getAvatarsOrBanners()) { | ||
30 | if (oldImageModel.width) continue | ||
31 | |||
32 | await safeDeleteActorImage(actor, oldImageModel, type, t) | ||
33 | } | ||
34 | |||
35 | for (const imageInfo of imagesInfo) { | ||
36 | const oldImageModel = getAvatarsOrBanners().find(i => imageInfo.width && i.width === imageInfo.width) | ||
37 | |||
38 | if (oldImageModel) { | ||
39 | // Don't update the avatar if the file URL did not change | ||
40 | if (imageInfo.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) { | ||
41 | continue | ||
42 | } | ||
43 | |||
44 | await safeDeleteActorImage(actor, oldImageModel, type, t) | ||
45 | } | ||
46 | |||
47 | const imageModel = await ActorImageModel.create({ | ||
48 | filename: imageInfo.name, | ||
49 | onDisk: imageInfo.onDisk ?? false, | ||
50 | fileUrl: imageInfo.fileUrl, | ||
51 | height: imageInfo.height, | ||
52 | width: imageInfo.width, | ||
53 | type, | ||
54 | actorId: actor.id | ||
55 | }, { transaction: t }) | ||
56 | |||
57 | addActorImage(actor, type, imageModel) | ||
58 | } | ||
59 | |||
60 | return actor | ||
61 | } | ||
62 | |||
63 | async function deleteActorImages (actor: MActorImages, type: ActorImageType, t: Transaction) { | ||
64 | try { | ||
65 | const association = buildAssociationName(type) | ||
66 | |||
67 | for (const image of actor[association]) { | ||
68 | await image.destroy({ transaction: t }) | ||
69 | } | ||
70 | |||
71 | actor[association] = [] | ||
72 | } catch (err) { | ||
73 | logger.error('Cannot remove old image of actor %s.', actor.url, { err }) | ||
74 | } | ||
75 | |||
76 | return actor | ||
77 | } | ||
78 | |||
79 | async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType, t: Transaction) { | ||
80 | try { | ||
81 | await toDelete.destroy({ transaction: t }) | ||
82 | |||
83 | const association = buildAssociationName(type) | ||
84 | actor[association] = actor[association].filter(image => image.id !== toDelete.id) | ||
85 | } catch (err) { | ||
86 | logger.error('Cannot remove old actor image of actor %s.', actor.url, { err }) | ||
87 | } | ||
88 | } | ||
89 | |||
90 | // --------------------------------------------------------------------------- | ||
91 | |||
92 | export { | ||
93 | ImageInfo, | ||
94 | |||
95 | updateActorImages, | ||
96 | deleteActorImages | ||
97 | } | ||
98 | |||
99 | // --------------------------------------------------------------------------- | ||
100 | |||
101 | function addActorImage (actor: MActorImages, type: ActorImageType, imageModel: MActorImage) { | ||
102 | const association = buildAssociationName(type) | ||
103 | if (!actor[association]) actor[association] = [] | ||
104 | |||
105 | actor[association].push(imageModel) | ||
106 | } | ||
107 | |||
108 | function buildAssociationName (type: ActorImageType) { | ||
109 | return type === ActorImageType.AVATAR | ||
110 | ? 'Avatars' | ||
111 | : 'Banners' | ||
112 | } | ||
diff --git a/server/lib/activitypub/actors/index.ts b/server/lib/activitypub/actors/index.ts deleted file mode 100644 index 5ee2a6f1a..000000000 --- a/server/lib/activitypub/actors/index.ts +++ /dev/null | |||
@@ -1,6 +0,0 @@ | |||
1 | export * from './get' | ||
2 | export * from './image' | ||
3 | export * from './keys' | ||
4 | export * from './refresh' | ||
5 | export * from './updater' | ||
6 | export * from './webfinger' | ||
diff --git a/server/lib/activitypub/actors/keys.ts b/server/lib/activitypub/actors/keys.ts deleted file mode 100644 index c3d18abd8..000000000 --- a/server/lib/activitypub/actors/keys.ts +++ /dev/null | |||
@@ -1,16 +0,0 @@ | |||
1 | import { createPrivateAndPublicKeys } from '@server/helpers/peertube-crypto' | ||
2 | import { MActor } from '@server/types/models' | ||
3 | |||
4 | // Set account keys, this could be long so process after the account creation and do not block the client | ||
5 | async function generateAndSaveActorKeys <T extends MActor> (actor: T) { | ||
6 | const { publicKey, privateKey } = await createPrivateAndPublicKeys() | ||
7 | |||
8 | actor.publicKey = publicKey | ||
9 | actor.privateKey = privateKey | ||
10 | |||
11 | return actor.save() | ||
12 | } | ||
13 | |||
14 | export { | ||
15 | generateAndSaveActorKeys | ||
16 | } | ||
diff --git a/server/lib/activitypub/actors/refresh.ts b/server/lib/activitypub/actors/refresh.ts deleted file mode 100644 index d15cb5e90..000000000 --- a/server/lib/activitypub/actors/refresh.ts +++ /dev/null | |||
@@ -1,81 +0,0 @@ | |||
1 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
2 | import { CachePromiseFactory } from '@server/helpers/promise-cache' | ||
3 | import { PeerTubeRequestError } from '@server/helpers/requests' | ||
4 | import { ActorLoadByUrlType } from '@server/lib/model-loaders' | ||
5 | import { ActorModel } from '@server/models/actor/actor' | ||
6 | import { MActorAccountChannelId, MActorFull } from '@server/types/models' | ||
7 | import { HttpStatusCode } from '@shared/models' | ||
8 | import { fetchRemoteActor } from './shared' | ||
9 | import { APActorUpdater } from './updater' | ||
10 | import { getUrlFromWebfinger } from './webfinger' | ||
11 | |||
12 | type RefreshResult <T> = Promise<{ actor: T | MActorFull, refreshed: boolean }> | ||
13 | |||
14 | type RefreshOptions <T> = { | ||
15 | actor: T | ||
16 | fetchedType: ActorLoadByUrlType | ||
17 | } | ||
18 | |||
19 | const promiseCache = new CachePromiseFactory(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url) | ||
20 | |||
21 | function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <T> { | ||
22 | const actorArg = options.actor | ||
23 | if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false }) | ||
24 | |||
25 | return promiseCache.run(options) | ||
26 | } | ||
27 | |||
28 | export { | ||
29 | refreshActorIfNeeded | ||
30 | } | ||
31 | |||
32 | // --------------------------------------------------------------------------- | ||
33 | |||
34 | async function doRefresh <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <MActorFull> { | ||
35 | const { actor: actorArg, fetchedType } = options | ||
36 | |||
37 | // We need more attributes | ||
38 | const actor = fetchedType === 'all' | ||
39 | ? actorArg as MActorFull | ||
40 | : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url) | ||
41 | |||
42 | const lTags = loggerTagsFactory('ap', 'actor', 'refresh', actor.url) | ||
43 | |||
44 | logger.info('Refreshing actor %s.', actor.url, lTags()) | ||
45 | |||
46 | try { | ||
47 | const actorUrl = await getActorUrl(actor) | ||
48 | const { actorObject } = await fetchRemoteActor(actorUrl) | ||
49 | |||
50 | if (actorObject === undefined) { | ||
51 | logger.info('Cannot fetch remote actor %s in refresh actor.', actorUrl) | ||
52 | return { actor, refreshed: false } | ||
53 | } | ||
54 | |||
55 | const updater = new APActorUpdater(actorObject, actor) | ||
56 | await updater.update() | ||
57 | |||
58 | return { refreshed: true, actor } | ||
59 | } catch (err) { | ||
60 | if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) { | ||
61 | logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url, lTags()) | ||
62 | |||
63 | actor.Account | ||
64 | ? await actor.Account.destroy() | ||
65 | : await actor.VideoChannel.destroy() | ||
66 | |||
67 | return { actor: undefined, refreshed: false } | ||
68 | } | ||
69 | |||
70 | logger.info('Cannot refresh actor %s.', actor.url, { err, ...lTags() }) | ||
71 | return { actor, refreshed: false } | ||
72 | } | ||
73 | } | ||
74 | |||
75 | function getActorUrl (actor: MActorFull) { | ||
76 | return getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost()) | ||
77 | .catch(err => { | ||
78 | logger.warn('Cannot get actor URL from webfinger, keeping the old one.', { err }) | ||
79 | return actor.url | ||
80 | }) | ||
81 | } | ||
diff --git a/server/lib/activitypub/actors/shared/creator.ts b/server/lib/activitypub/actors/shared/creator.ts deleted file mode 100644 index 500bc9912..000000000 --- a/server/lib/activitypub/actors/shared/creator.ts +++ /dev/null | |||
@@ -1,149 +0,0 @@ | |||
1 | import { Op, Transaction } from 'sequelize' | ||
2 | import { sequelizeTypescript } from '@server/initializers/database' | ||
3 | import { AccountModel } from '@server/models/account/account' | ||
4 | import { ActorModel } from '@server/models/actor/actor' | ||
5 | import { ServerModel } from '@server/models/server/server' | ||
6 | import { VideoChannelModel } from '@server/models/video/video-channel' | ||
7 | import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models' | ||
8 | import { ActivityPubActor, ActorImageType } from '@shared/models' | ||
9 | import { updateActorImages } from '../image' | ||
10 | import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes' | ||
11 | import { fetchActorFollowsCount } from './url-to-object' | ||
12 | |||
13 | export class APActorCreator { | ||
14 | |||
15 | constructor ( | ||
16 | private readonly actorObject: ActivityPubActor, | ||
17 | private readonly ownerActor?: MActorFullActor | ||
18 | ) { | ||
19 | |||
20 | } | ||
21 | |||
22 | async create (): Promise<MActorFullActor> { | ||
23 | const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject) | ||
24 | |||
25 | const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount)) | ||
26 | |||
27 | return sequelizeTypescript.transaction(async t => { | ||
28 | const server = await this.setServer(actorInstance, t) | ||
29 | |||
30 | const { actorCreated, created } = await this.saveActor(actorInstance, t) | ||
31 | |||
32 | await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t) | ||
33 | await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t) | ||
34 | |||
35 | await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t) | ||
36 | |||
37 | if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance | ||
38 | actorCreated.Account = await this.saveAccount(actorCreated, t) as MAccountDefault | ||
39 | actorCreated.Account.Actor = actorCreated | ||
40 | } | ||
41 | |||
42 | if (actorCreated.type === 'Group') { // Video channel | ||
43 | const channel = await this.saveVideoChannel(actorCreated, t) | ||
44 | actorCreated.VideoChannel = Object.assign(channel, { Actor: actorCreated, Account: this.ownerActor.Account }) | ||
45 | } | ||
46 | |||
47 | actorCreated.Server = server | ||
48 | |||
49 | return actorCreated | ||
50 | }) | ||
51 | } | ||
52 | |||
53 | private async setServer (actor: MActor, t: Transaction) { | ||
54 | const actorHost = new URL(actor.url).host | ||
55 | |||
56 | const serverOptions = { | ||
57 | where: { | ||
58 | host: actorHost | ||
59 | }, | ||
60 | defaults: { | ||
61 | host: actorHost | ||
62 | }, | ||
63 | transaction: t | ||
64 | } | ||
65 | const [ server ] = await ServerModel.findOrCreate(serverOptions) | ||
66 | |||
67 | // Save our new account in database | ||
68 | actor.serverId = server.id | ||
69 | |||
70 | return server as MServer | ||
71 | } | ||
72 | |||
73 | private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) { | ||
74 | const imagesInfo = getImagesInfoFromObject(this.actorObject, type) | ||
75 | if (imagesInfo.length === 0) return | ||
76 | |||
77 | return updateActorImages(actor as MActorImages, type, imagesInfo, t) | ||
78 | } | ||
79 | |||
80 | private async saveActor (actor: MActor, t: Transaction) { | ||
81 | // Force the actor creation using findOrCreate() instead of save() | ||
82 | // Sometimes Sequelize skips the save() when it thinks the instance already exists | ||
83 | // (which could be false in a retried query) | ||
84 | const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({ | ||
85 | defaults: actor.toJSON(), | ||
86 | where: { | ||
87 | [Op.or]: [ | ||
88 | { | ||
89 | url: actor.url | ||
90 | }, | ||
91 | { | ||
92 | serverId: actor.serverId, | ||
93 | preferredUsername: actor.preferredUsername | ||
94 | } | ||
95 | ] | ||
96 | }, | ||
97 | transaction: t | ||
98 | }) | ||
99 | |||
100 | return { actorCreated, created } | ||
101 | } | ||
102 | |||
103 | private async tryToFixActorUrlIfNeeded (actorCreated: MActor, newActor: MActor, created: boolean, t: Transaction) { | ||
104 | // Try to fix non HTTPS accounts of remote instances that fixed their URL afterwards | ||
105 | if (created !== true && actorCreated.url !== newActor.url) { | ||
106 | // Only fix http://example.com/account/djidane to https://example.com/account/djidane | ||
107 | if (actorCreated.url.replace(/^http:\/\//, '') !== newActor.url.replace(/^https:\/\//, '')) { | ||
108 | throw new Error(`Actor from DB with URL ${actorCreated.url} does not correspond to actor ${newActor.url}`) | ||
109 | } | ||
110 | |||
111 | actorCreated.url = newActor.url | ||
112 | await actorCreated.save({ transaction: t }) | ||
113 | } | ||
114 | } | ||
115 | |||
116 | private async saveAccount (actor: MActorId, t: Transaction) { | ||
117 | const [ accountCreated ] = await AccountModel.findOrCreate({ | ||
118 | defaults: { | ||
119 | name: getActorDisplayNameFromObject(this.actorObject), | ||
120 | description: this.actorObject.summary, | ||
121 | actorId: actor.id | ||
122 | }, | ||
123 | where: { | ||
124 | actorId: actor.id | ||
125 | }, | ||
126 | transaction: t | ||
127 | }) | ||
128 | |||
129 | return accountCreated as MAccount | ||
130 | } | ||
131 | |||
132 | private async saveVideoChannel (actor: MActorId, t: Transaction) { | ||
133 | const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({ | ||
134 | defaults: { | ||
135 | name: getActorDisplayNameFromObject(this.actorObject), | ||
136 | description: this.actorObject.summary, | ||
137 | support: this.actorObject.support, | ||
138 | actorId: actor.id, | ||
139 | accountId: this.ownerActor.Account.id | ||
140 | }, | ||
141 | where: { | ||
142 | actorId: actor.id | ||
143 | }, | ||
144 | transaction: t | ||
145 | }) | ||
146 | |||
147 | return videoChannelCreated as MChannel | ||
148 | } | ||
149 | } | ||
diff --git a/server/lib/activitypub/actors/shared/index.ts b/server/lib/activitypub/actors/shared/index.ts deleted file mode 100644 index 52af1a8e1..000000000 --- a/server/lib/activitypub/actors/shared/index.ts +++ /dev/null | |||
@@ -1,3 +0,0 @@ | |||
1 | export * from './creator' | ||
2 | export * from './object-to-model-attributes' | ||
3 | export * from './url-to-object' | ||
diff --git a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts deleted file mode 100644 index 3ce332681..000000000 --- a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts +++ /dev/null | |||
@@ -1,84 +0,0 @@ | |||
1 | import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc' | ||
2 | import { MIMETYPES } from '@server/initializers/constants' | ||
3 | import { ActorModel } from '@server/models/actor/actor' | ||
4 | import { FilteredModelAttributes } from '@server/types' | ||
5 | import { getLowercaseExtension } from '@shared/core-utils' | ||
6 | import { buildUUID } from '@shared/extra-utils' | ||
7 | import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models' | ||
8 | |||
9 | function getActorAttributesFromObject ( | ||
10 | actorObject: ActivityPubActor, | ||
11 | followersCount: number, | ||
12 | followingCount: number | ||
13 | ): FilteredModelAttributes<ActorModel> { | ||
14 | return { | ||
15 | type: actorObject.type, | ||
16 | preferredUsername: actorObject.preferredUsername, | ||
17 | url: actorObject.id, | ||
18 | publicKey: actorObject.publicKey.publicKeyPem, | ||
19 | privateKey: null, | ||
20 | followersCount, | ||
21 | followingCount, | ||
22 | inboxUrl: actorObject.inbox, | ||
23 | outboxUrl: actorObject.outbox, | ||
24 | followersUrl: actorObject.followers, | ||
25 | followingUrl: actorObject.following, | ||
26 | |||
27 | sharedInboxUrl: actorObject.endpoints?.sharedInbox | ||
28 | ? actorObject.endpoints.sharedInbox | ||
29 | : null | ||
30 | } | ||
31 | } | ||
32 | |||
33 | function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) { | ||
34 | const iconsOrImages = type === ActorImageType.AVATAR | ||
35 | ? actorObject.icon | ||
36 | : actorObject.image | ||
37 | |||
38 | return normalizeIconOrImage(iconsOrImages) | ||
39 | .map(iconOrImage => { | ||
40 | const mimetypes = MIMETYPES.IMAGE | ||
41 | |||
42 | if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined | ||
43 | |||
44 | let extension: string | ||
45 | |||
46 | if (iconOrImage.mediaType) { | ||
47 | extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType] | ||
48 | } else { | ||
49 | const tmp = getLowercaseExtension(iconOrImage.url) | ||
50 | |||
51 | if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp | ||
52 | } | ||
53 | |||
54 | if (!extension) return undefined | ||
55 | |||
56 | return { | ||
57 | name: buildUUID() + extension, | ||
58 | fileUrl: iconOrImage.url, | ||
59 | height: iconOrImage.height, | ||
60 | width: iconOrImage.width, | ||
61 | type | ||
62 | } | ||
63 | }) | ||
64 | .filter(i => !!i) | ||
65 | } | ||
66 | |||
67 | function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { | ||
68 | return actorObject.name || actorObject.preferredUsername | ||
69 | } | ||
70 | |||
71 | export { | ||
72 | getActorAttributesFromObject, | ||
73 | getImagesInfoFromObject, | ||
74 | getActorDisplayNameFromObject | ||
75 | } | ||
76 | |||
77 | // --------------------------------------------------------------------------- | ||
78 | |||
79 | function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] { | ||
80 | if (Array.isArray(icon)) return icon | ||
81 | if (icon) return [ icon ] | ||
82 | |||
83 | return [] | ||
84 | } | ||
diff --git a/server/lib/activitypub/actors/shared/url-to-object.ts b/server/lib/activitypub/actors/shared/url-to-object.ts deleted file mode 100644 index 73766bd50..000000000 --- a/server/lib/activitypub/actors/shared/url-to-object.ts +++ /dev/null | |||
@@ -1,56 +0,0 @@ | |||
1 | import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { ActivityPubActor, ActivityPubOrderedCollection } from '@shared/models' | ||
4 | import { fetchAP } from '../../activity' | ||
5 | import { checkUrlsSameHost } from '../../url' | ||
6 | |||
7 | async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode: number, actorObject: ActivityPubActor }> { | ||
8 | logger.info('Fetching remote actor %s.', actorUrl) | ||
9 | |||
10 | const { body, statusCode } = await fetchAP<ActivityPubActor>(actorUrl) | ||
11 | |||
12 | if (sanitizeAndCheckActorObject(body) === false) { | ||
13 | logger.debug('Remote actor JSON is not valid.', { actorJSON: body }) | ||
14 | return { actorObject: undefined, statusCode } | ||
15 | } | ||
16 | |||
17 | if (checkUrlsSameHost(body.id, actorUrl) !== true) { | ||
18 | logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, body.id) | ||
19 | return { actorObject: undefined, statusCode } | ||
20 | } | ||
21 | |||
22 | return { | ||
23 | statusCode, | ||
24 | |||
25 | actorObject: body | ||
26 | } | ||
27 | } | ||
28 | |||
29 | async function fetchActorFollowsCount (actorObject: ActivityPubActor) { | ||
30 | let followersCount = 0 | ||
31 | let followingCount = 0 | ||
32 | |||
33 | if (actorObject.followers) followersCount = await fetchActorTotalItems(actorObject.followers) | ||
34 | if (actorObject.following) followingCount = await fetchActorTotalItems(actorObject.following) | ||
35 | |||
36 | return { followersCount, followingCount } | ||
37 | } | ||
38 | |||
39 | // --------------------------------------------------------------------------- | ||
40 | export { | ||
41 | fetchActorFollowsCount, | ||
42 | fetchRemoteActor | ||
43 | } | ||
44 | |||
45 | // --------------------------------------------------------------------------- | ||
46 | |||
47 | async function fetchActorTotalItems (url: string) { | ||
48 | try { | ||
49 | const { body } = await fetchAP<ActivityPubOrderedCollection<unknown>>(url) | ||
50 | |||
51 | return body.totalItems || 0 | ||
52 | } catch (err) { | ||
53 | logger.info('Cannot fetch remote actor count %s.', url, { err }) | ||
54 | return 0 | ||
55 | } | ||
56 | } | ||
diff --git a/server/lib/activitypub/actors/updater.ts b/server/lib/activitypub/actors/updater.ts deleted file mode 100644 index 5a92e7a22..000000000 --- a/server/lib/activitypub/actors/updater.ts +++ /dev/null | |||
@@ -1,91 +0,0 @@ | |||
1 | import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { AccountModel } from '@server/models/account/account' | ||
4 | import { VideoChannelModel } from '@server/models/video/video-channel' | ||
5 | import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models' | ||
6 | import { ActivityPubActor, ActorImageType } from '@shared/models' | ||
7 | import { getOrCreateAPOwner } from './get' | ||
8 | import { updateActorImages } from './image' | ||
9 | import { fetchActorFollowsCount } from './shared' | ||
10 | import { getImagesInfoFromObject } from './shared/object-to-model-attributes' | ||
11 | |||
12 | export class APActorUpdater { | ||
13 | |||
14 | private readonly accountOrChannel: MAccount | MChannel | ||
15 | |||
16 | constructor ( | ||
17 | private readonly actorObject: ActivityPubActor, | ||
18 | private readonly actor: MActorFull | ||
19 | ) { | ||
20 | if (this.actorObject.type === 'Group') this.accountOrChannel = this.actor.VideoChannel | ||
21 | else this.accountOrChannel = this.actor.Account | ||
22 | } | ||
23 | |||
24 | async update () { | ||
25 | const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR) | ||
26 | const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER) | ||
27 | |||
28 | try { | ||
29 | await this.updateActorInstance(this.actor, this.actorObject) | ||
30 | |||
31 | this.accountOrChannel.name = this.actorObject.name || this.actorObject.preferredUsername | ||
32 | this.accountOrChannel.description = this.actorObject.summary | ||
33 | |||
34 | if (this.accountOrChannel instanceof VideoChannelModel) { | ||
35 | const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.url) | ||
36 | this.accountOrChannel.accountId = owner.Account.id | ||
37 | this.accountOrChannel.Account = owner.Account as AccountModel | ||
38 | |||
39 | this.accountOrChannel.support = this.actorObject.support | ||
40 | } | ||
41 | |||
42 | await runInReadCommittedTransaction(async t => { | ||
43 | await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t) | ||
44 | await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t) | ||
45 | }) | ||
46 | |||
47 | await runInReadCommittedTransaction(async t => { | ||
48 | await this.actor.save({ transaction: t }) | ||
49 | await this.accountOrChannel.save({ transaction: t }) | ||
50 | }) | ||
51 | |||
52 | logger.info('Remote account %s updated', this.actorObject.url) | ||
53 | } catch (err) { | ||
54 | if (this.actor !== undefined) { | ||
55 | await resetSequelizeInstance(this.actor) | ||
56 | } | ||
57 | |||
58 | if (this.accountOrChannel !== undefined) { | ||
59 | await resetSequelizeInstance(this.accountOrChannel) | ||
60 | } | ||
61 | |||
62 | // This is just a debug because we will retry the insert | ||
63 | logger.debug('Cannot update the remote account.', { err }) | ||
64 | throw err | ||
65 | } | ||
66 | } | ||
67 | |||
68 | private async updateActorInstance (actorInstance: MActor, actorObject: ActivityPubActor) { | ||
69 | const { followersCount, followingCount } = await fetchActorFollowsCount(actorObject) | ||
70 | |||
71 | actorInstance.type = actorObject.type | ||
72 | actorInstance.preferredUsername = actorObject.preferredUsername | ||
73 | actorInstance.url = actorObject.id | ||
74 | actorInstance.publicKey = actorObject.publicKey.publicKeyPem | ||
75 | actorInstance.followersCount = followersCount | ||
76 | actorInstance.followingCount = followingCount | ||
77 | actorInstance.inboxUrl = actorObject.inbox | ||
78 | actorInstance.outboxUrl = actorObject.outbox | ||
79 | actorInstance.followersUrl = actorObject.followers | ||
80 | actorInstance.followingUrl = actorObject.following | ||
81 | |||
82 | if (actorObject.published) actorInstance.remoteCreatedAt = new Date(actorObject.published) | ||
83 | |||
84 | if (actorObject.endpoints?.sharedInbox) { | ||
85 | actorInstance.sharedInboxUrl = actorObject.endpoints.sharedInbox | ||
86 | } | ||
87 | |||
88 | // Force actor update | ||
89 | actorInstance.changed('updatedAt', true) | ||
90 | } | ||
91 | } | ||
diff --git a/server/lib/activitypub/actors/webfinger.ts b/server/lib/activitypub/actors/webfinger.ts deleted file mode 100644 index b20a724da..000000000 --- a/server/lib/activitypub/actors/webfinger.ts +++ /dev/null | |||
@@ -1,67 +0,0 @@ | |||
1 | import WebFinger from 'webfinger.js' | ||
2 | import { isProdInstance } from '@server/helpers/core-utils' | ||
3 | import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc' | ||
4 | import { REQUEST_TIMEOUTS, WEBSERVER } from '@server/initializers/constants' | ||
5 | import { ActorModel } from '@server/models/actor/actor' | ||
6 | import { MActorFull } from '@server/types/models' | ||
7 | import { WebFingerData } from '@shared/models' | ||
8 | |||
9 | const webfinger = new WebFinger({ | ||
10 | webfist_fallback: false, | ||
11 | tls_only: isProdInstance(), | ||
12 | uri_fallback: false, | ||
13 | request_timeout: REQUEST_TIMEOUTS.DEFAULT | ||
14 | }) | ||
15 | |||
16 | async function loadActorUrlOrGetFromWebfinger (uriArg: string) { | ||
17 | // Handle strings like @toto@example.com | ||
18 | const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg | ||
19 | |||
20 | const [ name, host ] = uri.split('@') | ||
21 | let actor: MActorFull | ||
22 | |||
23 | if (!host || host === WEBSERVER.HOST) { | ||
24 | actor = await ActorModel.loadLocalByName(name) | ||
25 | } else { | ||
26 | actor = await ActorModel.loadByNameAndHost(name, host) | ||
27 | } | ||
28 | |||
29 | if (actor) return actor.url | ||
30 | |||
31 | return getUrlFromWebfinger(uri) | ||
32 | } | ||
33 | |||
34 | async function getUrlFromWebfinger (uri: string) { | ||
35 | const webfingerData: WebFingerData = await webfingerLookup(uri) | ||
36 | return getLinkOrThrow(webfingerData) | ||
37 | } | ||
38 | |||
39 | // --------------------------------------------------------------------------- | ||
40 | |||
41 | export { | ||
42 | getUrlFromWebfinger, | ||
43 | loadActorUrlOrGetFromWebfinger | ||
44 | } | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | function getLinkOrThrow (webfingerData: WebFingerData) { | ||
49 | if (Array.isArray(webfingerData.links) === false) throw new Error('WebFinger links is not an array.') | ||
50 | |||
51 | const selfLink = webfingerData.links.find(l => l.rel === 'self') | ||
52 | if (selfLink === undefined || isActivityPubUrlValid(selfLink.href) === false) { | ||
53 | throw new Error('Cannot find self link or href is not a valid URL.') | ||
54 | } | ||
55 | |||
56 | return selfLink.href | ||
57 | } | ||
58 | |||
59 | function webfingerLookup (nameWithHost: string) { | ||
60 | return new Promise<WebFingerData>((res, rej) => { | ||
61 | webfinger.lookup(nameWithHost, (err, p) => { | ||
62 | if (err) return rej(err) | ||
63 | |||
64 | return res(p.object) | ||
65 | }) | ||
66 | }) | ||
67 | } | ||
diff --git a/server/lib/activitypub/audience.ts b/server/lib/activitypub/audience.ts deleted file mode 100644 index 6f5491387..000000000 --- a/server/lib/activitypub/audience.ts +++ /dev/null | |||
@@ -1,34 +0,0 @@ | |||
1 | import { ActivityAudience } from '../../../shared/models/activitypub' | ||
2 | import { ACTIVITY_PUB } from '../../initializers/constants' | ||
3 | import { MActorFollowersUrl } from '../../types/models' | ||
4 | |||
5 | function getAudience (actorSender: MActorFollowersUrl, isPublic = true) { | ||
6 | return buildAudience([ actorSender.followersUrl ], isPublic) | ||
7 | } | ||
8 | |||
9 | function buildAudience (followerUrls: string[], isPublic = true) { | ||
10 | let to: string[] = [] | ||
11 | let cc: string[] = [] | ||
12 | |||
13 | if (isPublic) { | ||
14 | to = [ ACTIVITY_PUB.PUBLIC ] | ||
15 | cc = followerUrls | ||
16 | } else { // Unlisted | ||
17 | to = [] | ||
18 | cc = [] | ||
19 | } | ||
20 | |||
21 | return { to, cc } | ||
22 | } | ||
23 | |||
24 | function audiencify<T> (object: T, audience: ActivityAudience) { | ||
25 | return { ...audience, ...object } | ||
26 | } | ||
27 | |||
28 | // --------------------------------------------------------------------------- | ||
29 | |||
30 | export { | ||
31 | buildAudience, | ||
32 | getAudience, | ||
33 | audiencify | ||
34 | } | ||
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts deleted file mode 100644 index c3acd7112..000000000 --- a/server/lib/activitypub/cache-file.ts +++ /dev/null | |||
@@ -1,82 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models' | ||
3 | import { CacheFileObject, VideoStreamingPlaylistType } from '@shared/models' | ||
4 | import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' | ||
5 | |||
6 | async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) { | ||
7 | const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t) | ||
8 | |||
9 | if (redundancyModel) { | ||
10 | return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t) | ||
11 | } | ||
12 | |||
13 | return createCacheFile(cacheFileObject, video, byActor, t) | ||
14 | } | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
19 | createOrUpdateCacheFile | ||
20 | } | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) { | ||
25 | const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor) | ||
26 | |||
27 | return VideoRedundancyModel.create(attributes, { transaction: t }) | ||
28 | } | ||
29 | |||
30 | function updateCacheFile ( | ||
31 | cacheFileObject: CacheFileObject, | ||
32 | redundancyModel: MVideoRedundancy, | ||
33 | video: MVideoWithAllFiles, | ||
34 | byActor: MActorId, | ||
35 | t: Transaction | ||
36 | ) { | ||
37 | if (redundancyModel.actorId !== byActor.id) { | ||
38 | throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.') | ||
39 | } | ||
40 | |||
41 | const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor) | ||
42 | |||
43 | redundancyModel.expiresOn = attributes.expiresOn | ||
44 | redundancyModel.fileUrl = attributes.fileUrl | ||
45 | |||
46 | return redundancyModel.save({ transaction: t }) | ||
47 | } | ||
48 | |||
49 | function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) { | ||
50 | |||
51 | if (cacheFileObject.url.mediaType === 'application/x-mpegURL') { | ||
52 | const url = cacheFileObject.url | ||
53 | |||
54 | const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS) | ||
55 | if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url) | ||
56 | |||
57 | return { | ||
58 | expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null, | ||
59 | url: cacheFileObject.id, | ||
60 | fileUrl: url.href, | ||
61 | strategy: null, | ||
62 | videoStreamingPlaylistId: playlist.id, | ||
63 | actorId: byActor.id | ||
64 | } | ||
65 | } | ||
66 | |||
67 | const url = cacheFileObject.url | ||
68 | const videoFile = video.VideoFiles.find(f => { | ||
69 | return f.resolution === url.height && f.fps === url.fps | ||
70 | }) | ||
71 | |||
72 | if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`) | ||
73 | |||
74 | return { | ||
75 | expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null, | ||
76 | url: cacheFileObject.id, | ||
77 | fileUrl: url.href, | ||
78 | strategy: null, | ||
79 | videoFileId: videoFile.id, | ||
80 | actorId: byActor.id | ||
81 | } | ||
82 | } | ||
diff --git a/server/lib/activitypub/collection.ts b/server/lib/activitypub/collection.ts deleted file mode 100644 index a176cab51..000000000 --- a/server/lib/activitypub/collection.ts +++ /dev/null | |||
@@ -1,63 +0,0 @@ | |||
1 | import Bluebird from 'bluebird' | ||
2 | import validator from 'validator' | ||
3 | import { pageToStartAndCount } from '@server/helpers/core-utils' | ||
4 | import { ACTIVITY_PUB } from '@server/initializers/constants' | ||
5 | import { ResultList } from '@shared/models' | ||
6 | import { forceNumber } from '@shared/core-utils' | ||
7 | |||
8 | type ActivityPubCollectionPaginationHandler = (start: number, count: number) => Bluebird<ResultList<any>> | Promise<ResultList<any>> | ||
9 | |||
10 | async function activityPubCollectionPagination ( | ||
11 | baseUrl: string, | ||
12 | handler: ActivityPubCollectionPaginationHandler, | ||
13 | page?: any, | ||
14 | size = ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE | ||
15 | ) { | ||
16 | if (!page || !validator.isInt(page)) { | ||
17 | // We just display the first page URL, we only need the total items | ||
18 | const result = await handler(0, 1) | ||
19 | |||
20 | return { | ||
21 | id: baseUrl, | ||
22 | type: 'OrderedCollection', | ||
23 | totalItems: result.total, | ||
24 | first: result.data.length === 0 | ||
25 | ? undefined | ||
26 | : baseUrl + '?page=1' | ||
27 | } | ||
28 | } | ||
29 | |||
30 | const { start, count } = pageToStartAndCount(page, size) | ||
31 | const result = await handler(start, count) | ||
32 | |||
33 | let next: string | undefined | ||
34 | let prev: string | undefined | ||
35 | |||
36 | // Assert page is a number | ||
37 | page = forceNumber(page) | ||
38 | |||
39 | // There are more results | ||
40 | if (result.total > page * size) { | ||
41 | next = baseUrl + '?page=' + (page + 1) | ||
42 | } | ||
43 | |||
44 | if (page > 1) { | ||
45 | prev = baseUrl + '?page=' + (page - 1) | ||
46 | } | ||
47 | |||
48 | return { | ||
49 | id: baseUrl + '?page=' + page, | ||
50 | type: 'OrderedCollectionPage', | ||
51 | prev, | ||
52 | next, | ||
53 | partOf: baseUrl, | ||
54 | orderedItems: result.data, | ||
55 | totalItems: result.total | ||
56 | } | ||
57 | } | ||
58 | |||
59 | // --------------------------------------------------------------------------- | ||
60 | |||
61 | export { | ||
62 | activityPubCollectionPagination | ||
63 | } | ||
diff --git a/server/lib/activitypub/context.ts b/server/lib/activitypub/context.ts deleted file mode 100644 index 87eb498a3..000000000 --- a/server/lib/activitypub/context.ts +++ /dev/null | |||
@@ -1,212 +0,0 @@ | |||
1 | import { ContextType } from '@shared/models' | ||
2 | import { Hooks } from '../plugins/hooks' | ||
3 | |||
4 | async function activityPubContextify <T> (data: T, type: ContextType) { | ||
5 | return { ...await getContextData(type), ...data } | ||
6 | } | ||
7 | |||
8 | // --------------------------------------------------------------------------- | ||
9 | |||
10 | export { | ||
11 | getContextData, | ||
12 | activityPubContextify | ||
13 | } | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string }) } | ||
18 | |||
19 | const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = { | ||
20 | Video: buildContext({ | ||
21 | Hashtag: 'as:Hashtag', | ||
22 | uuid: 'sc:identifier', | ||
23 | category: 'sc:category', | ||
24 | licence: 'sc:license', | ||
25 | subtitleLanguage: 'sc:subtitleLanguage', | ||
26 | sensitive: 'as:sensitive', | ||
27 | language: 'sc:inLanguage', | ||
28 | identifier: 'sc:identifier', | ||
29 | |||
30 | isLiveBroadcast: 'sc:isLiveBroadcast', | ||
31 | liveSaveReplay: { | ||
32 | '@type': 'sc:Boolean', | ||
33 | '@id': 'pt:liveSaveReplay' | ||
34 | }, | ||
35 | permanentLive: { | ||
36 | '@type': 'sc:Boolean', | ||
37 | '@id': 'pt:permanentLive' | ||
38 | }, | ||
39 | latencyMode: { | ||
40 | '@type': 'sc:Number', | ||
41 | '@id': 'pt:latencyMode' | ||
42 | }, | ||
43 | |||
44 | Infohash: 'pt:Infohash', | ||
45 | |||
46 | tileWidth: { | ||
47 | '@type': 'sc:Number', | ||
48 | '@id': 'pt:tileWidth' | ||
49 | }, | ||
50 | tileHeight: { | ||
51 | '@type': 'sc:Number', | ||
52 | '@id': 'pt:tileHeight' | ||
53 | }, | ||
54 | tileDuration: { | ||
55 | '@type': 'sc:Number', | ||
56 | '@id': 'pt:tileDuration' | ||
57 | }, | ||
58 | |||
59 | originallyPublishedAt: 'sc:datePublished', | ||
60 | |||
61 | uploadDate: 'sc:uploadDate', | ||
62 | |||
63 | views: { | ||
64 | '@type': 'sc:Number', | ||
65 | '@id': 'pt:views' | ||
66 | }, | ||
67 | state: { | ||
68 | '@type': 'sc:Number', | ||
69 | '@id': 'pt:state' | ||
70 | }, | ||
71 | size: { | ||
72 | '@type': 'sc:Number', | ||
73 | '@id': 'pt:size' | ||
74 | }, | ||
75 | fps: { | ||
76 | '@type': 'sc:Number', | ||
77 | '@id': 'pt:fps' | ||
78 | }, | ||
79 | commentsEnabled: { | ||
80 | '@type': 'sc:Boolean', | ||
81 | '@id': 'pt:commentsEnabled' | ||
82 | }, | ||
83 | downloadEnabled: { | ||
84 | '@type': 'sc:Boolean', | ||
85 | '@id': 'pt:downloadEnabled' | ||
86 | }, | ||
87 | waitTranscoding: { | ||
88 | '@type': 'sc:Boolean', | ||
89 | '@id': 'pt:waitTranscoding' | ||
90 | }, | ||
91 | support: { | ||
92 | '@type': 'sc:Text', | ||
93 | '@id': 'pt:support' | ||
94 | }, | ||
95 | likes: { | ||
96 | '@id': 'as:likes', | ||
97 | '@type': '@id' | ||
98 | }, | ||
99 | dislikes: { | ||
100 | '@id': 'as:dislikes', | ||
101 | '@type': '@id' | ||
102 | }, | ||
103 | shares: { | ||
104 | '@id': 'as:shares', | ||
105 | '@type': '@id' | ||
106 | }, | ||
107 | comments: { | ||
108 | '@id': 'as:comments', | ||
109 | '@type': '@id' | ||
110 | } | ||
111 | }), | ||
112 | |||
113 | Playlist: buildContext({ | ||
114 | Playlist: 'pt:Playlist', | ||
115 | PlaylistElement: 'pt:PlaylistElement', | ||
116 | position: { | ||
117 | '@type': 'sc:Number', | ||
118 | '@id': 'pt:position' | ||
119 | }, | ||
120 | startTimestamp: { | ||
121 | '@type': 'sc:Number', | ||
122 | '@id': 'pt:startTimestamp' | ||
123 | }, | ||
124 | stopTimestamp: { | ||
125 | '@type': 'sc:Number', | ||
126 | '@id': 'pt:stopTimestamp' | ||
127 | }, | ||
128 | uuid: 'sc:identifier' | ||
129 | }), | ||
130 | |||
131 | CacheFile: buildContext({ | ||
132 | expires: 'sc:expires', | ||
133 | CacheFile: 'pt:CacheFile' | ||
134 | }), | ||
135 | |||
136 | Flag: buildContext({ | ||
137 | Hashtag: 'as:Hashtag' | ||
138 | }), | ||
139 | |||
140 | Actor: buildContext({ | ||
141 | playlists: { | ||
142 | '@id': 'pt:playlists', | ||
143 | '@type': '@id' | ||
144 | }, | ||
145 | support: { | ||
146 | '@type': 'sc:Text', | ||
147 | '@id': 'pt:support' | ||
148 | }, | ||
149 | |||
150 | // TODO: remove in a few versions, introduced in 4.2 | ||
151 | icons: 'as:icon' | ||
152 | }), | ||
153 | |||
154 | WatchAction: buildContext({ | ||
155 | WatchAction: 'sc:WatchAction', | ||
156 | startTimestamp: { | ||
157 | '@type': 'sc:Number', | ||
158 | '@id': 'pt:startTimestamp' | ||
159 | }, | ||
160 | stopTimestamp: { | ||
161 | '@type': 'sc:Number', | ||
162 | '@id': 'pt:stopTimestamp' | ||
163 | }, | ||
164 | watchSection: { | ||
165 | '@type': 'sc:Number', | ||
166 | '@id': 'pt:stopTimestamp' | ||
167 | }, | ||
168 | uuid: 'sc:identifier' | ||
169 | }), | ||
170 | |||
171 | Collection: buildContext(), | ||
172 | Follow: buildContext(), | ||
173 | Reject: buildContext(), | ||
174 | Accept: buildContext(), | ||
175 | View: buildContext(), | ||
176 | Announce: buildContext(), | ||
177 | Comment: buildContext(), | ||
178 | Delete: buildContext(), | ||
179 | Rate: buildContext() | ||
180 | } | ||
181 | |||
182 | async function getContextData (type: ContextType) { | ||
183 | const contextData = await Hooks.wrapObject( | ||
184 | contextStore[type], | ||
185 | 'filter:activity-pub.activity.context.build.result' | ||
186 | ) | ||
187 | |||
188 | return { '@context': contextData } | ||
189 | } | ||
190 | |||
191 | function buildContext (contextValue?: ContextValue) { | ||
192 | const baseContext = [ | ||
193 | 'https://www.w3.org/ns/activitystreams', | ||
194 | 'https://w3id.org/security/v1', | ||
195 | { | ||
196 | RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' | ||
197 | } | ||
198 | ] | ||
199 | |||
200 | if (!contextValue) return baseContext | ||
201 | |||
202 | return [ | ||
203 | ...baseContext, | ||
204 | |||
205 | { | ||
206 | pt: 'https://joinpeertube.org/ns#', | ||
207 | sc: 'http://schema.org/', | ||
208 | |||
209 | ...contextValue | ||
210 | } | ||
211 | ] | ||
212 | } | ||
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts deleted file mode 100644 index b8348e8cf..000000000 --- a/server/lib/activitypub/crawl.ts +++ /dev/null | |||
@@ -1,58 +0,0 @@ | |||
1 | import Bluebird from 'bluebird' | ||
2 | import { URL } from 'url' | ||
3 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
4 | import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' | ||
5 | import { logger } from '../../helpers/logger' | ||
6 | import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants' | ||
7 | import { fetchAP } from './activity' | ||
8 | |||
9 | type HandlerFunction<T> = (items: T[]) => (Promise<any> | Bluebird<any>) | ||
10 | type CleanerFunction = (startedDate: Date) => Promise<any> | ||
11 | |||
12 | async function crawlCollectionPage <T> (argUrl: string, handler: HandlerFunction<T>, cleaner?: CleanerFunction) { | ||
13 | let url = argUrl | ||
14 | |||
15 | logger.info('Crawling ActivityPub data on %s.', url) | ||
16 | |||
17 | const startDate = new Date() | ||
18 | |||
19 | const response = await fetchAP<ActivityPubOrderedCollection<T>>(url) | ||
20 | const firstBody = response.body | ||
21 | |||
22 | const limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT | ||
23 | let i = 0 | ||
24 | let nextLink = firstBody.first | ||
25 | while (nextLink && i < limit) { | ||
26 | let body: any | ||
27 | |||
28 | if (typeof nextLink === 'string') { | ||
29 | // Don't crawl ourselves | ||
30 | const remoteHost = new URL(nextLink).host | ||
31 | if (remoteHost === WEBSERVER.HOST) continue | ||
32 | |||
33 | url = nextLink | ||
34 | |||
35 | const res = await fetchAP<ActivityPubOrderedCollection<T>>(url) | ||
36 | body = res.body | ||
37 | } else { | ||
38 | // nextLink is already the object we want | ||
39 | body = nextLink | ||
40 | } | ||
41 | |||
42 | nextLink = body.next | ||
43 | i++ | ||
44 | |||
45 | if (Array.isArray(body.orderedItems)) { | ||
46 | const items = body.orderedItems | ||
47 | logger.info('Processing %i ActivityPub items for %s.', items.length, url) | ||
48 | |||
49 | await handler(items) | ||
50 | } | ||
51 | } | ||
52 | |||
53 | if (cleaner) await retryTransactionWrapper(cleaner, startDate) | ||
54 | } | ||
55 | |||
56 | export { | ||
57 | crawlCollectionPage | ||
58 | } | ||
diff --git a/server/lib/activitypub/follow.ts b/server/lib/activitypub/follow.ts deleted file mode 100644 index f6e2a48fd..000000000 --- a/server/lib/activitypub/follow.ts +++ /dev/null | |||
@@ -1,51 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { getServerActor } from '@server/models/application/application' | ||
3 | import { logger } from '../../helpers/logger' | ||
4 | import { CONFIG } from '../../initializers/config' | ||
5 | import { SERVER_ACTOR_NAME } from '../../initializers/constants' | ||
6 | import { ServerModel } from '../../models/server/server' | ||
7 | import { MActorFollowActors } from '../../types/models' | ||
8 | import { JobQueue } from '../job-queue' | ||
9 | |||
10 | async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transaction?: Transaction) { | ||
11 | if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return | ||
12 | |||
13 | const follower = actorFollow.ActorFollower | ||
14 | |||
15 | if (follower.type === 'Application' && follower.preferredUsername === SERVER_ACTOR_NAME) { | ||
16 | logger.info('Auto follow back %s.', follower.url) | ||
17 | |||
18 | const me = await getServerActor() | ||
19 | |||
20 | const server = await ServerModel.load(follower.serverId, transaction) | ||
21 | const host = server.host | ||
22 | |||
23 | const payload = { | ||
24 | host, | ||
25 | name: SERVER_ACTOR_NAME, | ||
26 | followerActorId: me.id, | ||
27 | isAutoFollow: true | ||
28 | } | ||
29 | |||
30 | JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload }) | ||
31 | } | ||
32 | } | ||
33 | |||
34 | // If we only have an host, use a default account handle | ||
35 | function getRemoteNameAndHost (handleOrHost: string) { | ||
36 | let name = SERVER_ACTOR_NAME | ||
37 | let host = handleOrHost | ||
38 | |||
39 | const splitted = handleOrHost.split('@') | ||
40 | if (splitted.length === 2) { | ||
41 | name = splitted[0] | ||
42 | host = splitted[1] | ||
43 | } | ||
44 | |||
45 | return { name, host } | ||
46 | } | ||
47 | |||
48 | export { | ||
49 | autoFollowBackIfNeeded, | ||
50 | getRemoteNameAndHost | ||
51 | } | ||
diff --git a/server/lib/activitypub/inbox-manager.ts b/server/lib/activitypub/inbox-manager.ts deleted file mode 100644 index 27778cc9d..000000000 --- a/server/lib/activitypub/inbox-manager.ts +++ /dev/null | |||
@@ -1,47 +0,0 @@ | |||
1 | import PQueue from 'p-queue' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants' | ||
4 | import { MActorDefault, MActorSignature } from '@server/types/models' | ||
5 | import { Activity } from '@shared/models' | ||
6 | import { StatsManager } from '../stat-manager' | ||
7 | import { processActivities } from './process' | ||
8 | |||
9 | class InboxManager { | ||
10 | |||
11 | private static instance: InboxManager | ||
12 | private readonly inboxQueue: PQueue | ||
13 | |||
14 | private constructor () { | ||
15 | this.inboxQueue = new PQueue({ concurrency: 1 }) | ||
16 | |||
17 | setInterval(() => { | ||
18 | StatsManager.Instance.updateInboxWaiting(this.getActivityPubMessagesWaiting()) | ||
19 | }, SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS) | ||
20 | } | ||
21 | |||
22 | addInboxMessage (param: { | ||
23 | activities: Activity[] | ||
24 | signatureActor?: MActorSignature | ||
25 | inboxActor?: MActorDefault | ||
26 | }) { | ||
27 | this.inboxQueue.add(() => { | ||
28 | const options = { signatureActor: param.signatureActor, inboxActor: param.inboxActor } | ||
29 | |||
30 | return processActivities(param.activities, options) | ||
31 | }).catch(err => logger.error('Error with inbox queue.', { err })) | ||
32 | } | ||
33 | |||
34 | getActivityPubMessagesWaiting () { | ||
35 | return this.inboxQueue.size + this.inboxQueue.pending | ||
36 | } | ||
37 | |||
38 | static get Instance () { | ||
39 | return this.instance || (this.instance = new this()) | ||
40 | } | ||
41 | } | ||
42 | |||
43 | // --------------------------------------------------------------------------- | ||
44 | |||
45 | export { | ||
46 | InboxManager | ||
47 | } | ||
diff --git a/server/lib/activitypub/local-video-viewer.ts b/server/lib/activitypub/local-video-viewer.ts deleted file mode 100644 index bdd746791..000000000 --- a/server/lib/activitypub/local-video-viewer.ts +++ /dev/null | |||
@@ -1,44 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' | ||
3 | import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section' | ||
4 | import { MVideo } from '@server/types/models' | ||
5 | import { WatchActionObject } from '@shared/models' | ||
6 | import { getDurationFromActivityStream } from './activity' | ||
7 | |||
8 | async function createOrUpdateLocalVideoViewer (watchAction: WatchActionObject, video: MVideo, t: Transaction) { | ||
9 | const stats = await LocalVideoViewerModel.loadByUrl(watchAction.id) | ||
10 | if (stats) await stats.destroy({ transaction: t }) | ||
11 | |||
12 | const localVideoViewer = await LocalVideoViewerModel.create({ | ||
13 | url: watchAction.id, | ||
14 | uuid: watchAction.uuid, | ||
15 | |||
16 | watchTime: getDurationFromActivityStream(watchAction.duration), | ||
17 | |||
18 | startDate: new Date(watchAction.startTime), | ||
19 | endDate: new Date(watchAction.endTime), | ||
20 | |||
21 | country: watchAction.location | ||
22 | ? watchAction.location.addressCountry | ||
23 | : null, | ||
24 | |||
25 | videoId: video.id | ||
26 | }, { transaction: t }) | ||
27 | |||
28 | await LocalVideoViewerWatchSectionModel.bulkCreateSections({ | ||
29 | localVideoViewerId: localVideoViewer.id, | ||
30 | |||
31 | watchSections: watchAction.watchSections.map(s => ({ | ||
32 | start: s.startTimestamp, | ||
33 | end: s.endTimestamp | ||
34 | })), | ||
35 | |||
36 | transaction: t | ||
37 | }) | ||
38 | } | ||
39 | |||
40 | // --------------------------------------------------------------------------- | ||
41 | |||
42 | export { | ||
43 | createOrUpdateLocalVideoViewer | ||
44 | } | ||
diff --git a/server/lib/activitypub/outbox.ts b/server/lib/activitypub/outbox.ts deleted file mode 100644 index 5eef76871..000000000 --- a/server/lib/activitypub/outbox.ts +++ /dev/null | |||
@@ -1,24 +0,0 @@ | |||
1 | import { logger } from '@server/helpers/logger' | ||
2 | import { ActorModel } from '@server/models/actor/actor' | ||
3 | import { getServerActor } from '@server/models/application/application' | ||
4 | import { JobQueue } from '../job-queue' | ||
5 | |||
6 | async function addFetchOutboxJob (actor: Pick<ActorModel, 'id' | 'outboxUrl'>) { | ||
7 | // Don't fetch ourselves | ||
8 | const serverActor = await getServerActor() | ||
9 | if (serverActor.id === actor.id) { | ||
10 | logger.error('Cannot fetch our own outbox!') | ||
11 | return undefined | ||
12 | } | ||
13 | |||
14 | const payload = { | ||
15 | uri: actor.outboxUrl, | ||
16 | type: 'activity' as 'activity' | ||
17 | } | ||
18 | |||
19 | return JobQueue.Instance.createJobAsync({ type: 'activitypub-http-fetcher', payload }) | ||
20 | } | ||
21 | |||
22 | export { | ||
23 | addFetchOutboxJob | ||
24 | } | ||
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts deleted file mode 100644 index b24299f29..000000000 --- a/server/lib/activitypub/playlists/create-update.ts +++ /dev/null | |||
@@ -1,157 +0,0 @@ | |||
1 | import { map } from 'bluebird' | ||
2 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
3 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
5 | import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' | ||
6 | import { sequelizeTypescript } from '@server/initializers/database' | ||
7 | import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail' | ||
8 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
9 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' | ||
10 | import { FilteredModelAttributes } from '@server/types' | ||
11 | import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models' | ||
12 | import { PlaylistObject } from '@shared/models' | ||
13 | import { AttributesOnly } from '@shared/typescript-utils' | ||
14 | import { getAPId } from '../activity' | ||
15 | import { getOrCreateAPActor } from '../actors' | ||
16 | import { crawlCollectionPage } from '../crawl' | ||
17 | import { getOrCreateAPVideo } from '../videos' | ||
18 | import { | ||
19 | fetchRemotePlaylistElement, | ||
20 | fetchRemoteVideoPlaylist, | ||
21 | playlistElementObjectToDBAttributes, | ||
22 | playlistObjectToDBAttributes | ||
23 | } from './shared' | ||
24 | |||
25 | const lTags = loggerTagsFactory('ap', 'video-playlist') | ||
26 | |||
27 | async function createAccountPlaylists (playlistUrls: string[]) { | ||
28 | await map(playlistUrls, async playlistUrl => { | ||
29 | try { | ||
30 | const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) | ||
31 | if (exists === true) return | ||
32 | |||
33 | const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl) | ||
34 | |||
35 | if (playlistObject === undefined) { | ||
36 | throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) | ||
37 | } | ||
38 | |||
39 | return createOrUpdateVideoPlaylist(playlistObject) | ||
40 | } catch (err) { | ||
41 | logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) }) | ||
42 | } | ||
43 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
44 | } | ||
45 | |||
46 | async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) { | ||
47 | const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to) | ||
48 | |||
49 | await setVideoChannel(playlistObject, playlistAttributes) | ||
50 | |||
51 | const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true }) | ||
52 | |||
53 | const playlistElementUrls = await fetchElementUrls(playlistObject) | ||
54 | |||
55 | // Refetch playlist from DB since elements fetching could be long in time | ||
56 | const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null) | ||
57 | |||
58 | await updatePlaylistThumbnail(playlistObject, playlist) | ||
59 | |||
60 | const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist) | ||
61 | playlist.setVideosLength(elementsLength) | ||
62 | |||
63 | return playlist | ||
64 | } | ||
65 | |||
66 | // --------------------------------------------------------------------------- | ||
67 | |||
68 | export { | ||
69 | createAccountPlaylists, | ||
70 | createOrUpdateVideoPlaylist | ||
71 | } | ||
72 | |||
73 | // --------------------------------------------------------------------------- | ||
74 | |||
75 | async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) { | ||
76 | if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) { | ||
77 | throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject)) | ||
78 | } | ||
79 | |||
80 | const actor = await getOrCreateAPActor(getAPId(playlistObject.attributedTo[0]), 'all') | ||
81 | |||
82 | if (!actor.VideoChannel) { | ||
83 | logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) | ||
84 | return | ||
85 | } | ||
86 | |||
87 | playlistAttributes.videoChannelId = actor.VideoChannel.id | ||
88 | playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id | ||
89 | } | ||
90 | |||
91 | async function fetchElementUrls (playlistObject: PlaylistObject) { | ||
92 | let accItems: string[] = [] | ||
93 | await crawlCollectionPage<string>(playlistObject.id, items => { | ||
94 | accItems = accItems.concat(items) | ||
95 | |||
96 | return Promise.resolve() | ||
97 | }) | ||
98 | |||
99 | return accItems | ||
100 | } | ||
101 | |||
102 | async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) { | ||
103 | if (playlistObject.icon) { | ||
104 | let thumbnailModel: MThumbnail | ||
105 | |||
106 | try { | ||
107 | thumbnailModel = await updateRemotePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist }) | ||
108 | await playlist.setAndSaveThumbnail(thumbnailModel, undefined) | ||
109 | } catch (err) { | ||
110 | logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) }) | ||
111 | |||
112 | if (thumbnailModel) await thumbnailModel.removeThumbnail() | ||
113 | } | ||
114 | |||
115 | return | ||
116 | } | ||
117 | |||
118 | // Playlist does not have an icon, destroy existing one | ||
119 | if (playlist.hasThumbnail()) { | ||
120 | await playlist.Thumbnail.destroy() | ||
121 | playlist.Thumbnail = null | ||
122 | } | ||
123 | } | ||
124 | |||
125 | async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) { | ||
126 | const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist) | ||
127 | |||
128 | await retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => { | ||
129 | await VideoPlaylistElementModel.deleteAllOf(playlist.id, t) | ||
130 | |||
131 | for (const element of elementsToCreate) { | ||
132 | await VideoPlaylistElementModel.create(element, { transaction: t }) | ||
133 | } | ||
134 | })) | ||
135 | |||
136 | logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url)) | ||
137 | |||
138 | return elementsToCreate.length | ||
139 | } | ||
140 | |||
141 | async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) { | ||
142 | const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = [] | ||
143 | |||
144 | await map(elementUrls, async elementUrl => { | ||
145 | try { | ||
146 | const { elementObject } = await fetchRemotePlaylistElement(elementUrl) | ||
147 | |||
148 | const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video' }) | ||
149 | |||
150 | elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video)) | ||
151 | } catch (err) { | ||
152 | logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) }) | ||
153 | } | ||
154 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
155 | |||
156 | return elementsToCreate | ||
157 | } | ||
diff --git a/server/lib/activitypub/playlists/get.ts b/server/lib/activitypub/playlists/get.ts deleted file mode 100644 index c34554d69..000000000 --- a/server/lib/activitypub/playlists/get.ts +++ /dev/null | |||
@@ -1,35 +0,0 @@ | |||
1 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
2 | import { MVideoPlaylistFullSummary } from '@server/types/models' | ||
3 | import { APObjectId } from '@shared/models' | ||
4 | import { getAPId } from '../activity' | ||
5 | import { createOrUpdateVideoPlaylist } from './create-update' | ||
6 | import { scheduleRefreshIfNeeded } from './refresh' | ||
7 | import { fetchRemoteVideoPlaylist } from './shared' | ||
8 | |||
9 | async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise<MVideoPlaylistFullSummary> { | ||
10 | const playlistUrl = getAPId(playlistObjectArg) | ||
11 | |||
12 | const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl) | ||
13 | |||
14 | if (playlistFromDatabase) { | ||
15 | scheduleRefreshIfNeeded(playlistFromDatabase) | ||
16 | |||
17 | return playlistFromDatabase | ||
18 | } | ||
19 | |||
20 | const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl) | ||
21 | if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl) | ||
22 | |||
23 | // playlistUrl is just an alias/redirection, so process object id instead | ||
24 | if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject) | ||
25 | |||
26 | const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject) | ||
27 | |||
28 | return playlistCreated | ||
29 | } | ||
30 | |||
31 | // --------------------------------------------------------------------------- | ||
32 | |||
33 | export { | ||
34 | getOrCreateAPVideoPlaylist | ||
35 | } | ||
diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts deleted file mode 100644 index e2470a674..000000000 --- a/server/lib/activitypub/playlists/index.ts +++ /dev/null | |||
@@ -1,3 +0,0 @@ | |||
1 | export * from './get' | ||
2 | export * from './create-update' | ||
3 | export * from './refresh' | ||
diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts deleted file mode 100644 index 33260ea02..000000000 --- a/server/lib/activitypub/playlists/refresh.ts +++ /dev/null | |||
@@ -1,53 +0,0 @@ | |||
1 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
2 | import { PeerTubeRequestError } from '@server/helpers/requests' | ||
3 | import { JobQueue } from '@server/lib/job-queue' | ||
4 | import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | import { createOrUpdateVideoPlaylist } from './create-update' | ||
7 | import { fetchRemoteVideoPlaylist } from './shared' | ||
8 | |||
9 | function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) { | ||
10 | if (!playlist.isOutdated()) return | ||
11 | |||
12 | JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } }) | ||
13 | } | ||
14 | |||
15 | async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> { | ||
16 | if (!videoPlaylist.isOutdated()) return videoPlaylist | ||
17 | |||
18 | const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url) | ||
19 | |||
20 | logger.info('Refreshing playlist %s.', videoPlaylist.url, lTags()) | ||
21 | |||
22 | try { | ||
23 | const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url) | ||
24 | |||
25 | if (playlistObject === undefined) { | ||
26 | logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags()) | ||
27 | |||
28 | await videoPlaylist.setAsRefreshed() | ||
29 | return videoPlaylist | ||
30 | } | ||
31 | |||
32 | await createOrUpdateVideoPlaylist(playlistObject) | ||
33 | |||
34 | return videoPlaylist | ||
35 | } catch (err) { | ||
36 | if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) { | ||
37 | logger.info('Cannot refresh not existing playlist %s. Deleting it.', videoPlaylist.url, lTags()) | ||
38 | |||
39 | await videoPlaylist.destroy() | ||
40 | return undefined | ||
41 | } | ||
42 | |||
43 | logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() }) | ||
44 | |||
45 | await videoPlaylist.setAsRefreshed() | ||
46 | return videoPlaylist | ||
47 | } | ||
48 | } | ||
49 | |||
50 | export { | ||
51 | scheduleRefreshIfNeeded, | ||
52 | refreshVideoPlaylistIfNeeded | ||
53 | } | ||
diff --git a/server/lib/activitypub/playlists/shared/index.ts b/server/lib/activitypub/playlists/shared/index.ts deleted file mode 100644 index a217f2291..000000000 --- a/server/lib/activitypub/playlists/shared/index.ts +++ /dev/null | |||
@@ -1,2 +0,0 @@ | |||
1 | export * from './object-to-model-attributes' | ||
2 | export * from './url-to-object' | ||
diff --git a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts deleted file mode 100644 index 753b5e660..000000000 --- a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts +++ /dev/null | |||
@@ -1,40 +0,0 @@ | |||
1 | import { ACTIVITY_PUB } from '@server/initializers/constants' | ||
2 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
3 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' | ||
4 | import { MVideoId, MVideoPlaylistId } from '@server/types/models' | ||
5 | import { AttributesOnly } from '@shared/typescript-utils' | ||
6 | import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' | ||
7 | |||
8 | function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) { | ||
9 | const privacy = to.includes(ACTIVITY_PUB.PUBLIC) | ||
10 | ? VideoPlaylistPrivacy.PUBLIC | ||
11 | : VideoPlaylistPrivacy.UNLISTED | ||
12 | |||
13 | return { | ||
14 | name: playlistObject.name, | ||
15 | description: playlistObject.content, | ||
16 | privacy, | ||
17 | url: playlistObject.id, | ||
18 | uuid: playlistObject.uuid, | ||
19 | ownerAccountId: null, | ||
20 | videoChannelId: null, | ||
21 | createdAt: new Date(playlistObject.published), | ||
22 | updatedAt: new Date(playlistObject.updated) | ||
23 | } as AttributesOnly<VideoPlaylistModel> | ||
24 | } | ||
25 | |||
26 | function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) { | ||
27 | return { | ||
28 | position: elementObject.position, | ||
29 | url: elementObject.id, | ||
30 | startTimestamp: elementObject.startTimestamp || null, | ||
31 | stopTimestamp: elementObject.stopTimestamp || null, | ||
32 | videoPlaylistId: videoPlaylist.id, | ||
33 | videoId: video.id | ||
34 | } as AttributesOnly<VideoPlaylistElementModel> | ||
35 | } | ||
36 | |||
37 | export { | ||
38 | playlistObjectToDBAttributes, | ||
39 | playlistElementObjectToDBAttributes | ||
40 | } | ||
diff --git a/server/lib/activitypub/playlists/shared/url-to-object.ts b/server/lib/activitypub/playlists/shared/url-to-object.ts deleted file mode 100644 index fd9fe5558..000000000 --- a/server/lib/activitypub/playlists/shared/url-to-object.ts +++ /dev/null | |||
@@ -1,47 +0,0 @@ | |||
1 | import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist' | ||
2 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { PlaylistElementObject, PlaylistObject } from '@shared/models' | ||
5 | import { fetchAP } from '../../activity' | ||
6 | import { checkUrlsSameHost } from '../../url' | ||
7 | |||
8 | async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> { | ||
9 | const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl) | ||
10 | |||
11 | logger.info('Fetching remote playlist %s.', playlistUrl, lTags()) | ||
12 | |||
13 | const { body, statusCode } = await fetchAP<any>(playlistUrl) | ||
14 | |||
15 | if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) { | ||
16 | logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() }) | ||
17 | return { statusCode, playlistObject: undefined } | ||
18 | } | ||
19 | |||
20 | if (!isArray(body.to)) { | ||
21 | logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() }) | ||
22 | return { statusCode, playlistObject: undefined } | ||
23 | } | ||
24 | |||
25 | return { statusCode, playlistObject: body } | ||
26 | } | ||
27 | |||
28 | async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> { | ||
29 | const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl) | ||
30 | |||
31 | logger.debug('Fetching remote playlist element %s.', elementUrl, lTags()) | ||
32 | |||
33 | const { body, statusCode } = await fetchAP<PlaylistElementObject>(elementUrl) | ||
34 | |||
35 | if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`) | ||
36 | |||
37 | if (checkUrlsSameHost(body.id, elementUrl) !== true) { | ||
38 | throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`) | ||
39 | } | ||
40 | |||
41 | return { statusCode, elementObject: body } | ||
42 | } | ||
43 | |||
44 | export { | ||
45 | fetchRemoteVideoPlaylist, | ||
46 | fetchRemotePlaylistElement | ||
47 | } | ||
diff --git a/server/lib/activitypub/process/index.ts b/server/lib/activitypub/process/index.ts deleted file mode 100644 index 5466739c1..000000000 --- a/server/lib/activitypub/process/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './process' | ||
diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts deleted file mode 100644 index 077b01eda..000000000 --- a/server/lib/activitypub/process/process-accept.ts +++ /dev/null | |||
@@ -1,32 +0,0 @@ | |||
1 | import { ActivityAccept } from '../../../../shared/models/activitypub' | ||
2 | import { ActorFollowModel } from '../../../models/actor/actor-follow' | ||
3 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
4 | import { MActorDefault, MActorSignature } from '../../../types/models' | ||
5 | import { addFetchOutboxJob } from '../outbox' | ||
6 | |||
7 | async function processAcceptActivity (options: APProcessorOptions<ActivityAccept>) { | ||
8 | const { byActor: targetActor, inboxActor } = options | ||
9 | if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.') | ||
10 | |||
11 | return processAccept(inboxActor, targetActor) | ||
12 | } | ||
13 | |||
14 | // --------------------------------------------------------------------------- | ||
15 | |||
16 | export { | ||
17 | processAcceptActivity | ||
18 | } | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | async function processAccept (actor: MActorDefault, targetActor: MActorSignature) { | ||
23 | const follow = await ActorFollowModel.loadByActorAndTarget(actor.id, targetActor.id) | ||
24 | if (!follow) throw new Error('Cannot find associated follow.') | ||
25 | |||
26 | if (follow.state !== 'accepted') { | ||
27 | follow.state = 'accepted' | ||
28 | await follow.save() | ||
29 | |||
30 | await addFetchOutboxJob(targetActor) | ||
31 | } | ||
32 | } | ||
diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts deleted file mode 100644 index 9cc87ee27..000000000 --- a/server/lib/activitypub/process/process-announce.ts +++ /dev/null | |||
@@ -1,75 +0,0 @@ | |||
1 | import { getAPId } from '@server/lib/activitypub/activity' | ||
2 | import { ActivityAnnounce } from '../../../../shared/models/activitypub' | ||
3 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
4 | import { logger } from '../../../helpers/logger' | ||
5 | import { sequelizeTypescript } from '../../../initializers/database' | ||
6 | import { VideoShareModel } from '../../../models/video/video-share' | ||
7 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
8 | import { MActorSignature, MVideoAccountLightBlacklistAllFiles } from '../../../types/models' | ||
9 | import { Notifier } from '../../notifier' | ||
10 | import { forwardVideoRelatedActivity } from '../send/shared/send-utils' | ||
11 | import { getOrCreateAPVideo } from '../videos' | ||
12 | |||
13 | async function processAnnounceActivity (options: APProcessorOptions<ActivityAnnounce>) { | ||
14 | const { activity, byActor: actorAnnouncer } = options | ||
15 | // Only notify if it is not from a fetcher job | ||
16 | const notify = options.fromFetch !== true | ||
17 | |||
18 | // Announces on accounts are not supported | ||
19 | if (actorAnnouncer.type !== 'Application' && actorAnnouncer.type !== 'Group') return | ||
20 | |||
21 | return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity, notify) | ||
22 | } | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | processAnnounceActivity | ||
28 | } | ||
29 | |||
30 | // --------------------------------------------------------------------------- | ||
31 | |||
32 | async function processVideoShare (actorAnnouncer: MActorSignature, activity: ActivityAnnounce, notify: boolean) { | ||
33 | const objectUri = getAPId(activity.object) | ||
34 | |||
35 | let video: MVideoAccountLightBlacklistAllFiles | ||
36 | let videoCreated: boolean | ||
37 | |||
38 | try { | ||
39 | const result = await getOrCreateAPVideo({ videoObject: objectUri }) | ||
40 | video = result.video | ||
41 | videoCreated = result.created | ||
42 | } catch (err) { | ||
43 | logger.debug('Cannot process share of %s. Maybe this is not a video object, so just skipping.', objectUri, { err }) | ||
44 | return | ||
45 | } | ||
46 | |||
47 | await sequelizeTypescript.transaction(async t => { | ||
48 | // Add share entry | ||
49 | |||
50 | const share = { | ||
51 | actorId: actorAnnouncer.id, | ||
52 | videoId: video.id, | ||
53 | url: activity.id | ||
54 | } | ||
55 | |||
56 | const [ , created ] = await VideoShareModel.findOrCreate({ | ||
57 | where: { | ||
58 | url: activity.id | ||
59 | }, | ||
60 | defaults: share, | ||
61 | transaction: t | ||
62 | }) | ||
63 | |||
64 | if (video.isOwned() && created === true) { | ||
65 | // Don't resend the activity to the sender | ||
66 | const exceptions = [ actorAnnouncer ] | ||
67 | |||
68 | await forwardVideoRelatedActivity(activity, t, exceptions, video) | ||
69 | } | ||
70 | |||
71 | return undefined | ||
72 | }) | ||
73 | |||
74 | if (videoCreated && notify) Notifier.Instance.notifyOnNewVideoIfNeeded(video) | ||
75 | } | ||
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts deleted file mode 100644 index 5f980de65..000000000 --- a/server/lib/activitypub/process/process-create.ts +++ /dev/null | |||
@@ -1,170 +0,0 @@ | |||
1 | import { isBlockedByServerOrAccount } from '@server/lib/blocklist' | ||
2 | import { isRedundancyAccepted } from '@server/lib/redundancy' | ||
3 | import { VideoModel } from '@server/models/video/video' | ||
4 | import { | ||
5 | AbuseObject, | ||
6 | ActivityCreate, | ||
7 | ActivityCreateObject, | ||
8 | ActivityObject, | ||
9 | CacheFileObject, | ||
10 | PlaylistObject, | ||
11 | VideoCommentObject, | ||
12 | VideoObject, | ||
13 | WatchActionObject | ||
14 | } from '@shared/models' | ||
15 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
16 | import { logger } from '../../../helpers/logger' | ||
17 | import { sequelizeTypescript } from '../../../initializers/database' | ||
18 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
19 | import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models' | ||
20 | import { Notifier } from '../../notifier' | ||
21 | import { fetchAPObjectIfNeeded } from '../activity' | ||
22 | import { createOrUpdateCacheFile } from '../cache-file' | ||
23 | import { createOrUpdateLocalVideoViewer } from '../local-video-viewer' | ||
24 | import { createOrUpdateVideoPlaylist } from '../playlists' | ||
25 | import { forwardVideoRelatedActivity } from '../send/shared/send-utils' | ||
26 | import { resolveThread } from '../video-comments' | ||
27 | import { getOrCreateAPVideo } from '../videos' | ||
28 | |||
29 | async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) { | ||
30 | const { activity, byActor } = options | ||
31 | |||
32 | // Only notify if it is not from a fetcher job | ||
33 | const notify = options.fromFetch !== true | ||
34 | const activityObject = await fetchAPObjectIfNeeded<Exclude<ActivityObject, AbuseObject>>(activity.object) | ||
35 | const activityType = activityObject.type | ||
36 | |||
37 | if (activityType === 'Video') { | ||
38 | return processCreateVideo(activityObject, notify) | ||
39 | } | ||
40 | |||
41 | if (activityType === 'Note') { | ||
42 | // Comments will be fetched from videos | ||
43 | if (options.fromFetch) return | ||
44 | |||
45 | return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, notify) | ||
46 | } | ||
47 | |||
48 | if (activityType === 'WatchAction') { | ||
49 | return retryTransactionWrapper(processCreateWatchAction, activityObject) | ||
50 | } | ||
51 | |||
52 | if (activityType === 'CacheFile') { | ||
53 | return retryTransactionWrapper(processCreateCacheFile, activity, activityObject, byActor) | ||
54 | } | ||
55 | |||
56 | if (activityType === 'Playlist') { | ||
57 | return retryTransactionWrapper(processCreatePlaylist, activity, activityObject, byActor) | ||
58 | } | ||
59 | |||
60 | logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) | ||
61 | return Promise.resolve(undefined) | ||
62 | } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | export { | ||
67 | processCreateActivity | ||
68 | } | ||
69 | |||
70 | // --------------------------------------------------------------------------- | ||
71 | |||
72 | async function processCreateVideo (videoToCreateData: VideoObject, notify: boolean) { | ||
73 | const syncParam = { rates: false, shares: false, comments: false, refreshVideo: false } | ||
74 | const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam }) | ||
75 | |||
76 | if (created && notify) Notifier.Instance.notifyOnNewVideoIfNeeded(video) | ||
77 | |||
78 | return video | ||
79 | } | ||
80 | |||
81 | async function processCreateCacheFile ( | ||
82 | activity: ActivityCreate<CacheFileObject | string>, | ||
83 | cacheFile: CacheFileObject, | ||
84 | byActor: MActorSignature | ||
85 | ) { | ||
86 | if (await isRedundancyAccepted(activity, byActor) !== true) return | ||
87 | |||
88 | const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object }) | ||
89 | |||
90 | await sequelizeTypescript.transaction(async t => { | ||
91 | return createOrUpdateCacheFile(cacheFile, video, byActor, t) | ||
92 | }) | ||
93 | |||
94 | if (video.isOwned()) { | ||
95 | // Don't resend the activity to the sender | ||
96 | const exceptions = [ byActor ] | ||
97 | await forwardVideoRelatedActivity(activity, undefined, exceptions, video) | ||
98 | } | ||
99 | } | ||
100 | |||
101 | async function processCreateWatchAction (watchAction: WatchActionObject) { | ||
102 | if (watchAction.actionStatus !== 'CompletedActionStatus') return | ||
103 | |||
104 | const video = await VideoModel.loadByUrl(watchAction.object) | ||
105 | if (video.remote) return | ||
106 | |||
107 | await sequelizeTypescript.transaction(async t => { | ||
108 | return createOrUpdateLocalVideoViewer(watchAction, video, t) | ||
109 | }) | ||
110 | } | ||
111 | |||
112 | async function processCreateVideoComment ( | ||
113 | activity: ActivityCreate<VideoCommentObject | string>, | ||
114 | commentObject: VideoCommentObject, | ||
115 | byActor: MActorSignature, | ||
116 | notify: boolean | ||
117 | ) { | ||
118 | const byAccount = byActor.Account | ||
119 | |||
120 | if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url) | ||
121 | |||
122 | let video: MVideoAccountLightBlacklistAllFiles | ||
123 | let created: boolean | ||
124 | let comment: MCommentOwnerVideo | ||
125 | |||
126 | try { | ||
127 | const resolveThreadResult = await resolveThread({ url: commentObject.id, isVideo: false }) | ||
128 | if (!resolveThreadResult) return // Comment not accepted | ||
129 | |||
130 | video = resolveThreadResult.video | ||
131 | created = resolveThreadResult.commentCreated | ||
132 | comment = resolveThreadResult.comment | ||
133 | } catch (err) { | ||
134 | logger.debug( | ||
135 | 'Cannot process video comment because we could not resolve thread %s. Maybe it was not a video thread, so skip it.', | ||
136 | commentObject.inReplyTo, | ||
137 | { err } | ||
138 | ) | ||
139 | return | ||
140 | } | ||
141 | |||
142 | // Try to not forward unwanted comments on our videos | ||
143 | if (video.isOwned()) { | ||
144 | if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) { | ||
145 | logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url) | ||
146 | return | ||
147 | } | ||
148 | |||
149 | if (created === true) { | ||
150 | // Don't resend the activity to the sender | ||
151 | const exceptions = [ byActor ] | ||
152 | |||
153 | await forwardVideoRelatedActivity(activity, undefined, exceptions, video) | ||
154 | } | ||
155 | } | ||
156 | |||
157 | if (created && notify) Notifier.Instance.notifyOnNewComment(comment) | ||
158 | } | ||
159 | |||
160 | async function processCreatePlaylist ( | ||
161 | activity: ActivityCreate<PlaylistObject | string>, | ||
162 | playlistObject: PlaylistObject, | ||
163 | byActor: MActorSignature | ||
164 | ) { | ||
165 | const byAccount = byActor.Account | ||
166 | |||
167 | if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) | ||
168 | |||
169 | await createOrUpdateVideoPlaylist(playlistObject, activity.to) | ||
170 | } | ||
diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts deleted file mode 100644 index ac0e7e235..000000000 --- a/server/lib/activitypub/process/process-delete.ts +++ /dev/null | |||
@@ -1,153 +0,0 @@ | |||
1 | import { ActivityDelete } from '../../../../shared/models/activitypub' | ||
2 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { sequelizeTypescript } from '../../../initializers/database' | ||
5 | import { ActorModel } from '../../../models/actor/actor' | ||
6 | import { VideoModel } from '../../../models/video/video' | ||
7 | import { VideoCommentModel } from '../../../models/video/video-comment' | ||
8 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
9 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
10 | import { | ||
11 | MAccountActor, | ||
12 | MActor, | ||
13 | MActorFull, | ||
14 | MActorSignature, | ||
15 | MChannelAccountActor, | ||
16 | MChannelActor, | ||
17 | MCommentOwnerVideo | ||
18 | } from '../../../types/models' | ||
19 | import { forwardVideoRelatedActivity } from '../send/shared/send-utils' | ||
20 | |||
21 | async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) { | ||
22 | const { activity, byActor } = options | ||
23 | |||
24 | const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id | ||
25 | |||
26 | if (activity.actor === objectUrl) { | ||
27 | // We need more attributes (all the account and channel) | ||
28 | const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url) | ||
29 | |||
30 | if (byActorFull.type === 'Person') { | ||
31 | if (!byActorFull.Account) throw new Error('Actor ' + byActorFull.url + ' is a person but we cannot find it in database.') | ||
32 | |||
33 | const accountToDelete = byActorFull.Account as MAccountActor | ||
34 | accountToDelete.Actor = byActorFull | ||
35 | |||
36 | return retryTransactionWrapper(processDeleteAccount, accountToDelete) | ||
37 | } else if (byActorFull.type === 'Group') { | ||
38 | if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.') | ||
39 | |||
40 | const channelToDelete = byActorFull.VideoChannel as MChannelAccountActor & { Actor: MActorFull } | ||
41 | channelToDelete.Actor = byActorFull | ||
42 | return retryTransactionWrapper(processDeleteVideoChannel, channelToDelete) | ||
43 | } | ||
44 | } | ||
45 | |||
46 | { | ||
47 | const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(objectUrl) | ||
48 | if (videoCommentInstance) { | ||
49 | return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity) | ||
50 | } | ||
51 | } | ||
52 | |||
53 | { | ||
54 | const videoInstance = await VideoModel.loadByUrlAndPopulateAccount(objectUrl) | ||
55 | if (videoInstance) { | ||
56 | if (videoInstance.isOwned()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`) | ||
57 | |||
58 | return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance) | ||
59 | } | ||
60 | } | ||
61 | |||
62 | { | ||
63 | const videoPlaylist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(objectUrl) | ||
64 | if (videoPlaylist) { | ||
65 | if (videoPlaylist.isOwned()) throw new Error(`Remote instance cannot delete owned playlist ${videoPlaylist.url}.`) | ||
66 | |||
67 | return retryTransactionWrapper(processDeleteVideoPlaylist, byActor, videoPlaylist) | ||
68 | } | ||
69 | } | ||
70 | |||
71 | return undefined | ||
72 | } | ||
73 | |||
74 | // --------------------------------------------------------------------------- | ||
75 | |||
76 | export { | ||
77 | processDeleteActivity | ||
78 | } | ||
79 | |||
80 | // --------------------------------------------------------------------------- | ||
81 | |||
82 | async function processDeleteVideo (actor: MActor, videoToDelete: VideoModel) { | ||
83 | logger.debug('Removing remote video "%s".', videoToDelete.uuid) | ||
84 | |||
85 | await sequelizeTypescript.transaction(async t => { | ||
86 | if (videoToDelete.VideoChannel.Account.Actor.id !== actor.id) { | ||
87 | throw new Error('Account ' + actor.url + ' does not own video channel ' + videoToDelete.VideoChannel.Actor.url) | ||
88 | } | ||
89 | |||
90 | await videoToDelete.destroy({ transaction: t }) | ||
91 | }) | ||
92 | |||
93 | logger.info('Remote video with uuid %s removed.', videoToDelete.uuid) | ||
94 | } | ||
95 | |||
96 | async function processDeleteVideoPlaylist (actor: MActor, playlistToDelete: VideoPlaylistModel) { | ||
97 | logger.debug('Removing remote video playlist "%s".', playlistToDelete.uuid) | ||
98 | |||
99 | await sequelizeTypescript.transaction(async t => { | ||
100 | if (playlistToDelete.OwnerAccount.Actor.id !== actor.id) { | ||
101 | throw new Error('Account ' + actor.url + ' does not own video playlist ' + playlistToDelete.url) | ||
102 | } | ||
103 | |||
104 | await playlistToDelete.destroy({ transaction: t }) | ||
105 | }) | ||
106 | |||
107 | logger.info('Remote video playlist with uuid %s removed.', playlistToDelete.uuid) | ||
108 | } | ||
109 | |||
110 | async function processDeleteAccount (accountToRemove: MAccountActor) { | ||
111 | logger.debug('Removing remote account "%s".', accountToRemove.Actor.url) | ||
112 | |||
113 | await sequelizeTypescript.transaction(async t => { | ||
114 | await accountToRemove.destroy({ transaction: t }) | ||
115 | }) | ||
116 | |||
117 | logger.info('Remote account %s removed.', accountToRemove.Actor.url) | ||
118 | } | ||
119 | |||
120 | async function processDeleteVideoChannel (videoChannelToRemove: MChannelActor) { | ||
121 | logger.debug('Removing remote video channel "%s".', videoChannelToRemove.Actor.url) | ||
122 | |||
123 | await sequelizeTypescript.transaction(async t => { | ||
124 | await videoChannelToRemove.destroy({ transaction: t }) | ||
125 | }) | ||
126 | |||
127 | logger.info('Remote video channel %s removed.', videoChannelToRemove.Actor.url) | ||
128 | } | ||
129 | |||
130 | function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) { | ||
131 | // Already deleted | ||
132 | if (videoComment.isDeleted()) return Promise.resolve() | ||
133 | |||
134 | logger.debug('Removing remote video comment "%s".', videoComment.url) | ||
135 | |||
136 | return sequelizeTypescript.transaction(async t => { | ||
137 | if (byActor.Account.id !== videoComment.Account.id && byActor.Account.id !== videoComment.Video.VideoChannel.accountId) { | ||
138 | throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`) | ||
139 | } | ||
140 | |||
141 | videoComment.markAsDeleted() | ||
142 | |||
143 | await videoComment.save({ transaction: t }) | ||
144 | |||
145 | if (videoComment.Video.isOwned()) { | ||
146 | // Don't resend the activity to the sender | ||
147 | const exceptions = [ byActor ] | ||
148 | await forwardVideoRelatedActivity(activity, t, exceptions, videoComment.Video) | ||
149 | } | ||
150 | |||
151 | logger.info('Remote video comment %s removed.', videoComment.url) | ||
152 | }) | ||
153 | } | ||
diff --git a/server/lib/activitypub/process/process-dislike.ts b/server/lib/activitypub/process/process-dislike.ts deleted file mode 100644 index 4e270f917..000000000 --- a/server/lib/activitypub/process/process-dislike.ts +++ /dev/null | |||
@@ -1,58 +0,0 @@ | |||
1 | import { VideoModel } from '@server/models/video/video' | ||
2 | import { ActivityDislike } from '@shared/models' | ||
3 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
4 | import { sequelizeTypescript } from '../../../initializers/database' | ||
5 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | ||
6 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
7 | import { MActorSignature } from '../../../types/models' | ||
8 | import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos' | ||
9 | |||
10 | async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) { | ||
11 | const { activity, byActor } = options | ||
12 | return retryTransactionWrapper(processDislike, activity, byActor) | ||
13 | } | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | export { | ||
18 | processDislikeActivity | ||
19 | } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | async function processDislike (activity: ActivityDislike, byActor: MActorSignature) { | ||
24 | const dislikeObject = activity.object | ||
25 | const byAccount = byActor.Account | ||
26 | |||
27 | if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url) | ||
28 | |||
29 | const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: dislikeObject, fetchType: 'only-video' }) | ||
30 | |||
31 | // We don't care about dislikes of remote videos | ||
32 | if (!onlyVideo.isOwned()) return | ||
33 | |||
34 | return sequelizeTypescript.transaction(async t => { | ||
35 | const video = await VideoModel.loadFull(onlyVideo.id, t) | ||
36 | |||
37 | const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t) | ||
38 | if (existingRate && existingRate.type === 'dislike') return | ||
39 | |||
40 | await video.increment('dislikes', { transaction: t }) | ||
41 | video.dislikes++ | ||
42 | |||
43 | if (existingRate && existingRate.type === 'like') { | ||
44 | await video.decrement('likes', { transaction: t }) | ||
45 | video.likes-- | ||
46 | } | ||
47 | |||
48 | const rate = existingRate || new AccountVideoRateModel() | ||
49 | rate.type = 'dislike' | ||
50 | rate.videoId = video.id | ||
51 | rate.accountId = byAccount.id | ||
52 | rate.url = activity.id | ||
53 | |||
54 | await rate.save({ transaction: t }) | ||
55 | |||
56 | await federateVideoIfNeeded(video, false, t) | ||
57 | }) | ||
58 | } | ||
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts deleted file mode 100644 index bea285670..000000000 --- a/server/lib/activitypub/process/process-flag.ts +++ /dev/null | |||
@@ -1,103 +0,0 @@ | |||
1 | import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation' | ||
2 | import { AccountModel } from '@server/models/account/account' | ||
3 | import { VideoModel } from '@server/models/video/video' | ||
4 | import { VideoCommentModel } from '@server/models/video/video-comment' | ||
5 | import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse' | ||
6 | import { AbuseState, ActivityFlag } from '@shared/models' | ||
7 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
8 | import { logger } from '../../../helpers/logger' | ||
9 | import { sequelizeTypescript } from '../../../initializers/database' | ||
10 | import { getAPId } from '../../../lib/activitypub/activity' | ||
11 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
12 | import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models' | ||
13 | |||
14 | async function processFlagActivity (options: APProcessorOptions<ActivityFlag>) { | ||
15 | const { activity, byActor } = options | ||
16 | |||
17 | return retryTransactionWrapper(processCreateAbuse, activity, byActor) | ||
18 | } | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | export { | ||
23 | processFlagActivity | ||
24 | } | ||
25 | |||
26 | // --------------------------------------------------------------------------- | ||
27 | |||
28 | async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature) { | ||
29 | const account = byActor.Account | ||
30 | if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url) | ||
31 | |||
32 | const reporterAccount = await AccountModel.load(account.id) | ||
33 | |||
34 | const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ] | ||
35 | |||
36 | const tags = Array.isArray(flag.tag) ? flag.tag : [] | ||
37 | const predefinedReasons = tags.map(tag => abusePredefinedReasonsMap[tag.name]) | ||
38 | .filter(v => !isNaN(v)) | ||
39 | |||
40 | const startAt = flag.startAt | ||
41 | const endAt = flag.endAt | ||
42 | |||
43 | for (const object of objects) { | ||
44 | try { | ||
45 | const uri = getAPId(object) | ||
46 | |||
47 | logger.debug('Reporting remote abuse for object %s.', uri) | ||
48 | |||
49 | await sequelizeTypescript.transaction(async t => { | ||
50 | const video = await VideoModel.loadByUrlAndPopulateAccount(uri, t) | ||
51 | let videoComment: MCommentOwnerVideo | ||
52 | let flaggedAccount: MAccountDefault | ||
53 | |||
54 | if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri, t) | ||
55 | if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri, t) | ||
56 | |||
57 | if (!video && !videoComment && !flaggedAccount) { | ||
58 | logger.warn('Cannot flag unknown entity %s.', object) | ||
59 | return | ||
60 | } | ||
61 | |||
62 | const baseAbuse = { | ||
63 | reporterAccountId: reporterAccount.id, | ||
64 | reason: flag.content, | ||
65 | state: AbuseState.PENDING, | ||
66 | predefinedReasons | ||
67 | } | ||
68 | |||
69 | if (video) { | ||
70 | return createVideoAbuse({ | ||
71 | baseAbuse, | ||
72 | startAt, | ||
73 | endAt, | ||
74 | reporterAccount, | ||
75 | transaction: t, | ||
76 | videoInstance: video, | ||
77 | skipNotification: false | ||
78 | }) | ||
79 | } | ||
80 | |||
81 | if (videoComment) { | ||
82 | return createVideoCommentAbuse({ | ||
83 | baseAbuse, | ||
84 | reporterAccount, | ||
85 | transaction: t, | ||
86 | commentInstance: videoComment, | ||
87 | skipNotification: false | ||
88 | }) | ||
89 | } | ||
90 | |||
91 | return await createAccountAbuse({ | ||
92 | baseAbuse, | ||
93 | reporterAccount, | ||
94 | transaction: t, | ||
95 | accountInstance: flaggedAccount, | ||
96 | skipNotification: false | ||
97 | }) | ||
98 | }) | ||
99 | } catch (err) { | ||
100 | logger.debug('Cannot process report of %s', getAPId(object), { err }) | ||
101 | } | ||
102 | } | ||
103 | } | ||
diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts deleted file mode 100644 index 7def753d5..000000000 --- a/server/lib/activitypub/process/process-follow.ts +++ /dev/null | |||
@@ -1,156 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize/types' | ||
2 | import { isBlockedByServerOrAccount } from '@server/lib/blocklist' | ||
3 | import { AccountModel } from '@server/models/account/account' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { ActivityFollow } from '../../../../shared/models/activitypub' | ||
6 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
7 | import { logger } from '../../../helpers/logger' | ||
8 | import { CONFIG } from '../../../initializers/config' | ||
9 | import { sequelizeTypescript } from '../../../initializers/database' | ||
10 | import { getAPId } from '../../../lib/activitypub/activity' | ||
11 | import { ActorModel } from '../../../models/actor/actor' | ||
12 | import { ActorFollowModel } from '../../../models/actor/actor-follow' | ||
13 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
14 | import { MActorFollow, MActorFull, MActorId, MActorSignature } from '../../../types/models' | ||
15 | import { Notifier } from '../../notifier' | ||
16 | import { autoFollowBackIfNeeded } from '../follow' | ||
17 | import { sendAccept, sendReject } from '../send' | ||
18 | |||
19 | async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) { | ||
20 | const { activity, byActor } = options | ||
21 | |||
22 | const activityId = activity.id | ||
23 | const objectId = getAPId(activity.object) | ||
24 | |||
25 | return retryTransactionWrapper(processFollow, byActor, activityId, objectId) | ||
26 | } | ||
27 | |||
28 | // --------------------------------------------------------------------------- | ||
29 | |||
30 | export { | ||
31 | processFollowActivity | ||
32 | } | ||
33 | |||
34 | // --------------------------------------------------------------------------- | ||
35 | |||
36 | async function processFollow (byActor: MActorSignature, activityId: string, targetActorURL: string) { | ||
37 | const { actorFollow, created, targetActor } = await sequelizeTypescript.transaction(async t => { | ||
38 | const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t) | ||
39 | |||
40 | if (!targetActor) throw new Error('Unknown actor') | ||
41 | if (targetActor.isOwned() === false) throw new Error('This is not a local actor.') | ||
42 | |||
43 | if (await rejectIfInstanceFollowDisabled(byActor, activityId, targetActor)) return { actorFollow: undefined } | ||
44 | if (await rejectIfMuted(byActor, activityId, targetActor)) return { actorFollow: undefined } | ||
45 | |||
46 | const [ actorFollow, created ] = await ActorFollowModel.findOrCreateCustom({ | ||
47 | byActor, | ||
48 | targetActor, | ||
49 | activityId, | ||
50 | state: await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL | ||
51 | ? 'pending' | ||
52 | : 'accepted', | ||
53 | transaction: t | ||
54 | }) | ||
55 | |||
56 | if (rejectIfAlreadyRejected(actorFollow, byActor, activityId, targetActor)) return { actorFollow: undefined } | ||
57 | |||
58 | await acceptIfNeeded(actorFollow, targetActor, t) | ||
59 | |||
60 | await fixFollowURLIfNeeded(actorFollow, activityId, t) | ||
61 | |||
62 | actorFollow.ActorFollower = byActor | ||
63 | actorFollow.ActorFollowing = targetActor | ||
64 | |||
65 | // Target sends to actor he accepted the follow request | ||
66 | if (actorFollow.state === 'accepted') { | ||
67 | sendAccept(actorFollow) | ||
68 | |||
69 | await autoFollowBackIfNeeded(actorFollow, t) | ||
70 | } | ||
71 | |||
72 | return { actorFollow, created, targetActor } | ||
73 | }) | ||
74 | |||
75 | // Rejected | ||
76 | if (!actorFollow) return | ||
77 | |||
78 | if (created) { | ||
79 | const follower = await ActorModel.loadFull(byActor.id) | ||
80 | const actorFollowFull = Object.assign(actorFollow, { ActorFollowing: targetActor, ActorFollower: follower }) | ||
81 | |||
82 | if (await isFollowingInstance(targetActor)) { | ||
83 | Notifier.Instance.notifyOfNewInstanceFollow(actorFollowFull) | ||
84 | } else { | ||
85 | Notifier.Instance.notifyOfNewUserFollow(actorFollowFull) | ||
86 | } | ||
87 | } | ||
88 | |||
89 | logger.info('Actor %s is followed by actor %s.', targetActorURL, byActor.url) | ||
90 | } | ||
91 | |||
92 | async function rejectIfInstanceFollowDisabled (byActor: MActorSignature, activityId: string, targetActor: MActorFull) { | ||
93 | if (await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) { | ||
94 | logger.info('Rejecting %s because instance followers are disabled.', targetActor.url) | ||
95 | |||
96 | sendReject(activityId, byActor, targetActor) | ||
97 | |||
98 | return true | ||
99 | } | ||
100 | |||
101 | return false | ||
102 | } | ||
103 | |||
104 | async function rejectIfMuted (byActor: MActorSignature, activityId: string, targetActor: MActorFull) { | ||
105 | const followerAccount = await AccountModel.load(byActor.Account.id) | ||
106 | const followingAccountId = targetActor.Account | ||
107 | |||
108 | if (followerAccount && await isBlockedByServerOrAccount(followerAccount, followingAccountId)) { | ||
109 | logger.info('Rejecting %s because follower is muted.', byActor.url) | ||
110 | |||
111 | sendReject(activityId, byActor, targetActor) | ||
112 | |||
113 | return true | ||
114 | } | ||
115 | |||
116 | return false | ||
117 | } | ||
118 | |||
119 | function rejectIfAlreadyRejected (actorFollow: MActorFollow, byActor: MActorSignature, activityId: string, targetActor: MActorFull) { | ||
120 | // Already rejected | ||
121 | if (actorFollow.state === 'rejected') { | ||
122 | logger.info('Rejecting %s because follow is already rejected.', byActor.url) | ||
123 | |||
124 | sendReject(activityId, byActor, targetActor) | ||
125 | |||
126 | return true | ||
127 | } | ||
128 | |||
129 | return false | ||
130 | } | ||
131 | |||
132 | async function acceptIfNeeded (actorFollow: MActorFollow, targetActor: MActorFull, transaction: Transaction) { | ||
133 | // Set the follow as accepted if the remote actor follows a channel or account | ||
134 | // Or if the instance automatically accepts followers | ||
135 | if (actorFollow.state === 'accepted') return | ||
136 | if (!await isFollowingInstance(targetActor)) return | ||
137 | if (CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL === true && await isFollowingInstance(targetActor)) return | ||
138 | |||
139 | actorFollow.state = 'accepted' | ||
140 | |||
141 | await actorFollow.save({ transaction }) | ||
142 | } | ||
143 | |||
144 | async function fixFollowURLIfNeeded (actorFollow: MActorFollow, activityId: string, transaction: Transaction) { | ||
145 | // Before PeerTube V3 we did not save the follow ID. Try to fix these old follows | ||
146 | if (!actorFollow.url) { | ||
147 | actorFollow.url = activityId | ||
148 | await actorFollow.save({ transaction }) | ||
149 | } | ||
150 | } | ||
151 | |||
152 | async function isFollowingInstance (targetActor: MActorId) { | ||
153 | const serverActor = await getServerActor() | ||
154 | |||
155 | return targetActor.id === serverActor.id | ||
156 | } | ||
diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts deleted file mode 100644 index 580a05bcd..000000000 --- a/server/lib/activitypub/process/process-like.ts +++ /dev/null | |||
@@ -1,60 +0,0 @@ | |||
1 | import { VideoModel } from '@server/models/video/video' | ||
2 | import { ActivityLike } from '../../../../shared/models/activitypub' | ||
3 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
4 | import { sequelizeTypescript } from '../../../initializers/database' | ||
5 | import { getAPId } from '../../../lib/activitypub/activity' | ||
6 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | ||
7 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
8 | import { MActorSignature } from '../../../types/models' | ||
9 | import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos' | ||
10 | |||
11 | async function processLikeActivity (options: APProcessorOptions<ActivityLike>) { | ||
12 | const { activity, byActor } = options | ||
13 | |||
14 | return retryTransactionWrapper(processLikeVideo, byActor, activity) | ||
15 | } | ||
16 | |||
17 | // --------------------------------------------------------------------------- | ||
18 | |||
19 | export { | ||
20 | processLikeActivity | ||
21 | } | ||
22 | |||
23 | // --------------------------------------------------------------------------- | ||
24 | |||
25 | async function processLikeVideo (byActor: MActorSignature, activity: ActivityLike) { | ||
26 | const videoUrl = getAPId(activity.object) | ||
27 | |||
28 | const byAccount = byActor.Account | ||
29 | if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url) | ||
30 | |||
31 | const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video' }) | ||
32 | |||
33 | // We don't care about likes of remote videos | ||
34 | if (!onlyVideo.isOwned()) return | ||
35 | |||
36 | return sequelizeTypescript.transaction(async t => { | ||
37 | const video = await VideoModel.loadFull(onlyVideo.id, t) | ||
38 | |||
39 | const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t) | ||
40 | if (existingRate && existingRate.type === 'like') return | ||
41 | |||
42 | if (existingRate && existingRate.type === 'dislike') { | ||
43 | await video.decrement('dislikes', { transaction: t }) | ||
44 | video.dislikes-- | ||
45 | } | ||
46 | |||
47 | await video.increment('likes', { transaction: t }) | ||
48 | video.likes++ | ||
49 | |||
50 | const rate = existingRate || new AccountVideoRateModel() | ||
51 | rate.type = 'like' | ||
52 | rate.videoId = video.id | ||
53 | rate.accountId = byAccount.id | ||
54 | rate.url = activity.id | ||
55 | |||
56 | await rate.save({ transaction: t }) | ||
57 | |||
58 | await federateVideoIfNeeded(video, false, t) | ||
59 | }) | ||
60 | } | ||
diff --git a/server/lib/activitypub/process/process-reject.ts b/server/lib/activitypub/process/process-reject.ts deleted file mode 100644 index db7ff24d8..000000000 --- a/server/lib/activitypub/process/process-reject.ts +++ /dev/null | |||
@@ -1,33 +0,0 @@ | |||
1 | import { ActivityReject } from '../../../../shared/models/activitypub/activity' | ||
2 | import { sequelizeTypescript } from '../../../initializers/database' | ||
3 | import { ActorFollowModel } from '../../../models/actor/actor-follow' | ||
4 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
5 | import { MActor } from '../../../types/models' | ||
6 | |||
7 | async function processRejectActivity (options: APProcessorOptions<ActivityReject>) { | ||
8 | const { byActor: targetActor, inboxActor } = options | ||
9 | if (inboxActor === undefined) throw new Error('Need to reject on explicit inbox.') | ||
10 | |||
11 | return processReject(inboxActor, targetActor) | ||
12 | } | ||
13 | |||
14 | // --------------------------------------------------------------------------- | ||
15 | |||
16 | export { | ||
17 | processRejectActivity | ||
18 | } | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | async function processReject (follower: MActor, targetActor: MActor) { | ||
23 | return sequelizeTypescript.transaction(async t => { | ||
24 | const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, targetActor.id, t) | ||
25 | |||
26 | if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${targetActor.id}.`) | ||
27 | |||
28 | actorFollow.state = 'rejected' | ||
29 | await actorFollow.save({ transaction: t }) | ||
30 | |||
31 | return undefined | ||
32 | }) | ||
33 | } | ||
diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts deleted file mode 100644 index a9d8199de..000000000 --- a/server/lib/activitypub/process/process-undo.ts +++ /dev/null | |||
@@ -1,183 +0,0 @@ | |||
1 | import { VideoModel } from '@server/models/video/video' | ||
2 | import { | ||
3 | ActivityAnnounce, | ||
4 | ActivityCreate, | ||
5 | ActivityDislike, | ||
6 | ActivityFollow, | ||
7 | ActivityLike, | ||
8 | ActivityUndo, | ||
9 | ActivityUndoObject, | ||
10 | CacheFileObject | ||
11 | } from '../../../../shared/models/activitypub' | ||
12 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
13 | import { logger } from '../../../helpers/logger' | ||
14 | import { sequelizeTypescript } from '../../../initializers/database' | ||
15 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | ||
16 | import { ActorModel } from '../../../models/actor/actor' | ||
17 | import { ActorFollowModel } from '../../../models/actor/actor-follow' | ||
18 | import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' | ||
19 | import { VideoShareModel } from '../../../models/video/video-share' | ||
20 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
21 | import { MActorSignature } from '../../../types/models' | ||
22 | import { fetchAPObjectIfNeeded } from '../activity' | ||
23 | import { forwardVideoRelatedActivity } from '../send/shared/send-utils' | ||
24 | import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos' | ||
25 | |||
26 | async function processUndoActivity (options: APProcessorOptions<ActivityUndo<ActivityUndoObject>>) { | ||
27 | const { activity, byActor } = options | ||
28 | const activityToUndo = activity.object | ||
29 | |||
30 | if (activityToUndo.type === 'Like') { | ||
31 | return retryTransactionWrapper(processUndoLike, byActor, activity) | ||
32 | } | ||
33 | |||
34 | if (activityToUndo.type === 'Create') { | ||
35 | const objectToUndo = await fetchAPObjectIfNeeded<CacheFileObject>(activityToUndo.object) | ||
36 | |||
37 | if (objectToUndo.type === 'CacheFile') { | ||
38 | return retryTransactionWrapper(processUndoCacheFile, byActor, activity, objectToUndo) | ||
39 | } | ||
40 | } | ||
41 | |||
42 | if (activityToUndo.type === 'Dislike') { | ||
43 | return retryTransactionWrapper(processUndoDislike, byActor, activity) | ||
44 | } | ||
45 | |||
46 | if (activityToUndo.type === 'Follow') { | ||
47 | return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo) | ||
48 | } | ||
49 | |||
50 | if (activityToUndo.type === 'Announce') { | ||
51 | return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo) | ||
52 | } | ||
53 | |||
54 | logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id }) | ||
55 | |||
56 | return undefined | ||
57 | } | ||
58 | |||
59 | // --------------------------------------------------------------------------- | ||
60 | |||
61 | export { | ||
62 | processUndoActivity | ||
63 | } | ||
64 | |||
65 | // --------------------------------------------------------------------------- | ||
66 | |||
67 | async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo<ActivityLike>) { | ||
68 | const likeActivity = activity.object | ||
69 | |||
70 | const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: likeActivity.object }) | ||
71 | // We don't care about likes of remote videos | ||
72 | if (!onlyVideo.isOwned()) return | ||
73 | |||
74 | return sequelizeTypescript.transaction(async t => { | ||
75 | if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) | ||
76 | |||
77 | const video = await VideoModel.loadFull(onlyVideo.id, t) | ||
78 | const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, likeActivity.id, t) | ||
79 | if (!rate || rate.type !== 'like') { | ||
80 | logger.warn('Unknown like by account %d for video %d.', byActor.Account.id, video.id) | ||
81 | return | ||
82 | } | ||
83 | |||
84 | await rate.destroy({ transaction: t }) | ||
85 | await video.decrement('likes', { transaction: t }) | ||
86 | |||
87 | video.likes-- | ||
88 | await federateVideoIfNeeded(video, false, t) | ||
89 | }) | ||
90 | } | ||
91 | |||
92 | async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo<ActivityDislike>) { | ||
93 | const dislikeActivity = activity.object | ||
94 | |||
95 | const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: dislikeActivity.object }) | ||
96 | // We don't care about likes of remote videos | ||
97 | if (!onlyVideo.isOwned()) return | ||
98 | |||
99 | return sequelizeTypescript.transaction(async t => { | ||
100 | if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) | ||
101 | |||
102 | const video = await VideoModel.loadFull(onlyVideo.id, t) | ||
103 | const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislikeActivity.id, t) | ||
104 | if (!rate || rate.type !== 'dislike') { | ||
105 | logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id) | ||
106 | return | ||
107 | } | ||
108 | |||
109 | await rate.destroy({ transaction: t }) | ||
110 | await video.decrement('dislikes', { transaction: t }) | ||
111 | video.dislikes-- | ||
112 | |||
113 | await federateVideoIfNeeded(video, false, t) | ||
114 | }) | ||
115 | } | ||
116 | |||
117 | // --------------------------------------------------------------------------- | ||
118 | |||
119 | async function processUndoCacheFile ( | ||
120 | byActor: MActorSignature, | ||
121 | activity: ActivityUndo<ActivityCreate<CacheFileObject>>, | ||
122 | cacheFileObject: CacheFileObject | ||
123 | ) { | ||
124 | const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object }) | ||
125 | |||
126 | return sequelizeTypescript.transaction(async t => { | ||
127 | const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t) | ||
128 | if (!cacheFile) { | ||
129 | logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id) | ||
130 | return | ||
131 | } | ||
132 | |||
133 | if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.') | ||
134 | |||
135 | await cacheFile.destroy({ transaction: t }) | ||
136 | |||
137 | if (video.isOwned()) { | ||
138 | // Don't resend the activity to the sender | ||
139 | const exceptions = [ byActor ] | ||
140 | |||
141 | await forwardVideoRelatedActivity(activity, t, exceptions, video) | ||
142 | } | ||
143 | }) | ||
144 | } | ||
145 | |||
146 | function processUndoAnnounce (byActor: MActorSignature, announceActivity: ActivityAnnounce) { | ||
147 | return sequelizeTypescript.transaction(async t => { | ||
148 | const share = await VideoShareModel.loadByUrl(announceActivity.id, t) | ||
149 | if (!share) { | ||
150 | logger.warn('Unknown video share %d', announceActivity.id) | ||
151 | return | ||
152 | } | ||
153 | |||
154 | if (share.actorId !== byActor.id) throw new Error(`${share.url} is not shared by ${byActor.url}.`) | ||
155 | |||
156 | await share.destroy({ transaction: t }) | ||
157 | |||
158 | if (share.Video.isOwned()) { | ||
159 | // Don't resend the activity to the sender | ||
160 | const exceptions = [ byActor ] | ||
161 | |||
162 | await forwardVideoRelatedActivity(announceActivity, t, exceptions, share.Video) | ||
163 | } | ||
164 | }) | ||
165 | } | ||
166 | |||
167 | // --------------------------------------------------------------------------- | ||
168 | |||
169 | function processUndoFollow (follower: MActorSignature, followActivity: ActivityFollow) { | ||
170 | return sequelizeTypescript.transaction(async t => { | ||
171 | const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t) | ||
172 | const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t) | ||
173 | |||
174 | if (!actorFollow) { | ||
175 | logger.warn('Unknown actor follow %d -> %d.', follower.id, following.id) | ||
176 | return | ||
177 | } | ||
178 | |||
179 | await actorFollow.destroy({ transaction: t }) | ||
180 | |||
181 | return undefined | ||
182 | }) | ||
183 | } | ||
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts deleted file mode 100644 index 304ed9de6..000000000 --- a/server/lib/activitypub/process/process-update.ts +++ /dev/null | |||
@@ -1,119 +0,0 @@ | |||
1 | import { isRedundancyAccepted } from '@server/lib/redundancy' | ||
2 | import { ActivityUpdate, ActivityUpdateObject, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub' | ||
3 | import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor' | ||
4 | import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' | ||
5 | import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file' | ||
6 | import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' | ||
7 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
8 | import { logger } from '../../../helpers/logger' | ||
9 | import { sequelizeTypescript } from '../../../initializers/database' | ||
10 | import { ActorModel } from '../../../models/actor/actor' | ||
11 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
12 | import { MActorFull, MActorSignature } from '../../../types/models' | ||
13 | import { fetchAPObjectIfNeeded } from '../activity' | ||
14 | import { APActorUpdater } from '../actors/updater' | ||
15 | import { createOrUpdateCacheFile } from '../cache-file' | ||
16 | import { createOrUpdateVideoPlaylist } from '../playlists' | ||
17 | import { forwardVideoRelatedActivity } from '../send/shared/send-utils' | ||
18 | import { APVideoUpdater, getOrCreateAPVideo } from '../videos' | ||
19 | |||
20 | async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) { | ||
21 | const { activity, byActor } = options | ||
22 | |||
23 | const object = await fetchAPObjectIfNeeded(activity.object) | ||
24 | const objectType = object.type | ||
25 | |||
26 | if (objectType === 'Video') { | ||
27 | return retryTransactionWrapper(processUpdateVideo, activity) | ||
28 | } | ||
29 | |||
30 | if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') { | ||
31 | // We need more attributes | ||
32 | const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url) | ||
33 | return retryTransactionWrapper(processUpdateActor, byActorFull, object) | ||
34 | } | ||
35 | |||
36 | if (objectType === 'CacheFile') { | ||
37 | // We need more attributes | ||
38 | const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url) | ||
39 | return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity, object) | ||
40 | } | ||
41 | |||
42 | if (objectType === 'Playlist') { | ||
43 | return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object) | ||
44 | } | ||
45 | |||
46 | return undefined | ||
47 | } | ||
48 | |||
49 | // --------------------------------------------------------------------------- | ||
50 | |||
51 | export { | ||
52 | processUpdateActivity | ||
53 | } | ||
54 | |||
55 | // --------------------------------------------------------------------------- | ||
56 | |||
57 | async function processUpdateVideo (activity: ActivityUpdate<VideoObject | string>) { | ||
58 | const videoObject = activity.object as VideoObject | ||
59 | |||
60 | if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) { | ||
61 | logger.debug('Video sent by update is not valid.', { videoObject }) | ||
62 | return undefined | ||
63 | } | ||
64 | |||
65 | const { video, created } = await getOrCreateAPVideo({ | ||
66 | videoObject: videoObject.id, | ||
67 | allowRefresh: false, | ||
68 | fetchType: 'all' | ||
69 | }) | ||
70 | // We did not have this video, it has been created so no need to update | ||
71 | if (created) return | ||
72 | |||
73 | const updater = new APVideoUpdater(videoObject, video) | ||
74 | return updater.update(activity.to) | ||
75 | } | ||
76 | |||
77 | async function processUpdateCacheFile ( | ||
78 | byActor: MActorSignature, | ||
79 | activity: ActivityUpdate<CacheFileObject | string>, | ||
80 | cacheFileObject: CacheFileObject | ||
81 | ) { | ||
82 | if (await isRedundancyAccepted(activity, byActor) !== true) return | ||
83 | |||
84 | if (!isCacheFileObjectValid(cacheFileObject)) { | ||
85 | logger.debug('Cache file object sent by update is not valid.', { cacheFileObject }) | ||
86 | return undefined | ||
87 | } | ||
88 | |||
89 | const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object }) | ||
90 | |||
91 | await sequelizeTypescript.transaction(async t => { | ||
92 | await createOrUpdateCacheFile(cacheFileObject, video, byActor, t) | ||
93 | }) | ||
94 | |||
95 | if (video.isOwned()) { | ||
96 | // Don't resend the activity to the sender | ||
97 | const exceptions = [ byActor ] | ||
98 | |||
99 | await forwardVideoRelatedActivity(activity, undefined, exceptions, video) | ||
100 | } | ||
101 | } | ||
102 | |||
103 | async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) { | ||
104 | logger.debug('Updating remote account "%s".', actorObject.url) | ||
105 | |||
106 | const updater = new APActorUpdater(actorObject, actor) | ||
107 | return updater.update() | ||
108 | } | ||
109 | |||
110 | async function processUpdatePlaylist ( | ||
111 | byActor: MActorSignature, | ||
112 | activity: ActivityUpdate<PlaylistObject | string>, | ||
113 | playlistObject: PlaylistObject | ||
114 | ) { | ||
115 | const byAccount = byActor.Account | ||
116 | if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) | ||
117 | |||
118 | await createOrUpdateVideoPlaylist(playlistObject, activity.to) | ||
119 | } | ||
diff --git a/server/lib/activitypub/process/process-view.ts b/server/lib/activitypub/process/process-view.ts deleted file mode 100644 index e49506d82..000000000 --- a/server/lib/activitypub/process/process-view.ts +++ /dev/null | |||
@@ -1,42 +0,0 @@ | |||
1 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
2 | import { ActivityView } from '../../../../shared/models/activitypub' | ||
3 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
4 | import { MActorSignature } from '../../../types/models' | ||
5 | import { forwardVideoRelatedActivity } from '../send/shared/send-utils' | ||
6 | import { getOrCreateAPVideo } from '../videos' | ||
7 | |||
8 | async function processViewActivity (options: APProcessorOptions<ActivityView>) { | ||
9 | const { activity, byActor } = options | ||
10 | |||
11 | return processCreateView(activity, byActor) | ||
12 | } | ||
13 | |||
14 | // --------------------------------------------------------------------------- | ||
15 | |||
16 | export { | ||
17 | processViewActivity | ||
18 | } | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | async function processCreateView (activity: ActivityView, byActor: MActorSignature) { | ||
23 | const videoObject = activity.object | ||
24 | |||
25 | const { video } = await getOrCreateAPVideo({ | ||
26 | videoObject, | ||
27 | fetchType: 'only-video', | ||
28 | allowRefresh: false | ||
29 | }) | ||
30 | |||
31 | const viewerExpires = activity.expires | ||
32 | ? new Date(activity.expires) | ||
33 | : undefined | ||
34 | |||
35 | await VideoViewsManager.Instance.processRemoteView({ video, viewerId: activity.id, viewerExpires }) | ||
36 | |||
37 | if (video.isOwned()) { | ||
38 | // Forward the view but don't resend the activity to the sender | ||
39 | const exceptions = [ byActor ] | ||
40 | await forwardVideoRelatedActivity(activity, undefined, exceptions, video) | ||
41 | } | ||
42 | } | ||
diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts deleted file mode 100644 index 2bc3dce03..000000000 --- a/server/lib/activitypub/process/process.ts +++ /dev/null | |||
@@ -1,92 +0,0 @@ | |||
1 | import { StatsManager } from '@server/lib/stat-manager' | ||
2 | import { Activity, ActivityType } from '../../../../shared/models/activitypub' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { APProcessorOptions } from '../../../types/activitypub-processor.model' | ||
5 | import { MActorDefault, MActorSignature } from '../../../types/models' | ||
6 | import { getAPId } from '../activity' | ||
7 | import { getOrCreateAPActor } from '../actors' | ||
8 | import { checkUrlsSameHost } from '../url' | ||
9 | import { processAcceptActivity } from './process-accept' | ||
10 | import { processAnnounceActivity } from './process-announce' | ||
11 | import { processCreateActivity } from './process-create' | ||
12 | import { processDeleteActivity } from './process-delete' | ||
13 | import { processDislikeActivity } from './process-dislike' | ||
14 | import { processFlagActivity } from './process-flag' | ||
15 | import { processFollowActivity } from './process-follow' | ||
16 | import { processLikeActivity } from './process-like' | ||
17 | import { processRejectActivity } from './process-reject' | ||
18 | import { processUndoActivity } from './process-undo' | ||
19 | import { processUpdateActivity } from './process-update' | ||
20 | import { processViewActivity } from './process-view' | ||
21 | |||
22 | const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Activity>) => Promise<any> } = { | ||
23 | Create: processCreateActivity, | ||
24 | Update: processUpdateActivity, | ||
25 | Delete: processDeleteActivity, | ||
26 | Follow: processFollowActivity, | ||
27 | Accept: processAcceptActivity, | ||
28 | Reject: processRejectActivity, | ||
29 | Announce: processAnnounceActivity, | ||
30 | Undo: processUndoActivity, | ||
31 | Like: processLikeActivity, | ||
32 | Dislike: processDislikeActivity, | ||
33 | Flag: processFlagActivity, | ||
34 | View: processViewActivity | ||
35 | } | ||
36 | |||
37 | async function processActivities ( | ||
38 | activities: Activity[], | ||
39 | options: { | ||
40 | signatureActor?: MActorSignature | ||
41 | inboxActor?: MActorDefault | ||
42 | outboxUrl?: string | ||
43 | fromFetch?: boolean | ||
44 | } = {} | ||
45 | ) { | ||
46 | const { outboxUrl, signatureActor, inboxActor, fromFetch = false } = options | ||
47 | |||
48 | const actorsCache: { [ url: string ]: MActorSignature } = {} | ||
49 | |||
50 | for (const activity of activities) { | ||
51 | if (!signatureActor && [ 'Create', 'Announce', 'Like' ].includes(activity.type) === false) { | ||
52 | logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type) | ||
53 | continue | ||
54 | } | ||
55 | |||
56 | const actorUrl = getAPId(activity.actor) | ||
57 | |||
58 | // When we fetch remote data, we don't have signature | ||
59 | if (signatureActor && actorUrl !== signatureActor.url) { | ||
60 | logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, signatureActor.url) | ||
61 | continue | ||
62 | } | ||
63 | |||
64 | if (outboxUrl && checkUrlsSameHost(outboxUrl, actorUrl) !== true) { | ||
65 | logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', outboxUrl, actorUrl) | ||
66 | continue | ||
67 | } | ||
68 | |||
69 | const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl) | ||
70 | actorsCache[actorUrl] = byActor | ||
71 | |||
72 | const activityProcessor = processActivity[activity.type] | ||
73 | if (activityProcessor === undefined) { | ||
74 | logger.warn('Unknown activity type %s.', activity.type, { activityId: activity.id }) | ||
75 | continue | ||
76 | } | ||
77 | |||
78 | try { | ||
79 | await activityProcessor({ activity, byActor, inboxActor, fromFetch }) | ||
80 | |||
81 | StatsManager.Instance.addInboxProcessedSuccess(activity.type) | ||
82 | } catch (err) { | ||
83 | logger.warn('Cannot process activity %s.', activity.type, { err }) | ||
84 | |||
85 | StatsManager.Instance.addInboxProcessedError(activity.type) | ||
86 | } | ||
87 | } | ||
88 | } | ||
89 | |||
90 | export { | ||
91 | processActivities | ||
92 | } | ||
diff --git a/server/lib/activitypub/send/http.ts b/server/lib/activitypub/send/http.ts deleted file mode 100644 index b461aa55d..000000000 --- a/server/lib/activitypub/send/http.ts +++ /dev/null | |||
@@ -1,73 +0,0 @@ | |||
1 | import { buildDigest, signJsonLDObject } from '@server/helpers/peertube-crypto' | ||
2 | import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@server/initializers/constants' | ||
3 | import { ActorModel } from '@server/models/actor/actor' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { MActor } from '@server/types/models' | ||
6 | import { ContextType } from '@shared/models/activitypub/context' | ||
7 | import { activityPubContextify } from '../context' | ||
8 | |||
9 | type Payload <T> = { body: T, contextType: ContextType, signatureActorId?: number } | ||
10 | |||
11 | async function computeBody <T> ( | ||
12 | payload: Payload<T> | ||
13 | ): Promise<T | T & { type: 'RsaSignature2017', creator: string, created: string }> { | ||
14 | let body = payload.body | ||
15 | |||
16 | if (payload.signatureActorId) { | ||
17 | const actorSignature = await ActorModel.load(payload.signatureActorId) | ||
18 | if (!actorSignature) throw new Error('Unknown signature actor id.') | ||
19 | |||
20 | body = await signAndContextify(actorSignature, payload.body, payload.contextType) | ||
21 | } | ||
22 | |||
23 | return body | ||
24 | } | ||
25 | |||
26 | async function buildSignedRequestOptions (options: { | ||
27 | signatureActorId?: number | ||
28 | hasPayload: boolean | ||
29 | }) { | ||
30 | let actor: MActor | null | ||
31 | |||
32 | if (options.signatureActorId) { | ||
33 | actor = await ActorModel.load(options.signatureActorId) | ||
34 | if (!actor) throw new Error('Unknown signature actor id.') | ||
35 | } else { | ||
36 | // We need to sign the request, so use the server | ||
37 | actor = await getServerActor() | ||
38 | } | ||
39 | |||
40 | const keyId = actor.url | ||
41 | return { | ||
42 | algorithm: HTTP_SIGNATURE.ALGORITHM, | ||
43 | authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, | ||
44 | keyId, | ||
45 | key: actor.privateKey, | ||
46 | headers: options.hasPayload | ||
47 | ? HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD | ||
48 | : HTTP_SIGNATURE.HEADERS_TO_SIGN_WITHOUT_PAYLOAD | ||
49 | } | ||
50 | } | ||
51 | |||
52 | function buildGlobalHeaders (body: any) { | ||
53 | return { | ||
54 | 'digest': buildDigest(body), | ||
55 | 'content-type': 'application/activity+json', | ||
56 | 'accept': ACTIVITY_PUB.ACCEPT_HEADER | ||
57 | } | ||
58 | } | ||
59 | |||
60 | async function signAndContextify <T> (byActor: MActor, data: T, contextType: ContextType | null) { | ||
61 | const activity = contextType | ||
62 | ? await activityPubContextify(data, contextType) | ||
63 | : data | ||
64 | |||
65 | return signJsonLDObject(byActor, activity) | ||
66 | } | ||
67 | |||
68 | export { | ||
69 | buildGlobalHeaders, | ||
70 | computeBody, | ||
71 | buildSignedRequestOptions, | ||
72 | signAndContextify | ||
73 | } | ||
diff --git a/server/lib/activitypub/send/index.ts b/server/lib/activitypub/send/index.ts deleted file mode 100644 index 852ea2e74..000000000 --- a/server/lib/activitypub/send/index.ts +++ /dev/null | |||
@@ -1,10 +0,0 @@ | |||
1 | export * from './http' | ||
2 | export * from './send-accept' | ||
3 | export * from './send-announce' | ||
4 | export * from './send-create' | ||
5 | export * from './send-delete' | ||
6 | export * from './send-follow' | ||
7 | export * from './send-like' | ||
8 | export * from './send-reject' | ||
9 | export * from './send-undo' | ||
10 | export * from './send-update' | ||
diff --git a/server/lib/activitypub/send/send-accept.ts b/server/lib/activitypub/send/send-accept.ts deleted file mode 100644 index 4c9bcbb0b..000000000 --- a/server/lib/activitypub/send/send-accept.ts +++ /dev/null | |||
@@ -1,47 +0,0 @@ | |||
1 | import { ActivityAccept, ActivityFollow } from '@shared/models' | ||
2 | import { logger } from '../../../helpers/logger' | ||
3 | import { MActor, MActorFollowActors } from '../../../types/models' | ||
4 | import { getLocalActorFollowAcceptActivityPubUrl } from '../url' | ||
5 | import { buildFollowActivity } from './send-follow' | ||
6 | import { unicastTo } from './shared/send-utils' | ||
7 | |||
8 | function sendAccept (actorFollow: MActorFollowActors) { | ||
9 | const follower = actorFollow.ActorFollower | ||
10 | const me = actorFollow.ActorFollowing | ||
11 | |||
12 | if (!follower.serverId) { // This should never happen | ||
13 | logger.warn('Do not sending accept to local follower.') | ||
14 | return | ||
15 | } | ||
16 | |||
17 | logger.info('Creating job to accept follower %s.', follower.url) | ||
18 | |||
19 | const followData = buildFollowActivity(actorFollow.url, follower, me) | ||
20 | |||
21 | const url = getLocalActorFollowAcceptActivityPubUrl(actorFollow) | ||
22 | const data = buildAcceptActivity(url, me, followData) | ||
23 | |||
24 | return unicastTo({ | ||
25 | data, | ||
26 | byActor: me, | ||
27 | toActorUrl: follower.inboxUrl, | ||
28 | contextType: 'Accept' | ||
29 | }) | ||
30 | } | ||
31 | |||
32 | // --------------------------------------------------------------------------- | ||
33 | |||
34 | export { | ||
35 | sendAccept | ||
36 | } | ||
37 | |||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | function buildAcceptActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityAccept { | ||
41 | return { | ||
42 | type: 'Accept', | ||
43 | id: url, | ||
44 | actor: byActor.url, | ||
45 | object: followActivityData | ||
46 | } | ||
47 | } | ||
diff --git a/server/lib/activitypub/send/send-announce.ts b/server/lib/activitypub/send/send-announce.ts deleted file mode 100644 index 6c078b047..000000000 --- a/server/lib/activitypub/send/send-announce.ts +++ /dev/null | |||
@@ -1,58 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActivityAnnounce, ActivityAudience } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { MActorLight, MVideo } from '../../../types/models' | ||
5 | import { MVideoShare } from '../../../types/models/video' | ||
6 | import { audiencify, getAudience } from '../audience' | ||
7 | import { getActorsInvolvedInVideo, getAudienceFromFollowersOf } from './shared' | ||
8 | import { broadcastToFollowers } from './shared/send-utils' | ||
9 | |||
10 | async function buildAnnounceWithVideoAudience ( | ||
11 | byActor: MActorLight, | ||
12 | videoShare: MVideoShare, | ||
13 | video: MVideo, | ||
14 | t: Transaction | ||
15 | ) { | ||
16 | const announcedObject = video.url | ||
17 | |||
18 | const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) | ||
19 | const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo) | ||
20 | |||
21 | const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience) | ||
22 | |||
23 | return { activity, actorsInvolvedInVideo } | ||
24 | } | ||
25 | |||
26 | async function sendVideoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) { | ||
27 | const { activity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction) | ||
28 | |||
29 | logger.info('Creating job to send announce %s.', videoShare.url) | ||
30 | |||
31 | return broadcastToFollowers({ | ||
32 | data: activity, | ||
33 | byActor, | ||
34 | toFollowersOf: actorsInvolvedInVideo, | ||
35 | transaction, | ||
36 | actorsException: [ byActor ], | ||
37 | contextType: 'Announce' | ||
38 | }) | ||
39 | } | ||
40 | |||
41 | function buildAnnounceActivity (url: string, byActor: MActorLight, object: string, audience?: ActivityAudience): ActivityAnnounce { | ||
42 | if (!audience) audience = getAudience(byActor) | ||
43 | |||
44 | return audiencify({ | ||
45 | type: 'Announce' as 'Announce', | ||
46 | id: url, | ||
47 | actor: byActor.url, | ||
48 | object | ||
49 | }, audience) | ||
50 | } | ||
51 | |||
52 | // --------------------------------------------------------------------------- | ||
53 | |||
54 | export { | ||
55 | sendVideoAnnounce, | ||
56 | buildAnnounceActivity, | ||
57 | buildAnnounceWithVideoAudience | ||
58 | } | ||
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts deleted file mode 100644 index 2cd4db14d..000000000 --- a/server/lib/activitypub/send/send-create.ts +++ /dev/null | |||
@@ -1,226 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { getServerActor } from '@server/models/application/application' | ||
3 | import { | ||
4 | ActivityAudience, | ||
5 | ActivityCreate, | ||
6 | ActivityCreateObject, | ||
7 | ContextType, | ||
8 | VideoCommentObject, | ||
9 | VideoPlaylistPrivacy, | ||
10 | VideoPrivacy | ||
11 | } from '@shared/models' | ||
12 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
13 | import { VideoCommentModel } from '../../../models/video/video-comment' | ||
14 | import { | ||
15 | MActorLight, | ||
16 | MCommentOwnerVideo, | ||
17 | MLocalVideoViewerWithWatchSections, | ||
18 | MVideoAccountLight, | ||
19 | MVideoAP, | ||
20 | MVideoPlaylistFull, | ||
21 | MVideoRedundancyFileVideo, | ||
22 | MVideoRedundancyStreamingPlaylistVideo | ||
23 | } from '../../../types/models' | ||
24 | import { audiencify, getAudience } from '../audience' | ||
25 | import { | ||
26 | broadcastToActors, | ||
27 | broadcastToFollowers, | ||
28 | getActorsInvolvedInVideo, | ||
29 | getAudienceFromFollowersOf, | ||
30 | getVideoCommentAudience, | ||
31 | sendVideoActivityToOrigin, | ||
32 | sendVideoRelatedActivity, | ||
33 | unicastTo | ||
34 | } from './shared' | ||
35 | |||
36 | const lTags = loggerTagsFactory('ap', 'create') | ||
37 | |||
38 | async function sendCreateVideo (video: MVideoAP, transaction: Transaction) { | ||
39 | if (!video.hasPrivacyForFederation()) return undefined | ||
40 | |||
41 | logger.info('Creating job to send video creation of %s.', video.url, lTags(video.uuid)) | ||
42 | |||
43 | const byActor = video.VideoChannel.Account.Actor | ||
44 | const videoObject = await video.toActivityPubObject() | ||
45 | |||
46 | const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC) | ||
47 | const createActivity = buildCreateActivity(video.url, byActor, videoObject, audience) | ||
48 | |||
49 | return broadcastToFollowers({ | ||
50 | data: createActivity, | ||
51 | byActor, | ||
52 | toFollowersOf: [ byActor ], | ||
53 | transaction, | ||
54 | contextType: 'Video' | ||
55 | }) | ||
56 | } | ||
57 | |||
58 | async function sendCreateCacheFile ( | ||
59 | byActor: MActorLight, | ||
60 | video: MVideoAccountLight, | ||
61 | fileRedundancy: MVideoRedundancyStreamingPlaylistVideo | MVideoRedundancyFileVideo | ||
62 | ) { | ||
63 | logger.info('Creating job to send file cache of %s.', fileRedundancy.url, lTags(video.uuid)) | ||
64 | |||
65 | return sendVideoRelatedCreateActivity({ | ||
66 | byActor, | ||
67 | video, | ||
68 | url: fileRedundancy.url, | ||
69 | object: fileRedundancy.toActivityPubObject(), | ||
70 | contextType: 'CacheFile' | ||
71 | }) | ||
72 | } | ||
73 | |||
74 | async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) { | ||
75 | logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid)) | ||
76 | |||
77 | const byActor = await getServerActor() | ||
78 | |||
79 | const activityBuilder = (audience: ActivityAudience) => { | ||
80 | return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience) | ||
81 | } | ||
82 | |||
83 | return sendVideoActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' }) | ||
84 | } | ||
85 | |||
86 | async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) { | ||
87 | if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined | ||
88 | |||
89 | logger.info('Creating job to send create video playlist of %s.', playlist.url, lTags(playlist.uuid)) | ||
90 | |||
91 | const byActor = playlist.OwnerAccount.Actor | ||
92 | const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC) | ||
93 | |||
94 | const object = await playlist.toActivityPubObject(null, transaction) | ||
95 | const createActivity = buildCreateActivity(playlist.url, byActor, object, audience) | ||
96 | |||
97 | const serverActor = await getServerActor() | ||
98 | const toFollowersOf = [ byActor, serverActor ] | ||
99 | |||
100 | if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor) | ||
101 | |||
102 | return broadcastToFollowers({ | ||
103 | data: createActivity, | ||
104 | byActor, | ||
105 | toFollowersOf, | ||
106 | transaction, | ||
107 | contextType: 'Playlist' | ||
108 | }) | ||
109 | } | ||
110 | |||
111 | async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) { | ||
112 | logger.info('Creating job to send comment %s.', comment.url) | ||
113 | |||
114 | const isOrigin = comment.Video.isOwned() | ||
115 | |||
116 | const byActor = comment.Account.Actor | ||
117 | const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, transaction) | ||
118 | const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject | ||
119 | |||
120 | const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction) | ||
121 | // Add the actor that commented too | ||
122 | actorsInvolvedInComment.push(byActor) | ||
123 | |||
124 | const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted()) | ||
125 | .map(c => c.Account.Actor) | ||
126 | |||
127 | let audience: ActivityAudience | ||
128 | if (isOrigin) { | ||
129 | audience = getVideoCommentAudience(comment, threadParentComments, actorsInvolvedInComment, isOrigin) | ||
130 | } else { | ||
131 | audience = getAudienceFromFollowersOf(actorsInvolvedInComment.concat(parentsCommentActors)) | ||
132 | } | ||
133 | |||
134 | const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience) | ||
135 | |||
136 | // This was a reply, send it to the parent actors | ||
137 | const actorsException = [ byActor ] | ||
138 | await broadcastToActors({ | ||
139 | data: createActivity, | ||
140 | byActor, | ||
141 | toActors: parentsCommentActors, | ||
142 | transaction, | ||
143 | actorsException, | ||
144 | contextType: 'Comment' | ||
145 | }) | ||
146 | |||
147 | // Broadcast to our followers | ||
148 | await broadcastToFollowers({ | ||
149 | data: createActivity, | ||
150 | byActor, | ||
151 | toFollowersOf: [ byActor ], | ||
152 | transaction, | ||
153 | contextType: 'Comment' | ||
154 | }) | ||
155 | |||
156 | // Send to actors involved in the comment | ||
157 | if (isOrigin) { | ||
158 | return broadcastToFollowers({ | ||
159 | data: createActivity, | ||
160 | byActor, | ||
161 | toFollowersOf: actorsInvolvedInComment, | ||
162 | transaction, | ||
163 | actorsException, | ||
164 | contextType: 'Comment' | ||
165 | }) | ||
166 | } | ||
167 | |||
168 | // Send to origin | ||
169 | return transaction.afterCommit(() => { | ||
170 | return unicastTo({ | ||
171 | data: createActivity, | ||
172 | byActor, | ||
173 | toActorUrl: comment.Video.VideoChannel.Account.Actor.getSharedInbox(), | ||
174 | contextType: 'Comment' | ||
175 | }) | ||
176 | }) | ||
177 | } | ||
178 | |||
179 | function buildCreateActivity <T extends ActivityCreateObject> ( | ||
180 | url: string, | ||
181 | byActor: MActorLight, | ||
182 | object: T, | ||
183 | audience?: ActivityAudience | ||
184 | ): ActivityCreate<T> { | ||
185 | if (!audience) audience = getAudience(byActor) | ||
186 | |||
187 | return audiencify( | ||
188 | { | ||
189 | type: 'Create' as 'Create', | ||
190 | id: url + '/activity', | ||
191 | actor: byActor.url, | ||
192 | object: typeof object === 'string' | ||
193 | ? object | ||
194 | : audiencify(object, audience) | ||
195 | }, | ||
196 | audience | ||
197 | ) | ||
198 | } | ||
199 | |||
200 | // --------------------------------------------------------------------------- | ||
201 | |||
202 | export { | ||
203 | sendCreateVideo, | ||
204 | buildCreateActivity, | ||
205 | sendCreateVideoComment, | ||
206 | sendCreateVideoPlaylist, | ||
207 | sendCreateCacheFile, | ||
208 | sendCreateWatchAction | ||
209 | } | ||
210 | |||
211 | // --------------------------------------------------------------------------- | ||
212 | |||
213 | async function sendVideoRelatedCreateActivity (options: { | ||
214 | byActor: MActorLight | ||
215 | video: MVideoAccountLight | ||
216 | url: string | ||
217 | object: any | ||
218 | contextType: ContextType | ||
219 | transaction?: Transaction | ||
220 | }) { | ||
221 | const activityBuilder = (audience: ActivityAudience) => { | ||
222 | return buildCreateActivity(options.url, options.byActor, options.object, audience) | ||
223 | } | ||
224 | |||
225 | return sendVideoRelatedActivity(activityBuilder, options) | ||
226 | } | ||
diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts deleted file mode 100644 index 0d85d9001..000000000 --- a/server/lib/activitypub/send/send-delete.ts +++ /dev/null | |||
@@ -1,158 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { getServerActor } from '@server/models/application/application' | ||
3 | import { ActivityAudience, ActivityDelete } from '@shared/models' | ||
4 | import { logger } from '../../../helpers/logger' | ||
5 | import { ActorModel } from '../../../models/actor/actor' | ||
6 | import { VideoCommentModel } from '../../../models/video/video-comment' | ||
7 | import { VideoShareModel } from '../../../models/video/video-share' | ||
8 | import { MActorUrl } from '../../../types/models' | ||
9 | import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../types/models/video' | ||
10 | import { audiencify } from '../audience' | ||
11 | import { getDeleteActivityPubUrl } from '../url' | ||
12 | import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared' | ||
13 | import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './shared/send-utils' | ||
14 | |||
15 | async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) { | ||
16 | logger.info('Creating job to broadcast delete of video %s.', video.url) | ||
17 | |||
18 | const byActor = video.VideoChannel.Account.Actor | ||
19 | |||
20 | const activityBuilder = (audience: ActivityAudience) => { | ||
21 | const url = getDeleteActivityPubUrl(video.url) | ||
22 | |||
23 | return buildDeleteActivity(url, video.url, byActor, audience) | ||
24 | } | ||
25 | |||
26 | return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'Delete', transaction }) | ||
27 | } | ||
28 | |||
29 | async function sendDeleteActor (byActor: ActorModel, transaction: Transaction) { | ||
30 | logger.info('Creating job to broadcast delete of actor %s.', byActor.url) | ||
31 | |||
32 | const url = getDeleteActivityPubUrl(byActor.url) | ||
33 | const activity = buildDeleteActivity(url, byActor.url, byActor) | ||
34 | |||
35 | const actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction) | ||
36 | |||
37 | // In case the actor did not have any videos | ||
38 | const serverActor = await getServerActor() | ||
39 | actorsInvolved.push(serverActor) | ||
40 | |||
41 | actorsInvolved.push(byActor) | ||
42 | |||
43 | return broadcastToFollowers({ | ||
44 | data: activity, | ||
45 | byActor, | ||
46 | toFollowersOf: actorsInvolved, | ||
47 | contextType: 'Delete', | ||
48 | transaction | ||
49 | }) | ||
50 | } | ||
51 | |||
52 | async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, transaction: Transaction) { | ||
53 | logger.info('Creating job to send delete of comment %s.', videoComment.url) | ||
54 | |||
55 | const isVideoOrigin = videoComment.Video.isOwned() | ||
56 | |||
57 | const url = getDeleteActivityPubUrl(videoComment.url) | ||
58 | const byActor = videoComment.isOwned() | ||
59 | ? videoComment.Account.Actor | ||
60 | : videoComment.Video.VideoChannel.Account.Actor | ||
61 | |||
62 | const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, transaction) | ||
63 | const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted()) | ||
64 | |||
65 | const actorsInvolvedInComment = await getActorsInvolvedInVideo(videoComment.Video, transaction) | ||
66 | actorsInvolvedInComment.push(byActor) // Add the actor that commented the video | ||
67 | |||
68 | const audience = getVideoCommentAudience(videoComment, threadParentCommentsFiltered, actorsInvolvedInComment, isVideoOrigin) | ||
69 | const activity = buildDeleteActivity(url, videoComment.url, byActor, audience) | ||
70 | |||
71 | // This was a reply, send it to the parent actors | ||
72 | const actorsException = [ byActor ] | ||
73 | await broadcastToActors({ | ||
74 | data: activity, | ||
75 | byActor, | ||
76 | toActors: threadParentCommentsFiltered.map(c => c.Account.Actor), | ||
77 | transaction, | ||
78 | contextType: 'Delete', | ||
79 | actorsException | ||
80 | }) | ||
81 | |||
82 | // Broadcast to our followers | ||
83 | await broadcastToFollowers({ | ||
84 | data: activity, | ||
85 | byActor, | ||
86 | toFollowersOf: [ byActor ], | ||
87 | contextType: 'Delete', | ||
88 | transaction | ||
89 | }) | ||
90 | |||
91 | // Send to actors involved in the comment | ||
92 | if (isVideoOrigin) { | ||
93 | return broadcastToFollowers({ | ||
94 | data: activity, | ||
95 | byActor, | ||
96 | toFollowersOf: actorsInvolvedInComment, | ||
97 | transaction, | ||
98 | contextType: 'Delete', | ||
99 | actorsException | ||
100 | }) | ||
101 | } | ||
102 | |||
103 | // Send to origin | ||
104 | return transaction.afterCommit(() => { | ||
105 | return unicastTo({ | ||
106 | data: activity, | ||
107 | byActor, | ||
108 | toActorUrl: videoComment.Video.VideoChannel.Account.Actor.getSharedInbox(), | ||
109 | contextType: 'Delete' | ||
110 | }) | ||
111 | }) | ||
112 | } | ||
113 | |||
114 | async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary, transaction: Transaction) { | ||
115 | logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url) | ||
116 | |||
117 | const byActor = videoPlaylist.OwnerAccount.Actor | ||
118 | |||
119 | const url = getDeleteActivityPubUrl(videoPlaylist.url) | ||
120 | const activity = buildDeleteActivity(url, videoPlaylist.url, byActor) | ||
121 | |||
122 | const serverActor = await getServerActor() | ||
123 | const toFollowersOf = [ byActor, serverActor ] | ||
124 | |||
125 | if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor) | ||
126 | |||
127 | return broadcastToFollowers({ | ||
128 | data: activity, | ||
129 | byActor, | ||
130 | toFollowersOf, | ||
131 | contextType: 'Delete', | ||
132 | transaction | ||
133 | }) | ||
134 | } | ||
135 | |||
136 | // --------------------------------------------------------------------------- | ||
137 | |||
138 | export { | ||
139 | sendDeleteVideo, | ||
140 | sendDeleteActor, | ||
141 | sendDeleteVideoComment, | ||
142 | sendDeleteVideoPlaylist | ||
143 | } | ||
144 | |||
145 | // --------------------------------------------------------------------------- | ||
146 | |||
147 | function buildDeleteActivity (url: string, object: string, byActor: MActorUrl, audience?: ActivityAudience): ActivityDelete { | ||
148 | const activity = { | ||
149 | type: 'Delete' as 'Delete', | ||
150 | id: url, | ||
151 | actor: byActor.url, | ||
152 | object | ||
153 | } | ||
154 | |||
155 | if (audience) return audiencify(activity, audience) | ||
156 | |||
157 | return activity | ||
158 | } | ||
diff --git a/server/lib/activitypub/send/send-dislike.ts b/server/lib/activitypub/send/send-dislike.ts deleted file mode 100644 index 959e74823..000000000 --- a/server/lib/activitypub/send/send-dislike.ts +++ /dev/null | |||
@@ -1,40 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActivityAudience, ActivityDislike } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models' | ||
5 | import { audiencify, getAudience } from '../audience' | ||
6 | import { getVideoDislikeActivityPubUrlByLocalActor } from '../url' | ||
7 | import { sendVideoActivityToOrigin } from './shared/send-utils' | ||
8 | |||
9 | function sendDislike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) { | ||
10 | logger.info('Creating job to dislike %s.', video.url) | ||
11 | |||
12 | const activityBuilder = (audience: ActivityAudience) => { | ||
13 | const url = getVideoDislikeActivityPubUrlByLocalActor(byActor, video) | ||
14 | |||
15 | return buildDislikeActivity(url, byActor, video, audience) | ||
16 | } | ||
17 | |||
18 | return sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' }) | ||
19 | } | ||
20 | |||
21 | function buildDislikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityDislike { | ||
22 | if (!audience) audience = getAudience(byActor) | ||
23 | |||
24 | return audiencify( | ||
25 | { | ||
26 | id: url, | ||
27 | type: 'Dislike' as 'Dislike', | ||
28 | actor: byActor.url, | ||
29 | object: video.url | ||
30 | }, | ||
31 | audience | ||
32 | ) | ||
33 | } | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | sendDislike, | ||
39 | buildDislikeActivity | ||
40 | } | ||
diff --git a/server/lib/activitypub/send/send-flag.ts b/server/lib/activitypub/send/send-flag.ts deleted file mode 100644 index 138eb5adc..000000000 --- a/server/lib/activitypub/send/send-flag.ts +++ /dev/null | |||
@@ -1,42 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActivityAudience, ActivityFlag } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { MAbuseAP, MAccountLight, MActor } from '../../../types/models' | ||
5 | import { audiencify, getAudience } from '../audience' | ||
6 | import { getLocalAbuseActivityPubUrl } from '../url' | ||
7 | import { unicastTo } from './shared/send-utils' | ||
8 | |||
9 | function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) { | ||
10 | if (!flaggedAccount.Actor.serverId) return // Local user | ||
11 | |||
12 | const url = getLocalAbuseActivityPubUrl(abuse) | ||
13 | |||
14 | logger.info('Creating job to send abuse %s.', url) | ||
15 | |||
16 | // Custom audience, we only send the abuse to the origin instance | ||
17 | const audience = { to: [ flaggedAccount.Actor.url ], cc: [] } | ||
18 | const flagActivity = buildFlagActivity(url, byActor, abuse, audience) | ||
19 | |||
20 | return t.afterCommit(() => { | ||
21 | return unicastTo({ | ||
22 | data: flagActivity, | ||
23 | byActor, | ||
24 | toActorUrl: flaggedAccount.Actor.getSharedInbox(), | ||
25 | contextType: 'Flag' | ||
26 | }) | ||
27 | }) | ||
28 | } | ||
29 | |||
30 | function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag { | ||
31 | if (!audience) audience = getAudience(byActor) | ||
32 | |||
33 | const activity = { id: url, actor: byActor.url, ...abuse.toActivityPubObject() } | ||
34 | |||
35 | return audiencify(activity, audience) | ||
36 | } | ||
37 | |||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | export { | ||
41 | sendAbuse | ||
42 | } | ||
diff --git a/server/lib/activitypub/send/send-follow.ts b/server/lib/activitypub/send/send-follow.ts deleted file mode 100644 index 57501dadb..000000000 --- a/server/lib/activitypub/send/send-follow.ts +++ /dev/null | |||
@@ -1,37 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActivityFollow } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { MActor, MActorFollowActors } from '../../../types/models' | ||
5 | import { unicastTo } from './shared/send-utils' | ||
6 | |||
7 | function sendFollow (actorFollow: MActorFollowActors, t: Transaction) { | ||
8 | const me = actorFollow.ActorFollower | ||
9 | const following = actorFollow.ActorFollowing | ||
10 | |||
11 | // Same server as ours | ||
12 | if (!following.serverId) return | ||
13 | |||
14 | logger.info('Creating job to send follow request to %s.', following.url) | ||
15 | |||
16 | const data = buildFollowActivity(actorFollow.url, me, following) | ||
17 | |||
18 | return t.afterCommit(() => { | ||
19 | return unicastTo({ data, byActor: me, toActorUrl: following.inboxUrl, contextType: 'Follow' }) | ||
20 | }) | ||
21 | } | ||
22 | |||
23 | function buildFollowActivity (url: string, byActor: MActor, targetActor: MActor): ActivityFollow { | ||
24 | return { | ||
25 | type: 'Follow', | ||
26 | id: url, | ||
27 | actor: byActor.url, | ||
28 | object: targetActor.url | ||
29 | } | ||
30 | } | ||
31 | |||
32 | // --------------------------------------------------------------------------- | ||
33 | |||
34 | export { | ||
35 | sendFollow, | ||
36 | buildFollowActivity | ||
37 | } | ||
diff --git a/server/lib/activitypub/send/send-like.ts b/server/lib/activitypub/send/send-like.ts deleted file mode 100644 index 46c9fdec9..000000000 --- a/server/lib/activitypub/send/send-like.ts +++ /dev/null | |||
@@ -1,40 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActivityAudience, ActivityLike } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models' | ||
5 | import { audiencify, getAudience } from '../audience' | ||
6 | import { getVideoLikeActivityPubUrlByLocalActor } from '../url' | ||
7 | import { sendVideoActivityToOrigin } from './shared/send-utils' | ||
8 | |||
9 | function sendLike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) { | ||
10 | logger.info('Creating job to like %s.', video.url) | ||
11 | |||
12 | const activityBuilder = (audience: ActivityAudience) => { | ||
13 | const url = getVideoLikeActivityPubUrlByLocalActor(byActor, video) | ||
14 | |||
15 | return buildLikeActivity(url, byActor, video, audience) | ||
16 | } | ||
17 | |||
18 | return sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' }) | ||
19 | } | ||
20 | |||
21 | function buildLikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityLike { | ||
22 | if (!audience) audience = getAudience(byActor) | ||
23 | |||
24 | return audiencify( | ||
25 | { | ||
26 | id: url, | ||
27 | type: 'Like' as 'Like', | ||
28 | actor: byActor.url, | ||
29 | object: video.url | ||
30 | }, | ||
31 | audience | ||
32 | ) | ||
33 | } | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | sendLike, | ||
39 | buildLikeActivity | ||
40 | } | ||
diff --git a/server/lib/activitypub/send/send-reject.ts b/server/lib/activitypub/send/send-reject.ts deleted file mode 100644 index a5f8c2ecf..000000000 --- a/server/lib/activitypub/send/send-reject.ts +++ /dev/null | |||
@@ -1,39 +0,0 @@ | |||
1 | import { ActivityFollow, ActivityReject } from '@shared/models' | ||
2 | import { logger } from '../../../helpers/logger' | ||
3 | import { MActor } from '../../../types/models' | ||
4 | import { getLocalActorFollowRejectActivityPubUrl } from '../url' | ||
5 | import { buildFollowActivity } from './send-follow' | ||
6 | import { unicastTo } from './shared/send-utils' | ||
7 | |||
8 | function sendReject (followUrl: string, follower: MActor, following: MActor) { | ||
9 | if (!follower.serverId) { // This should never happen | ||
10 | logger.warn('Do not sending reject to local follower.') | ||
11 | return | ||
12 | } | ||
13 | |||
14 | logger.info('Creating job to reject follower %s.', follower.url) | ||
15 | |||
16 | const followData = buildFollowActivity(followUrl, follower, following) | ||
17 | |||
18 | const url = getLocalActorFollowRejectActivityPubUrl() | ||
19 | const data = buildRejectActivity(url, following, followData) | ||
20 | |||
21 | return unicastTo({ data, byActor: following, toActorUrl: follower.inboxUrl, contextType: 'Reject' }) | ||
22 | } | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | sendReject | ||
28 | } | ||
29 | |||
30 | // --------------------------------------------------------------------------- | ||
31 | |||
32 | function buildRejectActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityReject { | ||
33 | return { | ||
34 | type: 'Reject', | ||
35 | id: url, | ||
36 | actor: byActor.url, | ||
37 | object: followActivityData | ||
38 | } | ||
39 | } | ||
diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts deleted file mode 100644 index b0b48c9c4..000000000 --- a/server/lib/activitypub/send/send-undo.ts +++ /dev/null | |||
@@ -1,172 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { VideoModel } from '../../../models/video/video' | ||
5 | import { | ||
6 | MActor, | ||
7 | MActorAudience, | ||
8 | MActorFollowActors, | ||
9 | MActorLight, | ||
10 | MVideo, | ||
11 | MVideoAccountLight, | ||
12 | MVideoRedundancyVideo, | ||
13 | MVideoShare | ||
14 | } from '../../../types/models' | ||
15 | import { audiencify, getAudience } from '../audience' | ||
16 | import { getUndoActivityPubUrl, getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from '../url' | ||
17 | import { buildAnnounceWithVideoAudience } from './send-announce' | ||
18 | import { buildCreateActivity } from './send-create' | ||
19 | import { buildDislikeActivity } from './send-dislike' | ||
20 | import { buildFollowActivity } from './send-follow' | ||
21 | import { buildLikeActivity } from './send-like' | ||
22 | import { broadcastToFollowers, sendVideoActivityToOrigin, sendVideoRelatedActivity, unicastTo } from './shared/send-utils' | ||
23 | |||
24 | function sendUndoFollow (actorFollow: MActorFollowActors, t: Transaction) { | ||
25 | const me = actorFollow.ActorFollower | ||
26 | const following = actorFollow.ActorFollowing | ||
27 | |||
28 | // Same server as ours | ||
29 | if (!following.serverId) return | ||
30 | |||
31 | logger.info('Creating job to send an unfollow request to %s.', following.url) | ||
32 | |||
33 | const undoUrl = getUndoActivityPubUrl(actorFollow.url) | ||
34 | |||
35 | const followActivity = buildFollowActivity(actorFollow.url, me, following) | ||
36 | const undoActivity = undoActivityData(undoUrl, me, followActivity) | ||
37 | |||
38 | t.afterCommit(() => { | ||
39 | return unicastTo({ | ||
40 | data: undoActivity, | ||
41 | byActor: me, | ||
42 | toActorUrl: following.inboxUrl, | ||
43 | contextType: 'Follow' | ||
44 | }) | ||
45 | }) | ||
46 | } | ||
47 | |||
48 | // --------------------------------------------------------------------------- | ||
49 | |||
50 | async function sendUndoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) { | ||
51 | logger.info('Creating job to undo announce %s.', videoShare.url) | ||
52 | |||
53 | const undoUrl = getUndoActivityPubUrl(videoShare.url) | ||
54 | |||
55 | const { activity: announce, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction) | ||
56 | const undoActivity = undoActivityData(undoUrl, byActor, announce) | ||
57 | |||
58 | return broadcastToFollowers({ | ||
59 | data: undoActivity, | ||
60 | byActor, | ||
61 | toFollowersOf: actorsInvolvedInVideo, | ||
62 | transaction, | ||
63 | actorsException: [ byActor ], | ||
64 | contextType: 'Announce' | ||
65 | }) | ||
66 | } | ||
67 | |||
68 | async function sendUndoCacheFile (byActor: MActor, redundancyModel: MVideoRedundancyVideo, transaction: Transaction) { | ||
69 | logger.info('Creating job to undo cache file %s.', redundancyModel.url) | ||
70 | |||
71 | const associatedVideo = redundancyModel.getVideo() | ||
72 | if (!associatedVideo) { | ||
73 | logger.warn('Cannot send undo activity for redundancy %s: no video files associated.', redundancyModel.url) | ||
74 | return | ||
75 | } | ||
76 | |||
77 | const video = await VideoModel.loadFull(associatedVideo.id) | ||
78 | const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject()) | ||
79 | |||
80 | return sendUndoVideoRelatedActivity({ | ||
81 | byActor, | ||
82 | video, | ||
83 | url: redundancyModel.url, | ||
84 | activity: createActivity, | ||
85 | contextType: 'CacheFile', | ||
86 | transaction | ||
87 | }) | ||
88 | } | ||
89 | |||
90 | // --------------------------------------------------------------------------- | ||
91 | |||
92 | async function sendUndoLike (byActor: MActor, video: MVideoAccountLight, t: Transaction) { | ||
93 | logger.info('Creating job to undo a like of video %s.', video.url) | ||
94 | |||
95 | const likeUrl = getVideoLikeActivityPubUrlByLocalActor(byActor, video) | ||
96 | const likeActivity = buildLikeActivity(likeUrl, byActor, video) | ||
97 | |||
98 | return sendUndoVideoRateToOriginActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t }) | ||
99 | } | ||
100 | |||
101 | async function sendUndoDislike (byActor: MActor, video: MVideoAccountLight, t: Transaction) { | ||
102 | logger.info('Creating job to undo a dislike of video %s.', video.url) | ||
103 | |||
104 | const dislikeUrl = getVideoDislikeActivityPubUrlByLocalActor(byActor, video) | ||
105 | const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video) | ||
106 | |||
107 | return sendUndoVideoRateToOriginActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t }) | ||
108 | } | ||
109 | |||
110 | // --------------------------------------------------------------------------- | ||
111 | |||
112 | export { | ||
113 | sendUndoFollow, | ||
114 | sendUndoLike, | ||
115 | sendUndoDislike, | ||
116 | sendUndoAnnounce, | ||
117 | sendUndoCacheFile | ||
118 | } | ||
119 | |||
120 | // --------------------------------------------------------------------------- | ||
121 | |||
122 | function undoActivityData <T extends ActivityUndoObject> ( | ||
123 | url: string, | ||
124 | byActor: MActorAudience, | ||
125 | object: T, | ||
126 | audience?: ActivityAudience | ||
127 | ): ActivityUndo<T> { | ||
128 | if (!audience) audience = getAudience(byActor) | ||
129 | |||
130 | return audiencify( | ||
131 | { | ||
132 | type: 'Undo' as 'Undo', | ||
133 | id: url, | ||
134 | actor: byActor.url, | ||
135 | object | ||
136 | }, | ||
137 | audience | ||
138 | ) | ||
139 | } | ||
140 | |||
141 | async function sendUndoVideoRelatedActivity (options: { | ||
142 | byActor: MActor | ||
143 | video: MVideoAccountLight | ||
144 | url: string | ||
145 | activity: ActivityUndoObject | ||
146 | contextType: ContextType | ||
147 | transaction: Transaction | ||
148 | }) { | ||
149 | const activityBuilder = (audience: ActivityAudience) => { | ||
150 | const undoUrl = getUndoActivityPubUrl(options.url) | ||
151 | |||
152 | return undoActivityData(undoUrl, options.byActor, options.activity, audience) | ||
153 | } | ||
154 | |||
155 | return sendVideoRelatedActivity(activityBuilder, options) | ||
156 | } | ||
157 | |||
158 | async function sendUndoVideoRateToOriginActivity (options: { | ||
159 | byActor: MActor | ||
160 | video: MVideoAccountLight | ||
161 | url: string | ||
162 | activity: ActivityLike | ActivityDislike | ||
163 | transaction: Transaction | ||
164 | }) { | ||
165 | const activityBuilder = (audience: ActivityAudience) => { | ||
166 | const undoUrl = getUndoActivityPubUrl(options.url) | ||
167 | |||
168 | return undoActivityData(undoUrl, options.byActor, options.activity, audience) | ||
169 | } | ||
170 | |||
171 | return sendVideoActivityToOrigin(activityBuilder, { ...options, contextType: 'Rate' }) | ||
172 | } | ||
diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts deleted file mode 100644 index f3fb741c6..000000000 --- a/server/lib/activitypub/send/send-update.ts +++ /dev/null | |||
@@ -1,157 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { getServerActor } from '@server/models/application/application' | ||
3 | import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' | ||
4 | import { logger } from '../../../helpers/logger' | ||
5 | import { AccountModel } from '../../../models/account/account' | ||
6 | import { VideoModel } from '../../../models/video/video' | ||
7 | import { VideoShareModel } from '../../../models/video/video-share' | ||
8 | import { | ||
9 | MAccountDefault, | ||
10 | MActor, | ||
11 | MActorLight, | ||
12 | MChannelDefault, | ||
13 | MVideoAPLight, | ||
14 | MVideoPlaylistFull, | ||
15 | MVideoRedundancyVideo | ||
16 | } from '../../../types/models' | ||
17 | import { audiencify, getAudience } from '../audience' | ||
18 | import { getUpdateActivityPubUrl } from '../url' | ||
19 | import { getActorsInvolvedInVideo } from './shared' | ||
20 | import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils' | ||
21 | |||
22 | async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) { | ||
23 | if (!videoArg.hasPrivacyForFederation()) return undefined | ||
24 | |||
25 | const video = await videoArg.lightAPToFullAP(transaction) | ||
26 | |||
27 | logger.info('Creating job to update video %s.', video.url) | ||
28 | |||
29 | const byActor = overriddenByActor || video.VideoChannel.Account.Actor | ||
30 | |||
31 | const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString()) | ||
32 | |||
33 | const videoObject = await video.toActivityPubObject() | ||
34 | const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC) | ||
35 | |||
36 | const updateActivity = buildUpdateActivity(url, byActor, videoObject, audience) | ||
37 | |||
38 | const actorsInvolved = await getActorsInvolvedInVideo(video, transaction) | ||
39 | if (overriddenByActor) actorsInvolved.push(overriddenByActor) | ||
40 | |||
41 | return broadcastToFollowers({ | ||
42 | data: updateActivity, | ||
43 | byActor, | ||
44 | toFollowersOf: actorsInvolved, | ||
45 | contextType: 'Video', | ||
46 | transaction | ||
47 | }) | ||
48 | } | ||
49 | |||
50 | async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) { | ||
51 | const byActor = accountOrChannel.Actor | ||
52 | |||
53 | logger.info('Creating job to update actor %s.', byActor.url) | ||
54 | |||
55 | const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString()) | ||
56 | const accountOrChannelObject = await (accountOrChannel as any).toActivityPubObject() // FIXME: typescript bug? | ||
57 | const audience = getAudience(byActor) | ||
58 | const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience) | ||
59 | |||
60 | let actorsInvolved: MActor[] | ||
61 | if (accountOrChannel instanceof AccountModel) { | ||
62 | // Actors that shared my videos are involved too | ||
63 | actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction) | ||
64 | } else { | ||
65 | // Actors that shared videos of my channel are involved too | ||
66 | actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, transaction) | ||
67 | } | ||
68 | |||
69 | actorsInvolved.push(byActor) | ||
70 | |||
71 | return broadcastToFollowers({ | ||
72 | data: updateActivity, | ||
73 | byActor, | ||
74 | toFollowersOf: actorsInvolved, | ||
75 | transaction, | ||
76 | contextType: 'Actor' | ||
77 | }) | ||
78 | } | ||
79 | |||
80 | async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) { | ||
81 | logger.info('Creating job to update cache file %s.', redundancyModel.url) | ||
82 | |||
83 | const associatedVideo = redundancyModel.getVideo() | ||
84 | if (!associatedVideo) { | ||
85 | logger.warn('Cannot send update activity for redundancy %s: no video files associated.', redundancyModel.url) | ||
86 | return | ||
87 | } | ||
88 | |||
89 | const video = await VideoModel.loadFull(associatedVideo.id) | ||
90 | |||
91 | const activityBuilder = (audience: ActivityAudience) => { | ||
92 | const redundancyObject = redundancyModel.toActivityPubObject() | ||
93 | const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString()) | ||
94 | |||
95 | return buildUpdateActivity(url, byActor, redundancyObject, audience) | ||
96 | } | ||
97 | |||
98 | return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'CacheFile' }) | ||
99 | } | ||
100 | |||
101 | async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) { | ||
102 | if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined | ||
103 | |||
104 | const byActor = videoPlaylist.OwnerAccount.Actor | ||
105 | |||
106 | logger.info('Creating job to update video playlist %s.', videoPlaylist.url) | ||
107 | |||
108 | const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString()) | ||
109 | |||
110 | const object = await videoPlaylist.toActivityPubObject(null, transaction) | ||
111 | const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC) | ||
112 | |||
113 | const updateActivity = buildUpdateActivity(url, byActor, object, audience) | ||
114 | |||
115 | const serverActor = await getServerActor() | ||
116 | const toFollowersOf = [ byActor, serverActor ] | ||
117 | |||
118 | if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor) | ||
119 | |||
120 | return broadcastToFollowers({ | ||
121 | data: updateActivity, | ||
122 | byActor, | ||
123 | toFollowersOf, | ||
124 | transaction, | ||
125 | contextType: 'Playlist' | ||
126 | }) | ||
127 | } | ||
128 | |||
129 | // --------------------------------------------------------------------------- | ||
130 | |||
131 | export { | ||
132 | sendUpdateActor, | ||
133 | sendUpdateVideo, | ||
134 | sendUpdateCacheFile, | ||
135 | sendUpdateVideoPlaylist | ||
136 | } | ||
137 | |||
138 | // --------------------------------------------------------------------------- | ||
139 | |||
140 | function buildUpdateActivity ( | ||
141 | url: string, | ||
142 | byActor: MActorLight, | ||
143 | object: ActivityUpdateObject, | ||
144 | audience?: ActivityAudience | ||
145 | ): ActivityUpdate<ActivityUpdateObject> { | ||
146 | if (!audience) audience = getAudience(byActor) | ||
147 | |||
148 | return audiencify( | ||
149 | { | ||
150 | type: 'Update' as 'Update', | ||
151 | id: url, | ||
152 | actor: byActor.url, | ||
153 | object: audiencify(object, audience) | ||
154 | }, | ||
155 | audience | ||
156 | ) | ||
157 | } | ||
diff --git a/server/lib/activitypub/send/send-view.ts b/server/lib/activitypub/send/send-view.ts deleted file mode 100644 index bf3451603..000000000 --- a/server/lib/activitypub/send/send-view.ts +++ /dev/null | |||
@@ -1,62 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
3 | import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models' | ||
4 | import { ActivityAudience, ActivityView } from '@shared/models' | ||
5 | import { logger } from '../../../helpers/logger' | ||
6 | import { audiencify, getAudience } from '../audience' | ||
7 | import { getLocalVideoViewActivityPubUrl } from '../url' | ||
8 | import { sendVideoRelatedActivity } from './shared/send-utils' | ||
9 | |||
10 | type ViewType = 'view' | 'viewer' | ||
11 | |||
12 | async function sendView (options: { | ||
13 | byActor: MActorLight | ||
14 | type: ViewType | ||
15 | video: MVideoImmutable | ||
16 | viewerIdentifier: string | ||
17 | transaction?: Transaction | ||
18 | }) { | ||
19 | const { byActor, type, video, viewerIdentifier, transaction } = options | ||
20 | |||
21 | logger.info('Creating job to send %s of %s.', type, video.url) | ||
22 | |||
23 | const activityBuilder = (audience: ActivityAudience) => { | ||
24 | const url = getLocalVideoViewActivityPubUrl(byActor, video, viewerIdentifier) | ||
25 | |||
26 | return buildViewActivity({ url, byActor, video, audience, type }) | ||
27 | } | ||
28 | |||
29 | return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View', parallelizable: true }) | ||
30 | } | ||
31 | |||
32 | // --------------------------------------------------------------------------- | ||
33 | |||
34 | export { | ||
35 | sendView | ||
36 | } | ||
37 | |||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | function buildViewActivity (options: { | ||
41 | url: string | ||
42 | byActor: MActorAudience | ||
43 | video: MVideoUrl | ||
44 | type: ViewType | ||
45 | audience?: ActivityAudience | ||
46 | }): ActivityView { | ||
47 | const { url, byActor, type, video, audience = getAudience(byActor) } = options | ||
48 | |||
49 | return audiencify( | ||
50 | { | ||
51 | id: url, | ||
52 | type: 'View' as 'View', | ||
53 | actor: byActor.url, | ||
54 | object: video.url, | ||
55 | |||
56 | expires: type === 'viewer' | ||
57 | ? new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString() | ||
58 | : undefined | ||
59 | }, | ||
60 | audience | ||
61 | ) | ||
62 | } | ||
diff --git a/server/lib/activitypub/send/shared/audience-utils.ts b/server/lib/activitypub/send/shared/audience-utils.ts deleted file mode 100644 index 2f6b0741d..000000000 --- a/server/lib/activitypub/send/shared/audience-utils.ts +++ /dev/null | |||
@@ -1,74 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ACTIVITY_PUB } from '@server/initializers/constants' | ||
3 | import { ActorModel } from '@server/models/actor/actor' | ||
4 | import { VideoModel } from '@server/models/video/video' | ||
5 | import { VideoShareModel } from '@server/models/video/video-share' | ||
6 | import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models' | ||
7 | import { ActivityAudience } from '@shared/models' | ||
8 | |||
9 | function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience { | ||
10 | return { | ||
11 | to: [ accountActor.url ], | ||
12 | cc: actorsInvolvedInVideo.map(a => a.followersUrl) | ||
13 | } | ||
14 | } | ||
15 | |||
16 | function getVideoCommentAudience ( | ||
17 | videoComment: MCommentOwnerVideo, | ||
18 | threadParentComments: MCommentOwner[], | ||
19 | actorsInvolvedInVideo: MActorFollowersUrl[], | ||
20 | isOrigin = false | ||
21 | ): ActivityAudience { | ||
22 | const to = [ ACTIVITY_PUB.PUBLIC ] | ||
23 | const cc: string[] = [] | ||
24 | |||
25 | // Owner of the video we comment | ||
26 | if (isOrigin === false) { | ||
27 | cc.push(videoComment.Video.VideoChannel.Account.Actor.url) | ||
28 | } | ||
29 | |||
30 | // Followers of the poster | ||
31 | cc.push(videoComment.Account.Actor.followersUrl) | ||
32 | |||
33 | // Send to actors we reply to | ||
34 | for (const parentComment of threadParentComments) { | ||
35 | if (parentComment.isDeleted()) continue | ||
36 | |||
37 | cc.push(parentComment.Account.Actor.url) | ||
38 | } | ||
39 | |||
40 | return { | ||
41 | to, | ||
42 | cc: cc.concat(actorsInvolvedInVideo.map(a => a.followersUrl)) | ||
43 | } | ||
44 | } | ||
45 | |||
46 | function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience { | ||
47 | return { | ||
48 | to: [ ACTIVITY_PUB.PUBLIC ].concat(actorsInvolvedInObject.map(a => a.followersUrl)), | ||
49 | cc: [] | ||
50 | } | ||
51 | } | ||
52 | |||
53 | async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) { | ||
54 | const actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t) | ||
55 | |||
56 | const videoAll = video as VideoModel | ||
57 | |||
58 | const videoActor = videoAll.VideoChannel?.Account | ||
59 | ? videoAll.VideoChannel.Account.Actor | ||
60 | : await ActorModel.loadAccountActorFollowerUrlByVideoId(video.id, t) | ||
61 | |||
62 | actors.push(videoActor) | ||
63 | |||
64 | return actors | ||
65 | } | ||
66 | |||
67 | // --------------------------------------------------------------------------- | ||
68 | |||
69 | export { | ||
70 | getOriginVideoAudience, | ||
71 | getActorsInvolvedInVideo, | ||
72 | getAudienceFromFollowersOf, | ||
73 | getVideoCommentAudience | ||
74 | } | ||
diff --git a/server/lib/activitypub/send/shared/index.ts b/server/lib/activitypub/send/shared/index.ts deleted file mode 100644 index bda579115..000000000 --- a/server/lib/activitypub/send/shared/index.ts +++ /dev/null | |||
@@ -1,2 +0,0 @@ | |||
1 | export * from './audience-utils' | ||
2 | export * from './send-utils' | ||
diff --git a/server/lib/activitypub/send/shared/send-utils.ts b/server/lib/activitypub/send/shared/send-utils.ts deleted file mode 100644 index 2bc1ef8f5..000000000 --- a/server/lib/activitypub/send/shared/send-utils.ts +++ /dev/null | |||
@@ -1,291 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { ActorFollowHealthCache } from '@server/lib/actor-follow-health-cache' | ||
3 | import { getServerActor } from '@server/models/application/application' | ||
4 | import { Activity, ActivityAudience, ActivitypubHttpBroadcastPayload } from '@shared/models' | ||
5 | import { ContextType } from '@shared/models/activitypub/context' | ||
6 | import { afterCommitIfTransaction } from '../../../../helpers/database-utils' | ||
7 | import { logger } from '../../../../helpers/logger' | ||
8 | import { ActorModel } from '../../../../models/actor/actor' | ||
9 | import { ActorFollowModel } from '../../../../models/actor/actor-follow' | ||
10 | import { MActor, MActorId, MActorLight, MActorWithInboxes, MVideoAccountLight, MVideoId, MVideoImmutable } from '../../../../types/models' | ||
11 | import { JobQueue } from '../../../job-queue' | ||
12 | import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getOriginVideoAudience } from './audience-utils' | ||
13 | |||
14 | async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: { | ||
15 | byActor: MActorLight | ||
16 | video: MVideoImmutable | MVideoAccountLight | ||
17 | contextType: ContextType | ||
18 | parallelizable?: boolean | ||
19 | transaction?: Transaction | ||
20 | }) { | ||
21 | const { byActor, video, transaction, contextType, parallelizable } = options | ||
22 | |||
23 | // Send to origin | ||
24 | if (video.isOwned() === false) { | ||
25 | return sendVideoActivityToOrigin(activityBuilder, options) | ||
26 | } | ||
27 | |||
28 | const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, transaction) | ||
29 | |||
30 | // Send to followers | ||
31 | const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo) | ||
32 | const activity = activityBuilder(audience) | ||
33 | |||
34 | const actorsException = [ byActor ] | ||
35 | |||
36 | return broadcastToFollowers({ | ||
37 | data: activity, | ||
38 | byActor, | ||
39 | toFollowersOf: actorsInvolvedInVideo, | ||
40 | transaction, | ||
41 | actorsException, | ||
42 | parallelizable, | ||
43 | contextType | ||
44 | }) | ||
45 | } | ||
46 | |||
47 | async function sendVideoActivityToOrigin (activityBuilder: (audience: ActivityAudience) => Activity, options: { | ||
48 | byActor: MActorLight | ||
49 | video: MVideoImmutable | MVideoAccountLight | ||
50 | contextType: ContextType | ||
51 | |||
52 | actorsInvolvedInVideo?: MActorLight[] | ||
53 | transaction?: Transaction | ||
54 | }) { | ||
55 | const { byActor, video, actorsInvolvedInVideo, transaction, contextType } = options | ||
56 | |||
57 | if (video.isOwned()) throw new Error('Cannot send activity to owned video origin ' + video.url) | ||
58 | |||
59 | let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor | ||
60 | if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction) | ||
61 | |||
62 | const audience = getOriginVideoAudience(accountActor, actorsInvolvedInVideo) | ||
63 | const activity = activityBuilder(audience) | ||
64 | |||
65 | return afterCommitIfTransaction(transaction, () => { | ||
66 | return unicastTo({ | ||
67 | data: activity, | ||
68 | byActor, | ||
69 | toActorUrl: accountActor.getSharedInbox(), | ||
70 | contextType | ||
71 | }) | ||
72 | }) | ||
73 | } | ||
74 | |||
75 | // --------------------------------------------------------------------------- | ||
76 | |||
77 | async function forwardVideoRelatedActivity ( | ||
78 | activity: Activity, | ||
79 | t: Transaction, | ||
80 | followersException: MActorWithInboxes[], | ||
81 | video: MVideoId | ||
82 | ) { | ||
83 | // Mastodon does not add our announces in audience, so we forward to them manually | ||
84 | const additionalActors = await getActorsInvolvedInVideo(video, t) | ||
85 | const additionalFollowerUrls = additionalActors.map(a => a.followersUrl) | ||
86 | |||
87 | return forwardActivity(activity, t, followersException, additionalFollowerUrls) | ||
88 | } | ||
89 | |||
90 | async function forwardActivity ( | ||
91 | activity: Activity, | ||
92 | t: Transaction, | ||
93 | followersException: MActorWithInboxes[] = [], | ||
94 | additionalFollowerUrls: string[] = [] | ||
95 | ) { | ||
96 | logger.info('Forwarding activity %s.', activity.id) | ||
97 | |||
98 | const to = activity.to || [] | ||
99 | const cc = activity.cc || [] | ||
100 | |||
101 | const followersUrls = additionalFollowerUrls | ||
102 | for (const dest of to.concat(cc)) { | ||
103 | if (dest.endsWith('/followers')) { | ||
104 | followersUrls.push(dest) | ||
105 | } | ||
106 | } | ||
107 | |||
108 | const toActorFollowers = await ActorModel.listByFollowersUrls(followersUrls, t) | ||
109 | const uris = await computeFollowerUris(toActorFollowers, followersException, t) | ||
110 | |||
111 | if (uris.length === 0) { | ||
112 | logger.info('0 followers for %s, no forwarding.', toActorFollowers.map(a => a.id).join(', ')) | ||
113 | return undefined | ||
114 | } | ||
115 | |||
116 | logger.debug('Creating forwarding job.', { uris }) | ||
117 | |||
118 | const payload: ActivitypubHttpBroadcastPayload = { | ||
119 | uris, | ||
120 | body: activity, | ||
121 | contextType: null | ||
122 | } | ||
123 | return afterCommitIfTransaction(t, () => JobQueue.Instance.createJobAsync({ type: 'activitypub-http-broadcast', payload })) | ||
124 | } | ||
125 | |||
126 | // --------------------------------------------------------------------------- | ||
127 | |||
128 | async function broadcastToFollowers (options: { | ||
129 | data: any | ||
130 | byActor: MActorId | ||
131 | toFollowersOf: MActorId[] | ||
132 | transaction: Transaction | ||
133 | contextType: ContextType | ||
134 | |||
135 | parallelizable?: boolean | ||
136 | actorsException?: MActorWithInboxes[] | ||
137 | }) { | ||
138 | const { data, byActor, toFollowersOf, transaction, contextType, actorsException = [], parallelizable } = options | ||
139 | |||
140 | const uris = await computeFollowerUris(toFollowersOf, actorsException, transaction) | ||
141 | |||
142 | return afterCommitIfTransaction(transaction, () => { | ||
143 | return broadcastTo({ | ||
144 | uris, | ||
145 | data, | ||
146 | byActor, | ||
147 | parallelizable, | ||
148 | contextType | ||
149 | }) | ||
150 | }) | ||
151 | } | ||
152 | |||
153 | async function broadcastToActors (options: { | ||
154 | data: any | ||
155 | byActor: MActorId | ||
156 | toActors: MActor[] | ||
157 | transaction: Transaction | ||
158 | contextType: ContextType | ||
159 | actorsException?: MActorWithInboxes[] | ||
160 | }) { | ||
161 | const { data, byActor, toActors, transaction, contextType, actorsException = [] } = options | ||
162 | |||
163 | const uris = await computeUris(toActors, actorsException) | ||
164 | |||
165 | return afterCommitIfTransaction(transaction, () => { | ||
166 | return broadcastTo({ | ||
167 | uris, | ||
168 | data, | ||
169 | byActor, | ||
170 | contextType | ||
171 | }) | ||
172 | }) | ||
173 | } | ||
174 | |||
175 | function broadcastTo (options: { | ||
176 | uris: string[] | ||
177 | data: any | ||
178 | byActor: MActorId | ||
179 | contextType: ContextType | ||
180 | parallelizable?: boolean // default to false | ||
181 | }) { | ||
182 | const { uris, data, byActor, contextType, parallelizable } = options | ||
183 | |||
184 | if (uris.length === 0) return undefined | ||
185 | |||
186 | const broadcastUris: string[] = [] | ||
187 | const unicastUris: string[] = [] | ||
188 | |||
189 | // Bad URIs could be slow to respond, prefer to process them in a dedicated queue | ||
190 | for (const uri of uris) { | ||
191 | if (ActorFollowHealthCache.Instance.isBadInbox(uri)) { | ||
192 | unicastUris.push(uri) | ||
193 | } else { | ||
194 | broadcastUris.push(uri) | ||
195 | } | ||
196 | } | ||
197 | |||
198 | logger.debug('Creating broadcast job.', { broadcastUris, unicastUris }) | ||
199 | |||
200 | if (broadcastUris.length !== 0) { | ||
201 | const payload = { | ||
202 | uris: broadcastUris, | ||
203 | signatureActorId: byActor.id, | ||
204 | body: data, | ||
205 | contextType | ||
206 | } | ||
207 | |||
208 | JobQueue.Instance.createJobAsync({ | ||
209 | type: parallelizable | ||
210 | ? 'activitypub-http-broadcast-parallel' | ||
211 | : 'activitypub-http-broadcast', | ||
212 | |||
213 | payload | ||
214 | }) | ||
215 | } | ||
216 | |||
217 | for (const unicastUri of unicastUris) { | ||
218 | const payload = { | ||
219 | uri: unicastUri, | ||
220 | signatureActorId: byActor.id, | ||
221 | body: data, | ||
222 | contextType | ||
223 | } | ||
224 | |||
225 | JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload }) | ||
226 | } | ||
227 | } | ||
228 | |||
229 | function unicastTo (options: { | ||
230 | data: any | ||
231 | byActor: MActorId | ||
232 | toActorUrl: string | ||
233 | contextType: ContextType | ||
234 | }) { | ||
235 | const { data, byActor, toActorUrl, contextType } = options | ||
236 | |||
237 | logger.debug('Creating unicast job.', { uri: toActorUrl }) | ||
238 | |||
239 | const payload = { | ||
240 | uri: toActorUrl, | ||
241 | signatureActorId: byActor.id, | ||
242 | body: data, | ||
243 | contextType | ||
244 | } | ||
245 | |||
246 | JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload }) | ||
247 | } | ||
248 | |||
249 | // --------------------------------------------------------------------------- | ||
250 | |||
251 | export { | ||
252 | broadcastToFollowers, | ||
253 | unicastTo, | ||
254 | forwardActivity, | ||
255 | broadcastToActors, | ||
256 | sendVideoActivityToOrigin, | ||
257 | forwardVideoRelatedActivity, | ||
258 | sendVideoRelatedActivity | ||
259 | } | ||
260 | |||
261 | // --------------------------------------------------------------------------- | ||
262 | |||
263 | async function computeFollowerUris (toFollowersOf: MActorId[], actorsException: MActorWithInboxes[], t: Transaction) { | ||
264 | const toActorFollowerIds = toFollowersOf.map(a => a.id) | ||
265 | |||
266 | const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t) | ||
267 | const sharedInboxesException = await buildSharedInboxesException(actorsException) | ||
268 | |||
269 | return result.data.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false) | ||
270 | } | ||
271 | |||
272 | async function computeUris (toActors: MActor[], actorsException: MActorWithInboxes[] = []) { | ||
273 | const serverActor = await getServerActor() | ||
274 | const targetUrls = toActors | ||
275 | .filter(a => a.id !== serverActor.id) // Don't send to ourselves | ||
276 | .map(a => a.getSharedInbox()) | ||
277 | |||
278 | const toActorSharedInboxesSet = new Set(targetUrls) | ||
279 | |||
280 | const sharedInboxesException = await buildSharedInboxesException(actorsException) | ||
281 | return Array.from(toActorSharedInboxesSet) | ||
282 | .filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false) | ||
283 | } | ||
284 | |||
285 | async function buildSharedInboxesException (actorsException: MActorWithInboxes[]) { | ||
286 | const serverActor = await getServerActor() | ||
287 | |||
288 | return actorsException | ||
289 | .map(f => f.getSharedInbox()) | ||
290 | .concat([ serverActor.sharedInboxUrl ]) | ||
291 | } | ||
diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts deleted file mode 100644 index 792a73f2a..000000000 --- a/server/lib/activitypub/share.ts +++ /dev/null | |||
@@ -1,120 +0,0 @@ | |||
1 | import { map } from 'bluebird' | ||
2 | import { Transaction } from 'sequelize' | ||
3 | import { getServerActor } from '@server/models/application/application' | ||
4 | import { logger, loggerTagsFactory } from '../../helpers/logger' | ||
5 | import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants' | ||
6 | import { VideoShareModel } from '../../models/video/video-share' | ||
7 | import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video' | ||
8 | import { fetchAP, getAPId } from './activity' | ||
9 | import { getOrCreateAPActor } from './actors' | ||
10 | import { sendUndoAnnounce, sendVideoAnnounce } from './send' | ||
11 | import { checkUrlsSameHost, getLocalVideoAnnounceActivityPubUrl } from './url' | ||
12 | |||
13 | const lTags = loggerTagsFactory('share') | ||
14 | |||
15 | async function shareVideoByServerAndChannel (video: MVideoAccountLight, t: Transaction) { | ||
16 | if (!video.hasPrivacyForFederation()) return undefined | ||
17 | |||
18 | return Promise.all([ | ||
19 | shareByServer(video, t), | ||
20 | shareByVideoChannel(video, t) | ||
21 | ]) | ||
22 | } | ||
23 | |||
24 | async function changeVideoChannelShare ( | ||
25 | video: MVideoAccountLight, | ||
26 | oldVideoChannel: MChannelActorLight, | ||
27 | t: Transaction | ||
28 | ) { | ||
29 | logger.info( | ||
30 | 'Updating video channel of video %s: %s -> %s.', video.uuid, oldVideoChannel.name, video.VideoChannel.name, | ||
31 | lTags(video.uuid) | ||
32 | ) | ||
33 | |||
34 | await undoShareByVideoChannel(video, oldVideoChannel, t) | ||
35 | |||
36 | await shareByVideoChannel(video, t) | ||
37 | } | ||
38 | |||
39 | async function addVideoShares (shareUrls: string[], video: MVideoId) { | ||
40 | await map(shareUrls, async shareUrl => { | ||
41 | try { | ||
42 | await addVideoShare(shareUrl, video) | ||
43 | } catch (err) { | ||
44 | logger.warn('Cannot add share %s.', shareUrl, { err }) | ||
45 | } | ||
46 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
47 | } | ||
48 | |||
49 | export { | ||
50 | changeVideoChannelShare, | ||
51 | addVideoShares, | ||
52 | shareVideoByServerAndChannel | ||
53 | } | ||
54 | |||
55 | // --------------------------------------------------------------------------- | ||
56 | |||
57 | async function addVideoShare (shareUrl: string, video: MVideoId) { | ||
58 | const { body } = await fetchAP<any>(shareUrl) | ||
59 | if (!body?.actor) throw new Error('Body or body actor is invalid') | ||
60 | |||
61 | const actorUrl = getAPId(body.actor) | ||
62 | if (checkUrlsSameHost(shareUrl, actorUrl) !== true) { | ||
63 | throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`) | ||
64 | } | ||
65 | |||
66 | const actor = await getOrCreateAPActor(actorUrl) | ||
67 | |||
68 | const entry = { | ||
69 | actorId: actor.id, | ||
70 | videoId: video.id, | ||
71 | url: shareUrl | ||
72 | } | ||
73 | |||
74 | await VideoShareModel.upsert(entry) | ||
75 | } | ||
76 | |||
77 | async function shareByServer (video: MVideo, t: Transaction) { | ||
78 | const serverActor = await getServerActor() | ||
79 | |||
80 | const serverShareUrl = getLocalVideoAnnounceActivityPubUrl(serverActor, video) | ||
81 | const [ serverShare ] = await VideoShareModel.findOrCreate({ | ||
82 | defaults: { | ||
83 | actorId: serverActor.id, | ||
84 | videoId: video.id, | ||
85 | url: serverShareUrl | ||
86 | }, | ||
87 | where: { | ||
88 | url: serverShareUrl | ||
89 | }, | ||
90 | transaction: t | ||
91 | }) | ||
92 | |||
93 | return sendVideoAnnounce(serverActor, serverShare, video, t) | ||
94 | } | ||
95 | |||
96 | async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) { | ||
97 | const videoChannelShareUrl = getLocalVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video) | ||
98 | const [ videoChannelShare ] = await VideoShareModel.findOrCreate({ | ||
99 | defaults: { | ||
100 | actorId: video.VideoChannel.actorId, | ||
101 | videoId: video.id, | ||
102 | url: videoChannelShareUrl | ||
103 | }, | ||
104 | where: { | ||
105 | url: videoChannelShareUrl | ||
106 | }, | ||
107 | transaction: t | ||
108 | }) | ||
109 | |||
110 | return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t) | ||
111 | } | ||
112 | |||
113 | async function undoShareByVideoChannel (video: MVideo, oldVideoChannel: MChannelActorLight, t: Transaction) { | ||
114 | // Load old share | ||
115 | const oldShare = await VideoShareModel.load(oldVideoChannel.actorId, video.id, t) | ||
116 | if (!oldShare) return new Error('Cannot find old video channel share ' + oldVideoChannel.actorId + ' for video ' + video.id) | ||
117 | |||
118 | await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t) | ||
119 | await oldShare.destroy({ transaction: t }) | ||
120 | } | ||
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts deleted file mode 100644 index 5cdac71bf..000000000 --- a/server/lib/activitypub/url.ts +++ /dev/null | |||
@@ -1,177 +0,0 @@ | |||
1 | import { REMOTE_SCHEME, WEBSERVER } from '../../initializers/constants' | ||
2 | import { | ||
3 | MAbuseFull, | ||
4 | MAbuseId, | ||
5 | MActor, | ||
6 | MActorFollow, | ||
7 | MActorId, | ||
8 | MActorUrl, | ||
9 | MCommentId, | ||
10 | MLocalVideoViewer, | ||
11 | MVideoId, | ||
12 | MVideoPlaylistElement, | ||
13 | MVideoUrl, | ||
14 | MVideoUUID, | ||
15 | MVideoWithHost | ||
16 | } from '../../types/models' | ||
17 | import { MVideoFileVideoUUID } from '../../types/models/video/video-file' | ||
18 | import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist' | ||
19 | import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist' | ||
20 | |||
21 | function getLocalVideoActivityPubUrl (video: MVideoUUID) { | ||
22 | return WEBSERVER.URL + '/videos/watch/' + video.uuid | ||
23 | } | ||
24 | |||
25 | function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) { | ||
26 | return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid | ||
27 | } | ||
28 | |||
29 | function getLocalVideoPlaylistElementActivityPubUrl (videoPlaylist: MVideoPlaylistUUID, videoPlaylistElement: MVideoPlaylistElement) { | ||
30 | return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/videos/' + videoPlaylistElement.id | ||
31 | } | ||
32 | |||
33 | function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) { | ||
34 | const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : '' | ||
35 | |||
36 | return `${WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}` | ||
37 | } | ||
38 | |||
39 | function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) { | ||
40 | return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}` | ||
41 | } | ||
42 | |||
43 | function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) { | ||
44 | return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id | ||
45 | } | ||
46 | |||
47 | function getLocalVideoChannelActivityPubUrl (videoChannelName: string) { | ||
48 | return WEBSERVER.URL + '/video-channels/' + videoChannelName | ||
49 | } | ||
50 | |||
51 | function getLocalAccountActivityPubUrl (accountName: string) { | ||
52 | return WEBSERVER.URL + '/accounts/' + accountName | ||
53 | } | ||
54 | |||
55 | function getLocalAbuseActivityPubUrl (abuse: MAbuseId) { | ||
56 | return WEBSERVER.URL + '/admin/abuses/' + abuse.id | ||
57 | } | ||
58 | |||
59 | function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) { | ||
60 | return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier | ||
61 | } | ||
62 | |||
63 | function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) { | ||
64 | return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid | ||
65 | } | ||
66 | |||
67 | function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { | ||
68 | return byActor.url + '/likes/' + video.id | ||
69 | } | ||
70 | |||
71 | function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { | ||
72 | return byActor.url + '/dislikes/' + video.id | ||
73 | } | ||
74 | |||
75 | function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) { | ||
76 | return video.url + '/announces' | ||
77 | } | ||
78 | |||
79 | function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) { | ||
80 | return video.url + '/comments' | ||
81 | } | ||
82 | |||
83 | function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { | ||
84 | return video.url + '/likes' | ||
85 | } | ||
86 | |||
87 | function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) { | ||
88 | return video.url + '/dislikes' | ||
89 | } | ||
90 | |||
91 | function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) { | ||
92 | return follower.url + '/follows/' + following.id | ||
93 | } | ||
94 | |||
95 | function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) { | ||
96 | return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id | ||
97 | } | ||
98 | |||
99 | function getLocalActorFollowRejectActivityPubUrl () { | ||
100 | return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString() | ||
101 | } | ||
102 | |||
103 | function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) { | ||
104 | return video.url + '/announces/' + byActor.id | ||
105 | } | ||
106 | |||
107 | function getDeleteActivityPubUrl (originalUrl: string) { | ||
108 | return originalUrl + '/delete' | ||
109 | } | ||
110 | |||
111 | function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) { | ||
112 | return originalUrl + '/updates/' + updatedAt | ||
113 | } | ||
114 | |||
115 | function getUndoActivityPubUrl (originalUrl: string) { | ||
116 | return originalUrl + '/undo' | ||
117 | } | ||
118 | |||
119 | // --------------------------------------------------------------------------- | ||
120 | |||
121 | function getAbuseTargetUrl (abuse: MAbuseFull) { | ||
122 | return abuse.VideoAbuse?.Video?.url || | ||
123 | abuse.VideoCommentAbuse?.VideoComment?.url || | ||
124 | abuse.FlaggedAccount.Actor.url | ||
125 | } | ||
126 | |||
127 | // --------------------------------------------------------------------------- | ||
128 | |||
129 | function buildRemoteVideoBaseUrl (video: MVideoWithHost, path: string, scheme?: string) { | ||
130 | if (!scheme) scheme = REMOTE_SCHEME.HTTP | ||
131 | |||
132 | const host = video.VideoChannel.Actor.Server.host | ||
133 | |||
134 | return scheme + '://' + host + path | ||
135 | } | ||
136 | |||
137 | // --------------------------------------------------------------------------- | ||
138 | |||
139 | function checkUrlsSameHost (url1: string, url2: string) { | ||
140 | const idHost = new URL(url1).host | ||
141 | const actorHost = new URL(url2).host | ||
142 | |||
143 | return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase() | ||
144 | } | ||
145 | |||
146 | // --------------------------------------------------------------------------- | ||
147 | |||
148 | export { | ||
149 | getLocalVideoActivityPubUrl, | ||
150 | getLocalVideoPlaylistActivityPubUrl, | ||
151 | getLocalVideoPlaylistElementActivityPubUrl, | ||
152 | getLocalVideoCacheFileActivityPubUrl, | ||
153 | getLocalVideoCacheStreamingPlaylistActivityPubUrl, | ||
154 | getLocalVideoCommentActivityPubUrl, | ||
155 | getLocalVideoChannelActivityPubUrl, | ||
156 | getLocalAccountActivityPubUrl, | ||
157 | getLocalAbuseActivityPubUrl, | ||
158 | getLocalActorFollowActivityPubUrl, | ||
159 | getLocalActorFollowAcceptActivityPubUrl, | ||
160 | getLocalVideoAnnounceActivityPubUrl, | ||
161 | getUpdateActivityPubUrl, | ||
162 | getUndoActivityPubUrl, | ||
163 | getVideoLikeActivityPubUrlByLocalActor, | ||
164 | getLocalVideoViewActivityPubUrl, | ||
165 | getVideoDislikeActivityPubUrlByLocalActor, | ||
166 | getLocalActorFollowRejectActivityPubUrl, | ||
167 | getDeleteActivityPubUrl, | ||
168 | getLocalVideoSharesActivityPubUrl, | ||
169 | getLocalVideoCommentsActivityPubUrl, | ||
170 | getLocalVideoLikesActivityPubUrl, | ||
171 | getLocalVideoDislikesActivityPubUrl, | ||
172 | getLocalVideoViewerActivityPubUrl, | ||
173 | |||
174 | getAbuseTargetUrl, | ||
175 | checkUrlsSameHost, | ||
176 | buildRemoteVideoBaseUrl | ||
177 | } | ||
diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts deleted file mode 100644 index b861be5bd..000000000 --- a/server/lib/activitypub/video-comments.ts +++ /dev/null | |||
@@ -1,205 +0,0 @@ | |||
1 | import { map } from 'bluebird' | ||
2 | |||
3 | import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments' | ||
4 | import { logger } from '../../helpers/logger' | ||
5 | import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants' | ||
6 | import { VideoCommentModel } from '../../models/video/video-comment' | ||
7 | import { MComment, MCommentOwner, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../types/models/video' | ||
8 | import { isRemoteVideoCommentAccepted } from '../moderation' | ||
9 | import { Hooks } from '../plugins/hooks' | ||
10 | import { fetchAP } from './activity' | ||
11 | import { getOrCreateAPActor } from './actors' | ||
12 | import { checkUrlsSameHost } from './url' | ||
13 | import { getOrCreateAPVideo } from './videos' | ||
14 | |||
15 | type ResolveThreadParams = { | ||
16 | url: string | ||
17 | comments?: MCommentOwner[] | ||
18 | isVideo?: boolean | ||
19 | commentCreated?: boolean | ||
20 | } | ||
21 | type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }> | ||
22 | |||
23 | async function addVideoComments (commentUrls: string[]) { | ||
24 | return map(commentUrls, async commentUrl => { | ||
25 | try { | ||
26 | await resolveThread({ url: commentUrl, isVideo: false }) | ||
27 | } catch (err) { | ||
28 | logger.warn('Cannot resolve thread %s.', commentUrl, { err }) | ||
29 | } | ||
30 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
31 | } | ||
32 | |||
33 | async function resolveThread (params: ResolveThreadParams): ResolveThreadResult { | ||
34 | const { url, isVideo } = params | ||
35 | |||
36 | if (params.commentCreated === undefined) params.commentCreated = false | ||
37 | if (params.comments === undefined) params.comments = [] | ||
38 | |||
39 | // If it is not a video, or if we don't know if it's a video, try to get the thread from DB | ||
40 | if (isVideo === false || isVideo === undefined) { | ||
41 | const result = await resolveCommentFromDB(params) | ||
42 | if (result) return result | ||
43 | } | ||
44 | |||
45 | try { | ||
46 | // If it is a video, or if we don't know if it's a video | ||
47 | if (isVideo === true || isVideo === undefined) { | ||
48 | // Keep await so we catch the exception | ||
49 | return await tryToResolveThreadFromVideo(params) | ||
50 | } | ||
51 | } catch (err) { | ||
52 | logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err }) | ||
53 | } | ||
54 | |||
55 | return resolveRemoteParentComment(params) | ||
56 | } | ||
57 | |||
58 | export { | ||
59 | addVideoComments, | ||
60 | resolveThread | ||
61 | } | ||
62 | |||
63 | // --------------------------------------------------------------------------- | ||
64 | |||
65 | async function resolveCommentFromDB (params: ResolveThreadParams) { | ||
66 | const { url, comments, commentCreated } = params | ||
67 | |||
68 | const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url) | ||
69 | if (!commentFromDatabase) return undefined | ||
70 | |||
71 | let parentComments = comments.concat([ commentFromDatabase ]) | ||
72 | |||
73 | // Speed up things and resolve directly the thread | ||
74 | if (commentFromDatabase.InReplyToVideoComment) { | ||
75 | const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC') | ||
76 | |||
77 | parentComments = parentComments.concat(data) | ||
78 | } | ||
79 | |||
80 | return resolveThread({ | ||
81 | url: commentFromDatabase.Video.url, | ||
82 | comments: parentComments, | ||
83 | isVideo: true, | ||
84 | commentCreated | ||
85 | }) | ||
86 | } | ||
87 | |||
88 | async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { | ||
89 | const { url, comments, commentCreated } = params | ||
90 | |||
91 | // Maybe it's a reply to a video? | ||
92 | // If yes, it's done: we resolved all the thread | ||
93 | const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false } | ||
94 | const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam }) | ||
95 | |||
96 | if (video.isOwned() && !video.hasPrivacyForFederation()) { | ||
97 | throw new Error('Cannot resolve thread of video with privacy that is not compatible with federation') | ||
98 | } | ||
99 | |||
100 | let resultComment: MCommentOwnerVideo | ||
101 | if (comments.length !== 0) { | ||
102 | const firstReply = comments[comments.length - 1] as MCommentOwnerVideo | ||
103 | firstReply.inReplyToCommentId = null | ||
104 | firstReply.originCommentId = null | ||
105 | firstReply.videoId = video.id | ||
106 | firstReply.changed('updatedAt', true) | ||
107 | firstReply.Video = video | ||
108 | |||
109 | if (await isRemoteCommentAccepted(firstReply) !== true) { | ||
110 | return undefined | ||
111 | } | ||
112 | |||
113 | comments[comments.length - 1] = await firstReply.save() | ||
114 | |||
115 | for (let i = comments.length - 2; i >= 0; i--) { | ||
116 | const comment = comments[i] as MCommentOwnerVideo | ||
117 | comment.originCommentId = firstReply.id | ||
118 | comment.inReplyToCommentId = comments[i + 1].id | ||
119 | comment.videoId = video.id | ||
120 | comment.changed('updatedAt', true) | ||
121 | comment.Video = video | ||
122 | |||
123 | if (await isRemoteCommentAccepted(comment) !== true) { | ||
124 | return undefined | ||
125 | } | ||
126 | |||
127 | comments[i] = await comment.save() | ||
128 | } | ||
129 | |||
130 | resultComment = comments[0] as MCommentOwnerVideo | ||
131 | } | ||
132 | |||
133 | return { video, comment: resultComment, commentCreated } | ||
134 | } | ||
135 | |||
136 | async function resolveRemoteParentComment (params: ResolveThreadParams) { | ||
137 | const { url, comments } = params | ||
138 | |||
139 | if (comments.length > ACTIVITY_PUB.MAX_RECURSION_COMMENTS) { | ||
140 | throw new Error('Recursion limit reached when resolving a thread') | ||
141 | } | ||
142 | |||
143 | const { body } = await fetchAP<any>(url) | ||
144 | |||
145 | if (sanitizeAndCheckVideoCommentObject(body) === false) { | ||
146 | throw new Error(`Remote video comment JSON ${url} is not valid:` + JSON.stringify(body)) | ||
147 | } | ||
148 | |||
149 | const actorUrl = body.attributedTo | ||
150 | if (!actorUrl && body.type !== 'Tombstone') throw new Error('Miss attributed to in comment') | ||
151 | |||
152 | if (actorUrl && checkUrlsSameHost(url, actorUrl) !== true) { | ||
153 | throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`) | ||
154 | } | ||
155 | |||
156 | if (checkUrlsSameHost(body.id, url) !== true) { | ||
157 | throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`) | ||
158 | } | ||
159 | |||
160 | const actor = actorUrl | ||
161 | ? await getOrCreateAPActor(actorUrl, 'all') | ||
162 | : null | ||
163 | |||
164 | const comment = new VideoCommentModel({ | ||
165 | url: body.id, | ||
166 | text: body.content ? body.content : '', | ||
167 | videoId: null, | ||
168 | accountId: actor ? actor.Account.id : null, | ||
169 | inReplyToCommentId: null, | ||
170 | originCommentId: null, | ||
171 | createdAt: new Date(body.published), | ||
172 | updatedAt: new Date(body.updated), | ||
173 | deletedAt: body.deleted ? new Date(body.deleted) : null | ||
174 | }) as MCommentOwner | ||
175 | comment.Account = actor ? actor.Account : null | ||
176 | |||
177 | return resolveThread({ | ||
178 | url: body.inReplyTo, | ||
179 | comments: comments.concat([ comment ]), | ||
180 | commentCreated: true | ||
181 | }) | ||
182 | } | ||
183 | |||
184 | async function isRemoteCommentAccepted (comment: MComment) { | ||
185 | // Already created | ||
186 | if (comment.id) return true | ||
187 | |||
188 | const acceptParameters = { | ||
189 | comment | ||
190 | } | ||
191 | |||
192 | const acceptedResult = await Hooks.wrapFun( | ||
193 | isRemoteVideoCommentAccepted, | ||
194 | acceptParameters, | ||
195 | 'filter:activity-pub.remote-video-comment.create.accept.result' | ||
196 | ) | ||
197 | |||
198 | if (!acceptedResult || acceptedResult.accepted !== true) { | ||
199 | logger.info('Refused to create a remote comment.', { acceptedResult, acceptParameters }) | ||
200 | |||
201 | return false | ||
202 | } | ||
203 | |||
204 | return true | ||
205 | } | ||
diff --git a/server/lib/activitypub/video-rates.ts b/server/lib/activitypub/video-rates.ts deleted file mode 100644 index 2e7920f4e..000000000 --- a/server/lib/activitypub/video-rates.ts +++ /dev/null | |||
@@ -1,59 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { VideoRateType } from '../../../shared/models/videos' | ||
3 | import { MAccountActor, MActorUrl, MVideoAccountLight, MVideoFullLight, MVideoId } from '../../types/models' | ||
4 | import { sendLike, sendUndoDislike, sendUndoLike } from './send' | ||
5 | import { sendDislike } from './send/send-dislike' | ||
6 | import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url' | ||
7 | import { federateVideoIfNeeded } from './videos' | ||
8 | |||
9 | async function sendVideoRateChange ( | ||
10 | account: MAccountActor, | ||
11 | video: MVideoFullLight, | ||
12 | likes: number, | ||
13 | dislikes: number, | ||
14 | t: Transaction | ||
15 | ) { | ||
16 | if (video.isOwned()) return federateVideoIfNeeded(video, false, t) | ||
17 | |||
18 | return sendVideoRateChangeToOrigin(account, video, likes, dislikes, t) | ||
19 | } | ||
20 | |||
21 | function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVideoId) { | ||
22 | return rateType === 'like' | ||
23 | ? getVideoLikeActivityPubUrlByLocalActor(actor, video) | ||
24 | : getVideoDislikeActivityPubUrlByLocalActor(actor, video) | ||
25 | } | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | export { | ||
30 | getLocalRateUrl, | ||
31 | sendVideoRateChange | ||
32 | } | ||
33 | |||
34 | // --------------------------------------------------------------------------- | ||
35 | |||
36 | async function sendVideoRateChangeToOrigin ( | ||
37 | account: MAccountActor, | ||
38 | video: MVideoAccountLight, | ||
39 | likes: number, | ||
40 | dislikes: number, | ||
41 | t: Transaction | ||
42 | ) { | ||
43 | // Local video, we don't need to send like | ||
44 | if (video.isOwned()) return | ||
45 | |||
46 | const actor = account.Actor | ||
47 | |||
48 | // Keep the order: first we undo and then we create | ||
49 | |||
50 | // Undo Like | ||
51 | if (likes < 0) await sendUndoLike(actor, video, t) | ||
52 | // Undo Dislike | ||
53 | if (dislikes < 0) await sendUndoDislike(actor, video, t) | ||
54 | |||
55 | // Like | ||
56 | if (likes > 0) await sendLike(actor, video, t) | ||
57 | // Dislike | ||
58 | if (dislikes > 0) await sendDislike(actor, video, t) | ||
59 | } | ||
diff --git a/server/lib/activitypub/videos/federate.ts b/server/lib/activitypub/videos/federate.ts deleted file mode 100644 index d7e251153..000000000 --- a/server/lib/activitypub/videos/federate.ts +++ /dev/null | |||
@@ -1,29 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize/types' | ||
2 | import { MVideoAP, MVideoAPLight } from '@server/types/models' | ||
3 | import { sendCreateVideo, sendUpdateVideo } from '../send' | ||
4 | import { shareVideoByServerAndChannel } from '../share' | ||
5 | |||
6 | async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) { | ||
7 | const video = videoArg as MVideoAP | ||
8 | |||
9 | if ( | ||
10 | // Check this is not a blacklisted video, or unfederated blacklisted video | ||
11 | (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) && | ||
12 | // Check the video is public/unlisted and published | ||
13 | video.hasPrivacyForFederation() && video.hasStateForFederation() | ||
14 | ) { | ||
15 | const video = await videoArg.lightAPToFullAP(transaction) | ||
16 | |||
17 | if (isNewVideo) { | ||
18 | // Now we'll add the video's meta data to our followers | ||
19 | await sendCreateVideo(video, transaction) | ||
20 | await shareVideoByServerAndChannel(video, transaction) | ||
21 | } else { | ||
22 | await sendUpdateVideo(video, transaction) | ||
23 | } | ||
24 | } | ||
25 | } | ||
26 | |||
27 | export { | ||
28 | federateVideoIfNeeded | ||
29 | } | ||
diff --git a/server/lib/activitypub/videos/get.ts b/server/lib/activitypub/videos/get.ts deleted file mode 100644 index 288c506ee..000000000 --- a/server/lib/activitypub/videos/get.ts +++ /dev/null | |||
@@ -1,116 +0,0 @@ | |||
1 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { JobQueue } from '@server/lib/job-queue' | ||
4 | import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' | ||
5 | import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' | ||
6 | import { APObjectId } from '@shared/models' | ||
7 | import { getAPId } from '../activity' | ||
8 | import { refreshVideoIfNeeded } from './refresh' | ||
9 | import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' | ||
10 | |||
11 | type GetVideoResult <T> = Promise<{ | ||
12 | video: T | ||
13 | created: boolean | ||
14 | autoBlacklisted?: boolean | ||
15 | }> | ||
16 | |||
17 | type GetVideoParamAll = { | ||
18 | videoObject: APObjectId | ||
19 | syncParam?: SyncParam | ||
20 | fetchType?: 'all' | ||
21 | allowRefresh?: boolean | ||
22 | } | ||
23 | |||
24 | type GetVideoParamImmutable = { | ||
25 | videoObject: APObjectId | ||
26 | syncParam?: SyncParam | ||
27 | fetchType: 'only-immutable-attributes' | ||
28 | allowRefresh: false | ||
29 | } | ||
30 | |||
31 | type GetVideoParamOther = { | ||
32 | videoObject: APObjectId | ||
33 | syncParam?: SyncParam | ||
34 | fetchType?: 'all' | 'only-video' | ||
35 | allowRefresh?: boolean | ||
36 | } | ||
37 | |||
38 | function getOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles> | ||
39 | function getOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable> | ||
40 | function getOrCreateAPVideo (options: GetVideoParamOther): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail> | ||
41 | |||
42 | async function getOrCreateAPVideo ( | ||
43 | options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther | ||
44 | ): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> { | ||
45 | // Default params | ||
46 | const syncParam = options.syncParam || { rates: true, shares: true, comments: true, refreshVideo: false } | ||
47 | const fetchType = options.fetchType || 'all' | ||
48 | const allowRefresh = options.allowRefresh !== false | ||
49 | |||
50 | // Get video url | ||
51 | const videoUrl = getAPId(options.videoObject) | ||
52 | let videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType) | ||
53 | |||
54 | if (videoFromDatabase) { | ||
55 | if (allowRefresh === true) { | ||
56 | // Typings ensure allowRefresh === false in only-immutable-attributes fetch type | ||
57 | videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam) | ||
58 | } | ||
59 | |||
60 | return { video: videoFromDatabase, created: false } | ||
61 | } | ||
62 | |||
63 | const { videoObject } = await fetchRemoteVideo(videoUrl) | ||
64 | if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl) | ||
65 | |||
66 | // videoUrl is just an alias/rediraction, so process object id instead | ||
67 | if (videoObject.id !== videoUrl) return getOrCreateAPVideo({ ...options, fetchType: 'all', videoObject }) | ||
68 | |||
69 | try { | ||
70 | const creator = new APVideoCreator(videoObject) | ||
71 | const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator)) | ||
72 | |||
73 | await syncVideoExternalAttributes(videoCreated, videoObject, syncParam) | ||
74 | |||
75 | return { video: videoCreated, created: true, autoBlacklisted } | ||
76 | } catch (err) { | ||
77 | // Maybe a concurrent getOrCreateAPVideo call created this video | ||
78 | if (err.name === 'SequelizeUniqueConstraintError') { | ||
79 | const alreadyCreatedVideo = await loadVideoByUrl(videoUrl, fetchType) | ||
80 | if (alreadyCreatedVideo) return { video: alreadyCreatedVideo, created: false } | ||
81 | |||
82 | logger.error('Cannot create video %s because of SequelizeUniqueConstraintError error, but cannot find it in database.', videoUrl) | ||
83 | } | ||
84 | |||
85 | throw err | ||
86 | } | ||
87 | } | ||
88 | |||
89 | // --------------------------------------------------------------------------- | ||
90 | |||
91 | export { | ||
92 | getOrCreateAPVideo | ||
93 | } | ||
94 | |||
95 | // --------------------------------------------------------------------------- | ||
96 | |||
97 | async function scheduleRefresh (video: MVideoThumbnail, fetchType: VideoLoadByUrlType, syncParam: SyncParam) { | ||
98 | if (!video.isOutdated()) return video | ||
99 | |||
100 | const refreshOptions = { | ||
101 | video, | ||
102 | fetchedType: fetchType, | ||
103 | syncParam | ||
104 | } | ||
105 | |||
106 | if (syncParam.refreshVideo === true) { | ||
107 | return refreshVideoIfNeeded(refreshOptions) | ||
108 | } | ||
109 | |||
110 | await JobQueue.Instance.createJob({ | ||
111 | type: 'activitypub-refresher', | ||
112 | payload: { type: 'video', url: video.url } | ||
113 | }) | ||
114 | |||
115 | return video | ||
116 | } | ||
diff --git a/server/lib/activitypub/videos/index.ts b/server/lib/activitypub/videos/index.ts deleted file mode 100644 index b22062598..000000000 --- a/server/lib/activitypub/videos/index.ts +++ /dev/null | |||
@@ -1,4 +0,0 @@ | |||
1 | export * from './federate' | ||
2 | export * from './get' | ||
3 | export * from './refresh' | ||
4 | export * from './updater' | ||
diff --git a/server/lib/activitypub/videos/refresh.ts b/server/lib/activitypub/videos/refresh.ts deleted file mode 100644 index 9f952a218..000000000 --- a/server/lib/activitypub/videos/refresh.ts +++ /dev/null | |||
@@ -1,68 +0,0 @@ | |||
1 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
2 | import { PeerTubeRequestError } from '@server/helpers/requests' | ||
3 | import { VideoLoadByUrlType } from '@server/lib/model-loaders' | ||
4 | import { VideoModel } from '@server/models/video/video' | ||
5 | import { MVideoAccountLightBlacklistAllFiles, MVideoThumbnail } from '@server/types/models' | ||
6 | import { HttpStatusCode } from '@shared/models' | ||
7 | import { ActorFollowHealthCache } from '../../actor-follow-health-cache' | ||
8 | import { fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' | ||
9 | import { APVideoUpdater } from './updater' | ||
10 | |||
11 | async function refreshVideoIfNeeded (options: { | ||
12 | video: MVideoThumbnail | ||
13 | fetchedType: VideoLoadByUrlType | ||
14 | syncParam: SyncParam | ||
15 | }): Promise<MVideoThumbnail> { | ||
16 | if (!options.video.isOutdated()) return options.video | ||
17 | |||
18 | // We need more attributes if the argument video was fetched with not enough joints | ||
19 | const video = options.fetchedType === 'all' | ||
20 | ? options.video as MVideoAccountLightBlacklistAllFiles | ||
21 | : await VideoModel.loadByUrlAndPopulateAccount(options.video.url) | ||
22 | |||
23 | const lTags = loggerTagsFactory('ap', 'video', 'refresh', video.uuid, video.url) | ||
24 | |||
25 | logger.info('Refreshing video %s.', video.url, lTags()) | ||
26 | |||
27 | try { | ||
28 | const { videoObject } = await fetchRemoteVideo(video.url) | ||
29 | |||
30 | if (videoObject === undefined) { | ||
31 | logger.warn('Cannot refresh remote video %s: invalid body.', video.url, lTags()) | ||
32 | |||
33 | await video.setAsRefreshed() | ||
34 | return video | ||
35 | } | ||
36 | |||
37 | const videoUpdater = new APVideoUpdater(videoObject, video) | ||
38 | await videoUpdater.update() | ||
39 | |||
40 | await syncVideoExternalAttributes(video, videoObject, options.syncParam) | ||
41 | |||
42 | ActorFollowHealthCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId) | ||
43 | |||
44 | return video | ||
45 | } catch (err) { | ||
46 | if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) { | ||
47 | logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url, lTags()) | ||
48 | |||
49 | // Video does not exist anymore | ||
50 | await video.destroy() | ||
51 | return undefined | ||
52 | } | ||
53 | |||
54 | logger.warn('Cannot refresh video %s.', options.video.url, { err, ...lTags() }) | ||
55 | |||
56 | ActorFollowHealthCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId) | ||
57 | |||
58 | // Don't refresh in loop | ||
59 | await video.setAsRefreshed() | ||
60 | return video | ||
61 | } | ||
62 | } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | export { | ||
67 | refreshVideoIfNeeded | ||
68 | } | ||
diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts deleted file mode 100644 index 98c2f58eb..000000000 --- a/server/lib/activitypub/videos/shared/abstract-builder.ts +++ /dev/null | |||
@@ -1,190 +0,0 @@ | |||
1 | import { CreationAttributes, Transaction } from 'sequelize/types' | ||
2 | import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils' | ||
3 | import { logger, LoggerTagsFn } from '@server/helpers/logger' | ||
4 | import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail' | ||
5 | import { setVideoTags } from '@server/lib/video' | ||
6 | import { StoryboardModel } from '@server/models/video/storyboard' | ||
7 | import { VideoCaptionModel } from '@server/models/video/video-caption' | ||
8 | import { VideoFileModel } from '@server/models/video/video-file' | ||
9 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
10 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | ||
11 | import { | ||
12 | MStreamingPlaylistFiles, | ||
13 | MStreamingPlaylistFilesVideo, | ||
14 | MVideoCaption, | ||
15 | MVideoFile, | ||
16 | MVideoFullLight, | ||
17 | MVideoThumbnail | ||
18 | } from '@server/types/models' | ||
19 | import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models' | ||
20 | import { findOwner, getOrCreateAPActor } from '../../actors' | ||
21 | import { | ||
22 | getCaptionAttributesFromObject, | ||
23 | getFileAttributesFromUrl, | ||
24 | getLiveAttributesFromObject, | ||
25 | getPreviewFromIcons, | ||
26 | getStoryboardAttributeFromObject, | ||
27 | getStreamingPlaylistAttributesFromObject, | ||
28 | getTagsFromObject, | ||
29 | getThumbnailFromIcons | ||
30 | } from './object-to-model-attributes' | ||
31 | import { getTrackerUrls, setVideoTrackers } from './trackers' | ||
32 | |||
33 | export abstract class APVideoAbstractBuilder { | ||
34 | protected abstract videoObject: VideoObject | ||
35 | protected abstract lTags: LoggerTagsFn | ||
36 | |||
37 | protected async getOrCreateVideoChannelFromVideoObject () { | ||
38 | const channel = await findOwner(this.videoObject.id, this.videoObject.attributedTo, 'Group') | ||
39 | if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.url) | ||
40 | |||
41 | return getOrCreateAPActor(channel.id, 'all') | ||
42 | } | ||
43 | |||
44 | protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) { | ||
45 | const miniatureIcon = getThumbnailFromIcons(this.videoObject) | ||
46 | if (!miniatureIcon) { | ||
47 | logger.warn('Cannot find thumbnail in video object', { object: this.videoObject }) | ||
48 | return undefined | ||
49 | } | ||
50 | |||
51 | const miniatureModel = updateRemoteVideoThumbnail({ | ||
52 | fileUrl: miniatureIcon.url, | ||
53 | video, | ||
54 | type: ThumbnailType.MINIATURE, | ||
55 | size: miniatureIcon, | ||
56 | onDisk: false // Lazy download remote thumbnails | ||
57 | }) | ||
58 | |||
59 | await video.addAndSaveThumbnail(miniatureModel, t) | ||
60 | } | ||
61 | |||
62 | protected async setPreview (video: MVideoFullLight, t?: Transaction) { | ||
63 | const previewIcon = getPreviewFromIcons(this.videoObject) | ||
64 | if (!previewIcon) return | ||
65 | |||
66 | const previewModel = updateRemoteVideoThumbnail({ | ||
67 | fileUrl: previewIcon.url, | ||
68 | video, | ||
69 | type: ThumbnailType.PREVIEW, | ||
70 | size: previewIcon, | ||
71 | onDisk: false // Lazy download remote previews | ||
72 | }) | ||
73 | |||
74 | await video.addAndSaveThumbnail(previewModel, t) | ||
75 | } | ||
76 | |||
77 | protected async setTags (video: MVideoFullLight, t: Transaction) { | ||
78 | const tags = getTagsFromObject(this.videoObject) | ||
79 | await setVideoTags({ video, tags, transaction: t }) | ||
80 | } | ||
81 | |||
82 | protected async setTrackers (video: MVideoFullLight, t: Transaction) { | ||
83 | const trackers = getTrackerUrls(this.videoObject, video) | ||
84 | await setVideoTrackers({ video, trackers, transaction: t }) | ||
85 | } | ||
86 | |||
87 | protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) { | ||
88 | const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t) | ||
89 | |||
90 | let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject) | ||
91 | .map(a => new VideoCaptionModel(a) as MVideoCaption) | ||
92 | |||
93 | for (const existingCaption of existingCaptions) { | ||
94 | // Only keep captions that do not already exist | ||
95 | const filtered = captionsToCreate.filter(c => !c.isEqual(existingCaption)) | ||
96 | |||
97 | // This caption already exists, we don't need to destroy and create it | ||
98 | if (filtered.length !== captionsToCreate.length) { | ||
99 | captionsToCreate = filtered | ||
100 | continue | ||
101 | } | ||
102 | |||
103 | // Destroy this caption that does not exist anymore | ||
104 | await existingCaption.destroy({ transaction: t }) | ||
105 | } | ||
106 | |||
107 | for (const captionToCreate of captionsToCreate) { | ||
108 | await captionToCreate.save({ transaction: t }) | ||
109 | } | ||
110 | } | ||
111 | |||
112 | protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) { | ||
113 | const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t) | ||
114 | if (existingStoryboard) await existingStoryboard.destroy({ transaction: t }) | ||
115 | |||
116 | const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject) | ||
117 | if (!storyboardAttributes) return | ||
118 | |||
119 | return StoryboardModel.create(storyboardAttributes, { transaction: t }) | ||
120 | } | ||
121 | |||
122 | protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) { | ||
123 | const attributes = getLiveAttributesFromObject(video, this.videoObject) | ||
124 | const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true }) | ||
125 | |||
126 | video.VideoLive = videoLive | ||
127 | } | ||
128 | |||
129 | protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) { | ||
130 | const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url) | ||
131 | const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) | ||
132 | |||
133 | // Remove video files that do not exist anymore | ||
134 | await deleteAllModels(filterNonExistingModels(video.VideoFiles || [], newVideoFiles), t) | ||
135 | |||
136 | // Update or add other one | ||
137 | const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t)) | ||
138 | video.VideoFiles = await Promise.all(upsertTasks) | ||
139 | } | ||
140 | |||
141 | protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { | ||
142 | const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject) | ||
143 | const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) | ||
144 | |||
145 | // Remove video playlists that do not exist anymore | ||
146 | await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t) | ||
147 | |||
148 | const oldPlaylists = video.VideoStreamingPlaylists | ||
149 | video.VideoStreamingPlaylists = [] | ||
150 | |||
151 | for (const playlistAttributes of streamingPlaylistAttributes) { | ||
152 | const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t) | ||
153 | streamingPlaylistModel.Video = video | ||
154 | |||
155 | await this.setStreamingPlaylistFiles(oldPlaylists, streamingPlaylistModel, playlistAttributes.tagAPObject, t) | ||
156 | |||
157 | video.VideoStreamingPlaylists.push(streamingPlaylistModel) | ||
158 | } | ||
159 | } | ||
160 | |||
161 | private async insertOrReplaceStreamingPlaylist (attributes: CreationAttributes<VideoStreamingPlaylistModel>, t: Transaction) { | ||
162 | const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t }) | ||
163 | |||
164 | return streamingPlaylist as MStreamingPlaylistFilesVideo | ||
165 | } | ||
166 | |||
167 | private getStreamingPlaylistFiles (oldPlaylists: MStreamingPlaylistFiles[], type: VideoStreamingPlaylistType) { | ||
168 | const playlist = oldPlaylists.find(s => s.type === type) | ||
169 | if (!playlist) return [] | ||
170 | |||
171 | return playlist.VideoFiles | ||
172 | } | ||
173 | |||
174 | private async setStreamingPlaylistFiles ( | ||
175 | oldPlaylists: MStreamingPlaylistFiles[], | ||
176 | playlistModel: MStreamingPlaylistFilesVideo, | ||
177 | tagObjects: ActivityTagObject[], | ||
178 | t: Transaction | ||
179 | ) { | ||
180 | const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type) | ||
181 | |||
182 | const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a)) | ||
183 | |||
184 | await deleteAllModels(filterNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles), t) | ||
185 | |||
186 | // Update or add other one | ||
187 | const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t)) | ||
188 | playlistModel.VideoFiles = await Promise.all(upsertTasks) | ||
189 | } | ||
190 | } | ||
diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts deleted file mode 100644 index e44fd0d52..000000000 --- a/server/lib/activitypub/videos/shared/creator.ts +++ /dev/null | |||
@@ -1,65 +0,0 @@ | |||
1 | |||
2 | import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger' | ||
3 | import { sequelizeTypescript } from '@server/initializers/database' | ||
4 | import { Hooks } from '@server/lib/plugins/hooks' | ||
5 | import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' | ||
6 | import { VideoModel } from '@server/models/video/video' | ||
7 | import { MVideoFullLight, MVideoThumbnail } from '@server/types/models' | ||
8 | import { VideoObject } from '@shared/models' | ||
9 | import { APVideoAbstractBuilder } from './abstract-builder' | ||
10 | import { getVideoAttributesFromObject } from './object-to-model-attributes' | ||
11 | |||
12 | export class APVideoCreator extends APVideoAbstractBuilder { | ||
13 | protected lTags: LoggerTagsFn | ||
14 | |||
15 | constructor (protected readonly videoObject: VideoObject) { | ||
16 | super() | ||
17 | |||
18 | this.lTags = loggerTagsFactory('ap', 'video', 'create', this.videoObject.uuid, this.videoObject.id) | ||
19 | } | ||
20 | |||
21 | async create () { | ||
22 | logger.debug('Adding remote video %s.', this.videoObject.id, this.lTags()) | ||
23 | |||
24 | const channelActor = await this.getOrCreateVideoChannelFromVideoObject() | ||
25 | const channel = channelActor.VideoChannel | ||
26 | |||
27 | const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to) | ||
28 | const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail | ||
29 | |||
30 | const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => { | ||
31 | const videoCreated = await video.save({ transaction: t }) as MVideoFullLight | ||
32 | videoCreated.VideoChannel = channel | ||
33 | |||
34 | await this.setThumbnail(videoCreated, t) | ||
35 | await this.setPreview(videoCreated, t) | ||
36 | await this.setWebVideoFiles(videoCreated, t) | ||
37 | await this.setStreamingPlaylists(videoCreated, t) | ||
38 | await this.setTags(videoCreated, t) | ||
39 | await this.setTrackers(videoCreated, t) | ||
40 | await this.insertOrReplaceCaptions(videoCreated, t) | ||
41 | await this.insertOrReplaceLive(videoCreated, t) | ||
42 | await this.insertOrReplaceStoryboard(videoCreated, t) | ||
43 | |||
44 | // We added a video in this channel, set it as updated | ||
45 | await channel.setAsUpdated(t) | ||
46 | |||
47 | const autoBlacklisted = await autoBlacklistVideoIfNeeded({ | ||
48 | video: videoCreated, | ||
49 | user: undefined, | ||
50 | isRemote: true, | ||
51 | isNew: true, | ||
52 | isNewFile: true, | ||
53 | transaction: t | ||
54 | }) | ||
55 | |||
56 | logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags()) | ||
57 | |||
58 | Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject }) | ||
59 | |||
60 | return { autoBlacklisted, videoCreated } | ||
61 | }) | ||
62 | |||
63 | return { autoBlacklisted, videoCreated } | ||
64 | } | ||
65 | } | ||
diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts deleted file mode 100644 index 951403493..000000000 --- a/server/lib/activitypub/videos/shared/index.ts +++ /dev/null | |||
@@ -1,6 +0,0 @@ | |||
1 | export * from './abstract-builder' | ||
2 | export * from './creator' | ||
3 | export * from './object-to-model-attributes' | ||
4 | export * from './trackers' | ||
5 | export * from './url-to-object' | ||
6 | export * from './video-sync-attributes' | ||
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts deleted file mode 100644 index 6cbe72e27..000000000 --- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ /dev/null | |||
@@ -1,285 +0,0 @@ | |||
1 | import { maxBy, minBy } from 'lodash' | ||
2 | import { decode as magnetUriDecode } from 'magnet-uri' | ||
3 | import { basename, extname } from 'path' | ||
4 | import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos' | ||
5 | import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos' | ||
6 | import { logger } from '@server/helpers/logger' | ||
7 | import { getExtFromMimetype } from '@server/helpers/video' | ||
8 | import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants' | ||
9 | import { generateTorrentFileName } from '@server/lib/paths' | ||
10 | import { VideoCaptionModel } from '@server/models/video/video-caption' | ||
11 | import { VideoFileModel } from '@server/models/video/video-file' | ||
12 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | ||
13 | import { FilteredModelAttributes } from '@server/types' | ||
14 | import { isStreamingPlaylist, MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId } from '@server/types/models' | ||
15 | import { | ||
16 | ActivityHashTagObject, | ||
17 | ActivityMagnetUrlObject, | ||
18 | ActivityPlaylistSegmentHashesObject, | ||
19 | ActivityPlaylistUrlObject, | ||
20 | ActivityTagObject, | ||
21 | ActivityUrlObject, | ||
22 | ActivityVideoUrlObject, | ||
23 | VideoObject, | ||
24 | VideoPrivacy, | ||
25 | VideoStreamingPlaylistType | ||
26 | } from '@shared/models' | ||
27 | import { getDurationFromActivityStream } from '../../activity' | ||
28 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
29 | import { generateImageFilename } from '@server/helpers/image-utils' | ||
30 | import { arrayify } from '@shared/core-utils' | ||
31 | |||
32 | function getThumbnailFromIcons (videoObject: VideoObject) { | ||
33 | let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) | ||
34 | // Fallback if there are not valid icons | ||
35 | if (validIcons.length === 0) validIcons = videoObject.icon | ||
36 | |||
37 | return minBy(validIcons, 'width') | ||
38 | } | ||
39 | |||
40 | function getPreviewFromIcons (videoObject: VideoObject) { | ||
41 | const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth) | ||
42 | |||
43 | return maxBy(validIcons, 'width') | ||
44 | } | ||
45 | |||
46 | function getTagsFromObject (videoObject: VideoObject) { | ||
47 | return videoObject.tag | ||
48 | .filter(isAPHashTagObject) | ||
49 | .map(t => t.name) | ||
50 | } | ||
51 | |||
52 | function getFileAttributesFromUrl ( | ||
53 | videoOrPlaylist: MVideo | MStreamingPlaylistVideo, | ||
54 | urls: (ActivityTagObject | ActivityUrlObject)[] | ||
55 | ) { | ||
56 | const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[] | ||
57 | |||
58 | if (fileUrls.length === 0) return [] | ||
59 | |||
60 | const attributes: FilteredModelAttributes<VideoFileModel>[] = [] | ||
61 | for (const fileUrl of fileUrls) { | ||
62 | // Fetch associated magnet uri | ||
63 | const magnet = urls.filter(isAPMagnetUrlObject) | ||
64 | .find(u => u.height === fileUrl.height) | ||
65 | |||
66 | if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href) | ||
67 | |||
68 | const parsed = magnetUriDecode(magnet.href) | ||
69 | if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) { | ||
70 | throw new Error('Cannot parse magnet URI ' + magnet.href) | ||
71 | } | ||
72 | |||
73 | const torrentUrl = Array.isArray(parsed.xs) | ||
74 | ? parsed.xs[0] | ||
75 | : parsed.xs | ||
76 | |||
77 | // Fetch associated metadata url, if any | ||
78 | const metadata = urls.filter(isAPVideoFileUrlMetadataObject) | ||
79 | .find(u => { | ||
80 | return u.height === fileUrl.height && | ||
81 | u.fps === fileUrl.fps && | ||
82 | u.rel.includes(fileUrl.mediaType) | ||
83 | }) | ||
84 | |||
85 | const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType) | ||
86 | const resolution = fileUrl.height | ||
87 | const videoId = isStreamingPlaylist(videoOrPlaylist) ? null : videoOrPlaylist.id | ||
88 | const videoStreamingPlaylistId = isStreamingPlaylist(videoOrPlaylist) ? videoOrPlaylist.id : null | ||
89 | |||
90 | const attribute = { | ||
91 | extname, | ||
92 | infoHash: parsed.infoHash, | ||
93 | resolution, | ||
94 | size: fileUrl.size, | ||
95 | fps: fileUrl.fps || -1, | ||
96 | metadataUrl: metadata?.href, | ||
97 | |||
98 | // Use the name of the remote file because we don't proxify video file requests | ||
99 | filename: basename(fileUrl.href), | ||
100 | fileUrl: fileUrl.href, | ||
101 | |||
102 | torrentUrl, | ||
103 | // Use our own torrent name since we proxify torrent requests | ||
104 | torrentFilename: generateTorrentFileName(videoOrPlaylist, resolution), | ||
105 | |||
106 | // This is a video file owned by a video or by a streaming playlist | ||
107 | videoId, | ||
108 | videoStreamingPlaylistId | ||
109 | } | ||
110 | |||
111 | attributes.push(attribute) | ||
112 | } | ||
113 | |||
114 | return attributes | ||
115 | } | ||
116 | |||
117 | function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) { | ||
118 | const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[] | ||
119 | if (playlistUrls.length === 0) return [] | ||
120 | |||
121 | const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = [] | ||
122 | for (const playlistUrlObject of playlistUrls) { | ||
123 | const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject) | ||
124 | |||
125 | const files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[] | ||
126 | |||
127 | if (!segmentsSha256UrlObject) { | ||
128 | logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject }) | ||
129 | continue | ||
130 | } | ||
131 | |||
132 | const attribute = { | ||
133 | type: VideoStreamingPlaylistType.HLS, | ||
134 | |||
135 | playlistFilename: basename(playlistUrlObject.href), | ||
136 | playlistUrl: playlistUrlObject.href, | ||
137 | |||
138 | segmentsSha256Filename: basename(segmentsSha256UrlObject.href), | ||
139 | segmentsSha256Url: segmentsSha256UrlObject.href, | ||
140 | |||
141 | p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files), | ||
142 | p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, | ||
143 | videoId: video.id, | ||
144 | |||
145 | tagAPObject: playlistUrlObject.tag | ||
146 | } | ||
147 | |||
148 | attributes.push(attribute) | ||
149 | } | ||
150 | |||
151 | return attributes | ||
152 | } | ||
153 | |||
154 | function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) { | ||
155 | return { | ||
156 | saveReplay: videoObject.liveSaveReplay, | ||
157 | permanentLive: videoObject.permanentLive, | ||
158 | latencyMode: videoObject.latencyMode, | ||
159 | videoId: video.id | ||
160 | } | ||
161 | } | ||
162 | |||
163 | function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) { | ||
164 | return videoObject.subtitleLanguage.map(c => ({ | ||
165 | videoId: video.id, | ||
166 | filename: VideoCaptionModel.generateCaptionName(c.identifier), | ||
167 | language: c.identifier, | ||
168 | fileUrl: c.url | ||
169 | })) | ||
170 | } | ||
171 | |||
172 | function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) { | ||
173 | if (!isArray(videoObject.preview)) return undefined | ||
174 | |||
175 | const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard')) | ||
176 | if (!storyboard) return undefined | ||
177 | |||
178 | const url = arrayify(storyboard.url).find(u => u.mediaType === 'image/jpeg') | ||
179 | |||
180 | return { | ||
181 | filename: generateImageFilename(extname(url.href)), | ||
182 | totalHeight: url.height, | ||
183 | totalWidth: url.width, | ||
184 | spriteHeight: url.tileHeight, | ||
185 | spriteWidth: url.tileWidth, | ||
186 | spriteDuration: getDurationFromActivityStream(url.tileDuration), | ||
187 | fileUrl: url.href, | ||
188 | videoId: video.id | ||
189 | } | ||
190 | } | ||
191 | |||
192 | function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) { | ||
193 | const privacy = to.includes(ACTIVITY_PUB.PUBLIC) | ||
194 | ? VideoPrivacy.PUBLIC | ||
195 | : VideoPrivacy.UNLISTED | ||
196 | |||
197 | const language = videoObject.language?.identifier | ||
198 | |||
199 | const category = videoObject.category | ||
200 | ? parseInt(videoObject.category.identifier, 10) | ||
201 | : undefined | ||
202 | |||
203 | const licence = videoObject.licence | ||
204 | ? parseInt(videoObject.licence.identifier, 10) | ||
205 | : undefined | ||
206 | |||
207 | const description = videoObject.content || null | ||
208 | const support = videoObject.support || null | ||
209 | |||
210 | return { | ||
211 | name: videoObject.name, | ||
212 | uuid: videoObject.uuid, | ||
213 | url: videoObject.id, | ||
214 | category, | ||
215 | licence, | ||
216 | language, | ||
217 | description, | ||
218 | support, | ||
219 | nsfw: videoObject.sensitive, | ||
220 | commentsEnabled: videoObject.commentsEnabled, | ||
221 | downloadEnabled: videoObject.downloadEnabled, | ||
222 | waitTranscoding: videoObject.waitTranscoding, | ||
223 | isLive: videoObject.isLiveBroadcast, | ||
224 | state: videoObject.state, | ||
225 | channelId: videoChannel.id, | ||
226 | duration: getDurationFromActivityStream(videoObject.duration), | ||
227 | createdAt: new Date(videoObject.published), | ||
228 | publishedAt: new Date(videoObject.published), | ||
229 | |||
230 | originallyPublishedAt: videoObject.originallyPublishedAt | ||
231 | ? new Date(videoObject.originallyPublishedAt) | ||
232 | : null, | ||
233 | |||
234 | inputFileUpdatedAt: videoObject.uploadDate | ||
235 | ? new Date(videoObject.uploadDate) | ||
236 | : null, | ||
237 | |||
238 | updatedAt: new Date(videoObject.updated), | ||
239 | views: videoObject.views, | ||
240 | remote: true, | ||
241 | privacy | ||
242 | } | ||
243 | } | ||
244 | |||
245 | // --------------------------------------------------------------------------- | ||
246 | |||
247 | export { | ||
248 | getThumbnailFromIcons, | ||
249 | getPreviewFromIcons, | ||
250 | |||
251 | getTagsFromObject, | ||
252 | |||
253 | getFileAttributesFromUrl, | ||
254 | getStreamingPlaylistAttributesFromObject, | ||
255 | |||
256 | getLiveAttributesFromObject, | ||
257 | getCaptionAttributesFromObject, | ||
258 | getStoryboardAttributeFromObject, | ||
259 | |||
260 | getVideoAttributesFromObject | ||
261 | } | ||
262 | |||
263 | // --------------------------------------------------------------------------- | ||
264 | |||
265 | function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject { | ||
266 | const urlMediaType = url.mediaType | ||
267 | |||
268 | return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/') | ||
269 | } | ||
270 | |||
271 | function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject { | ||
272 | return url && url.mediaType === 'application/x-mpegURL' | ||
273 | } | ||
274 | |||
275 | function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject { | ||
276 | return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json' | ||
277 | } | ||
278 | |||
279 | function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject { | ||
280 | return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' | ||
281 | } | ||
282 | |||
283 | function isAPHashTagObject (url: any): url is ActivityHashTagObject { | ||
284 | return url && url.type === 'Hashtag' | ||
285 | } | ||
diff --git a/server/lib/activitypub/videos/shared/trackers.ts b/server/lib/activitypub/videos/shared/trackers.ts deleted file mode 100644 index 2418f45c2..000000000 --- a/server/lib/activitypub/videos/shared/trackers.ts +++ /dev/null | |||
@@ -1,43 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize/types' | ||
2 | import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos' | ||
3 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
4 | import { REMOTE_SCHEME } from '@server/initializers/constants' | ||
5 | import { TrackerModel } from '@server/models/server/tracker' | ||
6 | import { MVideo, MVideoWithHost } from '@server/types/models' | ||
7 | import { ActivityTrackerUrlObject, VideoObject } from '@shared/models' | ||
8 | import { buildRemoteVideoBaseUrl } from '../../url' | ||
9 | |||
10 | function getTrackerUrls (object: VideoObject, video: MVideoWithHost) { | ||
11 | let wsFound = false | ||
12 | |||
13 | const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u)) | ||
14 | .map((u: ActivityTrackerUrlObject) => { | ||
15 | if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true | ||
16 | |||
17 | return u.href | ||
18 | }) | ||
19 | |||
20 | if (wsFound) return trackers | ||
21 | |||
22 | return [ | ||
23 | buildRemoteVideoBaseUrl(video, '/tracker/socket', REMOTE_SCHEME.WS), | ||
24 | buildRemoteVideoBaseUrl(video, '/tracker/announce') | ||
25 | ] | ||
26 | } | ||
27 | |||
28 | async function setVideoTrackers (options: { | ||
29 | video: MVideo | ||
30 | trackers: string[] | ||
31 | transaction: Transaction | ||
32 | }) { | ||
33 | const { video, trackers, transaction } = options | ||
34 | |||
35 | const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction) | ||
36 | |||
37 | await video.$set('Trackers', trackerInstances, { transaction }) | ||
38 | } | ||
39 | |||
40 | export { | ||
41 | getTrackerUrls, | ||
42 | setVideoTrackers | ||
43 | } | ||
diff --git a/server/lib/activitypub/videos/shared/url-to-object.ts b/server/lib/activitypub/videos/shared/url-to-object.ts deleted file mode 100644 index 7fe008419..000000000 --- a/server/lib/activitypub/videos/shared/url-to-object.ts +++ /dev/null | |||
@@ -1,25 +0,0 @@ | |||
1 | import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { VideoObject } from '@shared/models' | ||
4 | import { fetchAP } from '../../activity' | ||
5 | import { checkUrlsSameHost } from '../../url' | ||
6 | |||
7 | const lTags = loggerTagsFactory('ap', 'video') | ||
8 | |||
9 | async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> { | ||
10 | logger.info('Fetching remote video %s.', videoUrl, lTags(videoUrl)) | ||
11 | |||
12 | const { statusCode, body } = await fetchAP<any>(videoUrl) | ||
13 | |||
14 | if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) { | ||
15 | logger.debug('Remote video JSON is not valid.', { body, ...lTags(videoUrl) }) | ||
16 | |||
17 | return { statusCode, videoObject: undefined } | ||
18 | } | ||
19 | |||
20 | return { statusCode, videoObject: body } | ||
21 | } | ||
22 | |||
23 | export { | ||
24 | fetchRemoteVideo | ||
25 | } | ||
diff --git a/server/lib/activitypub/videos/shared/video-sync-attributes.ts b/server/lib/activitypub/videos/shared/video-sync-attributes.ts deleted file mode 100644 index 7fb933559..000000000 --- a/server/lib/activitypub/videos/shared/video-sync-attributes.ts +++ /dev/null | |||
@@ -1,107 +0,0 @@ | |||
1 | import { runInReadCommittedTransaction } from '@server/helpers/database-utils' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { JobQueue } from '@server/lib/job-queue' | ||
4 | import { VideoModel } from '@server/models/video/video' | ||
5 | import { VideoCommentModel } from '@server/models/video/video-comment' | ||
6 | import { VideoShareModel } from '@server/models/video/video-share' | ||
7 | import { MVideo } from '@server/types/models' | ||
8 | import { ActivitypubHttpFetcherPayload, ActivityPubOrderedCollection, VideoObject } from '@shared/models' | ||
9 | import { fetchAP } from '../../activity' | ||
10 | import { crawlCollectionPage } from '../../crawl' | ||
11 | import { addVideoShares } from '../../share' | ||
12 | import { addVideoComments } from '../../video-comments' | ||
13 | |||
14 | const lTags = loggerTagsFactory('ap', 'video') | ||
15 | |||
16 | type SyncParam = { | ||
17 | rates: boolean | ||
18 | shares: boolean | ||
19 | comments: boolean | ||
20 | refreshVideo?: boolean | ||
21 | } | ||
22 | |||
23 | async function syncVideoExternalAttributes ( | ||
24 | video: MVideo, | ||
25 | fetchedVideo: VideoObject, | ||
26 | syncParam: Pick<SyncParam, 'rates' | 'shares' | 'comments'> | ||
27 | ) { | ||
28 | logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid) | ||
29 | |||
30 | const ratePromise = updateVideoRates(video, fetchedVideo) | ||
31 | if (syncParam.rates) await ratePromise | ||
32 | |||
33 | await syncShares(video, fetchedVideo, syncParam.shares) | ||
34 | |||
35 | await syncComments(video, fetchedVideo, syncParam.comments) | ||
36 | } | ||
37 | |||
38 | async function updateVideoRates (video: MVideo, fetchedVideo: VideoObject) { | ||
39 | const [ likes, dislikes ] = await Promise.all([ | ||
40 | getRatesCount('like', video, fetchedVideo), | ||
41 | getRatesCount('dislike', video, fetchedVideo) | ||
42 | ]) | ||
43 | |||
44 | return runInReadCommittedTransaction(async t => { | ||
45 | await VideoModel.updateRatesOf(video.id, 'like', likes, t) | ||
46 | await VideoModel.updateRatesOf(video.id, 'dislike', dislikes, t) | ||
47 | }) | ||
48 | } | ||
49 | |||
50 | // --------------------------------------------------------------------------- | ||
51 | |||
52 | export { | ||
53 | SyncParam, | ||
54 | syncVideoExternalAttributes, | ||
55 | updateVideoRates | ||
56 | } | ||
57 | |||
58 | // --------------------------------------------------------------------------- | ||
59 | |||
60 | async function getRatesCount (type: 'like' | 'dislike', video: MVideo, fetchedVideo: VideoObject) { | ||
61 | const uri = type === 'like' | ||
62 | ? fetchedVideo.likes | ||
63 | : fetchedVideo.dislikes | ||
64 | |||
65 | logger.info('Sync %s of video %s', type, video.url) | ||
66 | |||
67 | const { body } = await fetchAP<ActivityPubOrderedCollection<any>>(uri) | ||
68 | |||
69 | if (isNaN(body.totalItems)) { | ||
70 | logger.error('Cannot sync %s of video %s, totalItems is not a number', type, video.url, { body }) | ||
71 | return | ||
72 | } | ||
73 | |||
74 | return body.totalItems | ||
75 | } | ||
76 | |||
77 | function syncShares (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) { | ||
78 | const uri = fetchedVideo.shares | ||
79 | |||
80 | if (!isSync) { | ||
81 | return createJob({ uri, videoId: video.id, type: 'video-shares' }) | ||
82 | } | ||
83 | |||
84 | const handler = items => addVideoShares(items, video) | ||
85 | const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate) | ||
86 | |||
87 | return crawlCollectionPage<string>(uri, handler, cleaner) | ||
88 | .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) })) | ||
89 | } | ||
90 | |||
91 | function syncComments (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) { | ||
92 | const uri = fetchedVideo.comments | ||
93 | |||
94 | if (!isSync) { | ||
95 | return createJob({ uri, videoId: video.id, type: 'video-comments' }) | ||
96 | } | ||
97 | |||
98 | const handler = items => addVideoComments(items) | ||
99 | const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate) | ||
100 | |||
101 | return crawlCollectionPage<string>(uri, handler, cleaner) | ||
102 | .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) })) | ||
103 | } | ||
104 | |||
105 | function createJob (payload: ActivitypubHttpFetcherPayload) { | ||
106 | return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) | ||
107 | } | ||
diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts deleted file mode 100644 index acb087895..000000000 --- a/server/lib/activitypub/videos/updater.ts +++ /dev/null | |||
@@ -1,180 +0,0 @@ | |||
1 | import { Transaction } from 'sequelize/types' | ||
2 | import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils' | ||
3 | import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger' | ||
4 | import { Notifier } from '@server/lib/notifier' | ||
5 | import { PeerTubeSocket } from '@server/lib/peertube-socket' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' | ||
8 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
9 | import { MActor, MChannelAccountLight, MChannelId, MVideoAccountLightBlacklistAllFiles, MVideoFullLight } from '@server/types/models' | ||
10 | import { VideoObject, VideoPrivacy } from '@shared/models' | ||
11 | import { APVideoAbstractBuilder, getVideoAttributesFromObject, updateVideoRates } from './shared' | ||
12 | |||
13 | export class APVideoUpdater extends APVideoAbstractBuilder { | ||
14 | private readonly wasPrivateVideo: boolean | ||
15 | private readonly wasUnlistedVideo: boolean | ||
16 | |||
17 | private readonly oldVideoChannel: MChannelAccountLight | ||
18 | |||
19 | protected lTags: LoggerTagsFn | ||
20 | |||
21 | constructor ( | ||
22 | protected readonly videoObject: VideoObject, | ||
23 | private readonly video: MVideoAccountLightBlacklistAllFiles | ||
24 | ) { | ||
25 | super() | ||
26 | |||
27 | this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE | ||
28 | this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED | ||
29 | |||
30 | this.oldVideoChannel = this.video.VideoChannel | ||
31 | |||
32 | this.lTags = loggerTagsFactory('ap', 'video', 'update', video.uuid, video.url) | ||
33 | } | ||
34 | |||
35 | async update (overrideTo?: string[]) { | ||
36 | logger.debug( | ||
37 | 'Updating remote video "%s".', this.videoObject.uuid, | ||
38 | { videoObject: this.videoObject, ...this.lTags() } | ||
39 | ) | ||
40 | |||
41 | const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt | ||
42 | |||
43 | try { | ||
44 | const channelActor = await this.getOrCreateVideoChannelFromVideoObject() | ||
45 | |||
46 | const thumbnailModel = await this.setThumbnail(this.video) | ||
47 | |||
48 | this.checkChannelUpdateOrThrow(channelActor) | ||
49 | |||
50 | const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo) | ||
51 | |||
52 | if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel) | ||
53 | |||
54 | await runInReadCommittedTransaction(async t => { | ||
55 | await this.setWebVideoFiles(videoUpdated, t) | ||
56 | await this.setStreamingPlaylists(videoUpdated, t) | ||
57 | }) | ||
58 | |||
59 | await Promise.all([ | ||
60 | runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)), | ||
61 | runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)), | ||
62 | runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)), | ||
63 | runInReadCommittedTransaction(t => { | ||
64 | return Promise.all([ | ||
65 | this.setPreview(videoUpdated, t), | ||
66 | this.setThumbnail(videoUpdated, t) | ||
67 | ]) | ||
68 | }), | ||
69 | this.setOrDeleteLive(videoUpdated) | ||
70 | ]) | ||
71 | |||
72 | await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) | ||
73 | |||
74 | await autoBlacklistVideoIfNeeded({ | ||
75 | video: videoUpdated, | ||
76 | user: undefined, | ||
77 | isRemote: true, | ||
78 | isNew: false, | ||
79 | isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt, | ||
80 | transaction: undefined | ||
81 | }) | ||
82 | |||
83 | await updateVideoRates(videoUpdated, this.videoObject) | ||
84 | |||
85 | // Notify our users? | ||
86 | if (this.wasPrivateVideo || this.wasUnlistedVideo) { | ||
87 | Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) | ||
88 | } | ||
89 | |||
90 | if (videoUpdated.isLive) { | ||
91 | PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated) | ||
92 | } | ||
93 | |||
94 | Hooks.runAction('action:activity-pub.remote-video.updated', { video: videoUpdated, videoAPObject: this.videoObject }) | ||
95 | |||
96 | logger.info('Remote video with uuid %s updated', this.videoObject.uuid, this.lTags()) | ||
97 | |||
98 | return videoUpdated | ||
99 | } catch (err) { | ||
100 | await this.catchUpdateError(err) | ||
101 | } | ||
102 | } | ||
103 | |||
104 | // Check we can update the channel: we trust the remote server | ||
105 | private checkChannelUpdateOrThrow (newChannelActor: MActor) { | ||
106 | if (!this.oldVideoChannel.Actor.serverId || !newChannelActor.serverId) { | ||
107 | throw new Error('Cannot check old channel/new channel validity because `serverId` is null') | ||
108 | } | ||
109 | |||
110 | if (this.oldVideoChannel.Actor.serverId !== newChannelActor.serverId) { | ||
111 | throw new Error(`New channel ${newChannelActor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`) | ||
112 | } | ||
113 | } | ||
114 | |||
115 | private updateVideo (channel: MChannelId, transaction?: Transaction, overrideTo?: string[]) { | ||
116 | const to = overrideTo || this.videoObject.to | ||
117 | const videoData = getVideoAttributesFromObject(channel, this.videoObject, to) | ||
118 | this.video.name = videoData.name | ||
119 | this.video.uuid = videoData.uuid | ||
120 | this.video.url = videoData.url | ||
121 | this.video.category = videoData.category | ||
122 | this.video.licence = videoData.licence | ||
123 | this.video.language = videoData.language | ||
124 | this.video.description = videoData.description | ||
125 | this.video.support = videoData.support | ||
126 | this.video.nsfw = videoData.nsfw | ||
127 | this.video.commentsEnabled = videoData.commentsEnabled | ||
128 | this.video.downloadEnabled = videoData.downloadEnabled | ||
129 | this.video.waitTranscoding = videoData.waitTranscoding | ||
130 | this.video.state = videoData.state | ||
131 | this.video.duration = videoData.duration | ||
132 | this.video.createdAt = videoData.createdAt | ||
133 | this.video.publishedAt = videoData.publishedAt | ||
134 | this.video.originallyPublishedAt = videoData.originallyPublishedAt | ||
135 | this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt | ||
136 | this.video.privacy = videoData.privacy | ||
137 | this.video.channelId = videoData.channelId | ||
138 | this.video.views = videoData.views | ||
139 | this.video.isLive = videoData.isLive | ||
140 | |||
141 | // Ensures we update the updatedAt attribute, even if main attributes did not change | ||
142 | this.video.changed('updatedAt', true) | ||
143 | |||
144 | return this.video.save({ transaction }) as Promise<MVideoFullLight> | ||
145 | } | ||
146 | |||
147 | private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) { | ||
148 | await this.insertOrReplaceCaptions(videoUpdated, t) | ||
149 | } | ||
150 | |||
151 | private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) { | ||
152 | await this.insertOrReplaceStoryboard(videoUpdated, t) | ||
153 | } | ||
154 | |||
155 | private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) { | ||
156 | if (!this.video.isLive) return | ||
157 | |||
158 | if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction) | ||
159 | |||
160 | // Delete existing live if it exists | ||
161 | await VideoLiveModel.destroy({ | ||
162 | where: { | ||
163 | videoId: this.video.id | ||
164 | }, | ||
165 | transaction | ||
166 | }) | ||
167 | |||
168 | videoUpdated.VideoLive = null | ||
169 | } | ||
170 | |||
171 | private async catchUpdateError (err: Error) { | ||
172 | if (this.video !== undefined) { | ||
173 | await resetSequelizeInstance(this.video) | ||
174 | } | ||
175 | |||
176 | // This is just a debug because we will retry the insert | ||
177 | logger.debug('Cannot update the remote video.', { err, ...this.lTags() }) | ||
178 | throw err | ||
179 | } | ||
180 | } | ||