]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame_incremental - server/lib/activitypub/actor.ts
Fetch outbox when searching an actor
[github/Chocobozzz/PeerTube.git] / server / lib / activitypub / actor.ts
... / ...
CommitLineData
1import * as Bluebird from 'bluebird'
2import { join } from 'path'
3import { Transaction } from 'sequelize'
4import * as url from 'url'
5import * as uuidv4 from 'uuid/v4'
6import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub'
7import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
8import { getActorUrl } from '../../helpers/activitypub'
9import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor'
10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
11import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
12import { logger } from '../../helpers/logger'
13import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
14import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests'
15import { getUrlFromWebfinger } from '../../helpers/webfinger'
16import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../initializers'
17import { AccountModel } from '../../models/account/account'
18import { ActorModel } from '../../models/activitypub/actor'
19import { AvatarModel } from '../../models/avatar/avatar'
20import { ServerModel } from '../../models/server/server'
21import { VideoChannelModel } from '../../models/video/video-channel'
22import { JobQueue } from '../job-queue'
23import { getServerActor } from '../../helpers/utils'
24
25// Set account keys, this could be long so process after the account creation and do not block the client
26function setAsyncActorKeys (actor: ActorModel) {
27 return createPrivateAndPublicKeys()
28 .then(({ publicKey, privateKey }) => {
29 actor.set('publicKey', publicKey)
30 actor.set('privateKey', privateKey)
31 return actor.save()
32 })
33 .catch(err => {
34 logger.error('Cannot set public/private keys of actor %d.', actor.uuid, { err })
35 return actor
36 })
37}
38
39async function getOrCreateActorAndServerAndModel (
40 activityActor: string | ActivityPubActor,
41 recurseIfNeeded = true,
42 updateCollections = false
43) {
44 const actorUrl = getActorUrl(activityActor)
45 let created = false
46
47 let actor = await ActorModel.loadByUrl(actorUrl)
48 // Orphan actor (not associated to an account of channel) so recreate it
49 if (actor && (!actor.Account && !actor.VideoChannel)) {
50 await actor.destroy()
51 actor = null
52 }
53
54 // We don't have this actor in our database, fetch it on remote
55 if (!actor) {
56 const { result } = await fetchRemoteActor(actorUrl)
57 if (result === undefined) throw new Error('Cannot fetch remote actor.')
58
59 // Create the attributed to actor
60 // In PeerTube a video channel is owned by an account
61 let ownerActor: ActorModel = undefined
62 if (recurseIfNeeded === true && result.actor.type === 'Group') {
63 const accountAttributedTo = result.attributedTo.find(a => a.type === 'Person')
64 if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actor.url)
65
66 try {
67 // Assert we don't recurse another time
68 ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, false)
69 } catch (err) {
70 logger.error('Cannot get or create account attributed to video channel ' + actor.url)
71 throw new Error(err)
72 }
73 }
74
75 actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor)
76 created = true
77 }
78
79 if (actor.Account) actor.Account.Actor = actor
80 if (actor.VideoChannel) actor.VideoChannel.Actor = actor
81
82 const { actor: actorRefreshed, refreshed } = await retryTransactionWrapper(refreshActorIfNeeded, actor)
83 if (!actorRefreshed) throw new Error('Actor ' + actorRefreshed.url + ' does not exist anymore.')
84
85 if ((created === true || refreshed === true) && updateCollections === true) {
86 const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
87 await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
88 }
89
90 return actorRefreshed
91}
92
93function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) {
94 return new ActorModel({
95 type,
96 url,
97 preferredUsername,
98 uuid,
99 publicKey: null,
100 privateKey: null,
101 followersCount: 0,
102 followingCount: 0,
103 inboxUrl: url + '/inbox',
104 outboxUrl: url + '/outbox',
105 sharedInboxUrl: CONFIG.WEBSERVER.URL + '/inbox',
106 followersUrl: url + '/followers',
107 followingUrl: url + '/following'
108 })
109}
110
111async function updateActorInstance (actorInstance: ActorModel, attributes: ActivityPubActor) {
112 const followersCount = await fetchActorTotalItems(attributes.followers)
113 const followingCount = await fetchActorTotalItems(attributes.following)
114
115 actorInstance.set('type', attributes.type)
116 actorInstance.set('uuid', attributes.uuid)
117 actorInstance.set('preferredUsername', attributes.preferredUsername)
118 actorInstance.set('url', attributes.id)
119 actorInstance.set('publicKey', attributes.publicKey.publicKeyPem)
120 actorInstance.set('followersCount', followersCount)
121 actorInstance.set('followingCount', followingCount)
122 actorInstance.set('inboxUrl', attributes.inbox)
123 actorInstance.set('outboxUrl', attributes.outbox)
124 actorInstance.set('sharedInboxUrl', attributes.endpoints.sharedInbox)
125 actorInstance.set('followersUrl', attributes.followers)
126 actorInstance.set('followingUrl', attributes.following)
127}
128
129async function updateActorAvatarInstance (actorInstance: ActorModel, avatarName: string, t: Transaction) {
130 if (avatarName !== undefined) {
131 if (actorInstance.avatarId) {
132 try {
133 await actorInstance.Avatar.destroy({ transaction: t })
134 } catch (err) {
135 logger.error('Cannot remove old avatar of actor %s.', actorInstance.url, { err })
136 }
137 }
138
139 const avatar = await AvatarModel.create({
140 filename: avatarName
141 }, { transaction: t })
142
143 actorInstance.set('avatarId', avatar.id)
144 actorInstance.Avatar = avatar
145 }
146
147 return actorInstance
148}
149
150async function fetchActorTotalItems (url: string) {
151 const options = {
152 uri: url,
153 method: 'GET',
154 json: true,
155 activityPub: true
156 }
157
158 try {
159 const { body } = await doRequest(options)
160 return body.totalItems ? body.totalItems : 0
161 } catch (err) {
162 logger.warn('Cannot fetch remote actor count %s.', url, { err })
163 return 0
164 }
165}
166
167async function fetchAvatarIfExists (actorJSON: ActivityPubActor) {
168 if (
169 actorJSON.icon && actorJSON.icon.type === 'Image' && IMAGE_MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined &&
170 isActivityPubUrlValid(actorJSON.icon.url)
171 ) {
172 const extension = IMAGE_MIMETYPE_EXT[actorJSON.icon.mediaType]
173
174 const avatarName = uuidv4() + extension
175 const destPath = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
176
177 await doRequestAndSaveToFile({
178 method: 'GET',
179 uri: actorJSON.icon.url
180 }, destPath)
181
182 return avatarName
183 }
184
185 return undefined
186}
187
188async function addFetchOutboxJob (actor: ActorModel) {
189 // Don't fetch ourselves
190 const serverActor = await getServerActor()
191 if (serverActor.id === actor.id) {
192 logger.error('Cannot fetch our own outbox!')
193 return undefined
194 }
195
196 const payload = {
197 uri: actor.outboxUrl,
198 type: 'activity' as 'activity'
199 }
200
201 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
202}
203
204export {
205 getOrCreateActorAndServerAndModel,
206 buildActorInstance,
207 setAsyncActorKeys,
208 fetchActorTotalItems,
209 fetchAvatarIfExists,
210 updateActorInstance,
211 updateActorAvatarInstance,
212 addFetchOutboxJob
213}
214
215// ---------------------------------------------------------------------------
216
217function saveActorAndServerAndModelIfNotExist (
218 result: FetchRemoteActorResult,
219 ownerActor?: ActorModel,
220 t?: Transaction
221): Bluebird<ActorModel> | Promise<ActorModel> {
222 let actor = result.actor
223
224 if (t !== undefined) return save(t)
225
226 return sequelizeTypescript.transaction(t => save(t))
227
228 async function save (t: Transaction) {
229 const actorHost = url.parse(actor.url).host
230
231 const serverOptions = {
232 where: {
233 host: actorHost
234 },
235 defaults: {
236 host: actorHost
237 },
238 transaction: t
239 }
240 const [ server ] = await ServerModel.findOrCreate(serverOptions)
241
242 // Save our new account in database
243 actor.set('serverId', server.id)
244
245 // Avatar?
246 if (result.avatarName) {
247 const avatar = await AvatarModel.create({
248 filename: result.avatarName
249 }, { transaction: t })
250 actor.set('avatarId', avatar.id)
251 }
252
253 // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists
254 // (which could be false in a retried query)
255 const [ actorCreated ] = await ActorModel.findOrCreate({
256 defaults: actor.toJSON(),
257 where: {
258 url: actor.url
259 },
260 transaction: t
261 })
262
263 if (actorCreated.type === 'Person' || actorCreated.type === 'Application') {
264 actorCreated.Account = await saveAccount(actorCreated, result, t)
265 actorCreated.Account.Actor = actorCreated
266 } else if (actorCreated.type === 'Group') { // Video channel
267 actorCreated.VideoChannel = await saveVideoChannel(actorCreated, result, ownerActor, t)
268 actorCreated.VideoChannel.Actor = actorCreated
269 actorCreated.VideoChannel.Account = ownerActor.Account
270 }
271
272 return actorCreated
273 }
274}
275
276type FetchRemoteActorResult = {
277 actor: ActorModel
278 name: string
279 summary: string
280 support?: string
281 avatarName?: string
282 attributedTo: ActivityPubAttributedTo[]
283}
284async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: number, result: FetchRemoteActorResult }> {
285 const options = {
286 uri: actorUrl,
287 method: 'GET',
288 json: true,
289 activityPub: true
290 }
291
292 logger.info('Fetching remote actor %s.', actorUrl)
293
294 const requestResult = await doRequest(options)
295 normalizeActor(requestResult.body)
296
297 const actorJSON: ActivityPubActor = requestResult.body
298
299 if (isActorObjectValid(actorJSON) === false) {
300 logger.debug('Remote actor JSON is not valid.', { actorJSON: actorJSON })
301 return { result: undefined, statusCode: requestResult.response.statusCode }
302 }
303
304 const followersCount = await fetchActorTotalItems(actorJSON.followers)
305 const followingCount = await fetchActorTotalItems(actorJSON.following)
306
307 const actor = new ActorModel({
308 type: actorJSON.type,
309 uuid: actorJSON.uuid,
310 preferredUsername: actorJSON.preferredUsername,
311 url: actorJSON.id,
312 publicKey: actorJSON.publicKey.publicKeyPem,
313 privateKey: null,
314 followersCount: followersCount,
315 followingCount: followingCount,
316 inboxUrl: actorJSON.inbox,
317 outboxUrl: actorJSON.outbox,
318 sharedInboxUrl: actorJSON.endpoints.sharedInbox,
319 followersUrl: actorJSON.followers,
320 followingUrl: actorJSON.following
321 })
322
323 const avatarName = await fetchAvatarIfExists(actorJSON)
324
325 const name = actorJSON.name || actorJSON.preferredUsername
326 return {
327 statusCode: requestResult.response.statusCode,
328 result: {
329 actor,
330 name,
331 avatarName,
332 summary: actorJSON.summary,
333 support: actorJSON.support,
334 attributedTo: actorJSON.attributedTo
335 }
336 }
337}
338
339async function saveAccount (actor: ActorModel, result: FetchRemoteActorResult, t: Transaction) {
340 const [ accountCreated ] = await AccountModel.findOrCreate({
341 defaults: {
342 name: result.name,
343 description: result.summary,
344 actorId: actor.id
345 },
346 where: {
347 actorId: actor.id
348 },
349 transaction: t
350 })
351
352 return accountCreated
353}
354
355async function saveVideoChannel (actor: ActorModel, result: FetchRemoteActorResult, ownerActor: ActorModel, t: Transaction) {
356 const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({
357 defaults: {
358 name: result.name,
359 description: result.summary,
360 support: result.support,
361 actorId: actor.id,
362 accountId: ownerActor.Account.id
363 },
364 where: {
365 actorId: actor.id
366 },
367 transaction: t
368 })
369
370 return videoChannelCreated
371}
372
373async function refreshActorIfNeeded (actor: ActorModel): Promise<{ actor: ActorModel, refreshed: boolean }> {
374 if (!actor.isOutdated()) return { actor, refreshed: false }
375
376 try {
377 const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
378 const { result, statusCode } = await fetchRemoteActor(actorUrl)
379
380 if (statusCode === 404) {
381 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
382 actor.Account ? actor.Account.destroy() : actor.VideoChannel.destroy()
383 return { actor: undefined, refreshed: false }
384 }
385
386 if (result === undefined) {
387 logger.warn('Cannot fetch remote actor in refresh actor.')
388 return { actor, refreshed: false }
389 }
390
391 return sequelizeTypescript.transaction(async t => {
392 updateInstanceWithAnother(actor, result.actor)
393
394 if (result.avatarName !== undefined) {
395 await updateActorAvatarInstance(actor, result.avatarName, t)
396 }
397
398 // Force update
399 actor.setDataValue('updatedAt', new Date())
400 await actor.save({ transaction: t })
401
402 if (actor.Account) {
403 await actor.save({ transaction: t })
404
405 actor.Account.set('name', result.name)
406 actor.Account.set('description', result.summary)
407 await actor.Account.save({ transaction: t })
408 } else if (actor.VideoChannel) {
409 await actor.save({ transaction: t })
410
411 actor.VideoChannel.set('name', result.name)
412 actor.VideoChannel.set('description', result.summary)
413 actor.VideoChannel.set('support', result.support)
414 await actor.VideoChannel.save({ transaction: t })
415 }
416
417 return { refreshed: true, actor }
418 })
419 } catch (err) {
420 logger.warn('Cannot refresh actor.', { err })
421 return { actor, refreshed: false }
422 }
423}