Move to eslint
[oweals/peertube.git] / server / models / activitypub / actor.ts
1 import { values } from 'lodash'
2 import { extname } from 'path'
3 import {
4   AllowNull,
5   BelongsTo,
6   Column,
7   CreatedAt,
8   DataType,
9   DefaultScope,
10   ForeignKey,
11   HasMany,
12   HasOne,
13   Is,
14   Model,
15   Scopes,
16   Table,
17   UpdatedAt
18 } from 'sequelize-typescript'
19 import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
20 import { Avatar } from '../../../shared/models/avatars/avatar.model'
21 import { activityPubContextify } from '../../helpers/activitypub'
22 import {
23   isActorFollowersCountValid,
24   isActorFollowingCountValid,
25   isActorPreferredUsernameValid,
26   isActorPrivateKeyValid,
27   isActorPublicKeyValid
28 } from '../../helpers/custom-validators/activitypub/actor'
29 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
30 import { ACTIVITY_PUB, ACTIVITY_PUB_ACTOR_TYPES, CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
31 import { AccountModel } from '../account/account'
32 import { AvatarModel } from '../avatar/avatar'
33 import { ServerModel } from '../server/server'
34 import { isOutdated, throwIfNotValid } from '../utils'
35 import { VideoChannelModel } from '../video/video-channel'
36 import { ActorFollowModel } from './actor-follow'
37 import { VideoModel } from '../video/video'
38 import {
39   MActor,
40   MActorAccountChannelId,
41   MActorAP,
42   MActorFormattable,
43   MActorFull,
44   MActorHost,
45   MActorServer,
46   MActorSummaryFormattable, MActorUrl,
47   MActorWithInboxes
48 } from '../../typings/models'
49 import * as Bluebird from 'bluebird'
50 import { Op, Transaction, literal } from 'sequelize'
51
52 enum ScopeNames {
53   FULL = 'FULL'
54 }
55
56 export const unusedActorAttributesForAPI = [
57   'publicKey',
58   'privateKey',
59   'inboxUrl',
60   'outboxUrl',
61   'sharedInboxUrl',
62   'followersUrl',
63   'followingUrl',
64   'url',
65   'createdAt',
66   'updatedAt'
67 ]
68
69 @DefaultScope(() => ({
70   include: [
71     {
72       model: ServerModel,
73       required: false
74     },
75     {
76       model: AvatarModel,
77       required: false
78     }
79   ]
80 }))
81 @Scopes(() => ({
82   [ScopeNames.FULL]: {
83     include: [
84       {
85         model: AccountModel.unscoped(),
86         required: false
87       },
88       {
89         model: VideoChannelModel.unscoped(),
90         required: false,
91         include: [
92           {
93             model: AccountModel,
94             required: true
95           }
96         ]
97       },
98       {
99         model: ServerModel,
100         required: false
101       },
102       {
103         model: AvatarModel,
104         required: false
105       }
106     ]
107   }
108 }))
109 @Table({
110   tableName: 'actor',
111   indexes: [
112     {
113       fields: [ 'url' ],
114       unique: true
115     },
116     {
117       fields: [ 'preferredUsername', 'serverId' ],
118       unique: true,
119       where: {
120         serverId: {
121           [Op.ne]: null
122         }
123       }
124     },
125     // {
126     //   fields: [ 'preferredUsername' ],
127     //   unique: true,
128     //   where: {
129     //     serverId: null
130     //   }
131     // },
132     {
133       fields: [ 'inboxUrl', 'sharedInboxUrl' ]
134     },
135     {
136       fields: [ 'sharedInboxUrl' ]
137     },
138     {
139       fields: [ 'serverId' ]
140     },
141     {
142       fields: [ 'avatarId' ]
143     },
144     {
145       fields: [ 'followersUrl' ]
146     }
147   ]
148 })
149 export class ActorModel extends Model<ActorModel> {
150
151   @AllowNull(false)
152   @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
153   type: ActivityPubActorType
154
155   @AllowNull(false)
156   @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
157   @Column
158   preferredUsername: string
159
160   @AllowNull(false)
161   @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
162   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
163   url: string
164
165   @AllowNull(true)
166   @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
167   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
168   publicKey: string
169
170   @AllowNull(true)
171   @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
172   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
173   privateKey: string
174
175   @AllowNull(false)
176   @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
177   @Column
178   followersCount: number
179
180   @AllowNull(false)
181   @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
182   @Column
183   followingCount: number
184
185   @AllowNull(false)
186   @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
187   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
188   inboxUrl: string
189
190   @AllowNull(true)
191   @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true))
192   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
193   outboxUrl: string
194
195   @AllowNull(true)
196   @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true))
197   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
198   sharedInboxUrl: string
199
200   @AllowNull(true)
201   @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true))
202   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
203   followersUrl: string
204
205   @AllowNull(true)
206   @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true))
207   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
208   followingUrl: string
209
210   @CreatedAt
211   createdAt: Date
212
213   @UpdatedAt
214   updatedAt: Date
215
216   @ForeignKey(() => AvatarModel)
217   @Column
218   avatarId: number
219
220   @BelongsTo(() => AvatarModel, {
221     foreignKey: {
222       allowNull: true
223     },
224     onDelete: 'set null',
225     hooks: true
226   })
227   Avatar: AvatarModel
228
229   @HasMany(() => ActorFollowModel, {
230     foreignKey: {
231       name: 'actorId',
232       allowNull: false
233     },
234     as: 'ActorFollowings',
235     onDelete: 'cascade'
236   })
237   ActorFollowing: ActorFollowModel[]
238
239   @HasMany(() => ActorFollowModel, {
240     foreignKey: {
241       name: 'targetActorId',
242       allowNull: false
243     },
244     as: 'ActorFollowers',
245     onDelete: 'cascade'
246   })
247   ActorFollowers: ActorFollowModel[]
248
249   @ForeignKey(() => ServerModel)
250   @Column
251   serverId: number
252
253   @BelongsTo(() => ServerModel, {
254     foreignKey: {
255       allowNull: true
256     },
257     onDelete: 'cascade'
258   })
259   Server: ServerModel
260
261   @HasOne(() => AccountModel, {
262     foreignKey: {
263       allowNull: true
264     },
265     onDelete: 'cascade',
266     hooks: true
267   })
268   Account: AccountModel
269
270   @HasOne(() => VideoChannelModel, {
271     foreignKey: {
272       allowNull: true
273     },
274     onDelete: 'cascade',
275     hooks: true
276   })
277   VideoChannel: VideoChannelModel
278
279   private static localNameCache: { [ id: string ]: any } = {}
280   private static localUrlCache: { [ id: string ]: any } = {}
281
282   static load (id: number): Bluebird<MActor> {
283     return ActorModel.unscoped().findByPk(id)
284   }
285
286   static loadFull (id: number): Bluebird<MActorFull> {
287     return ActorModel.scope(ScopeNames.FULL).findByPk(id)
288   }
289
290   static loadFromAccountByVideoId (videoId: number, transaction: Transaction): Bluebird<MActor> {
291     const query = {
292       include: [
293         {
294           attributes: [ 'id' ],
295           model: AccountModel.unscoped(),
296           required: true,
297           include: [
298             {
299               attributes: [ 'id' ],
300               model: VideoChannelModel.unscoped(),
301               required: true,
302               include: [
303                 {
304                   attributes: [ 'id' ],
305                   model: VideoModel.unscoped(),
306                   required: true,
307                   where: {
308                     id: videoId
309                   }
310                 }
311               ]
312             }
313           ]
314         }
315       ],
316       transaction
317     }
318
319     return ActorModel.unscoped().findOne(query)
320   }
321
322   static isActorUrlExist (url: string) {
323     const query = {
324       raw: true,
325       where: {
326         url
327       }
328     }
329
330     return ActorModel.unscoped().findOne(query)
331       .then(a => !!a)
332   }
333
334   static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Bluebird<MActorFull[]> {
335     const query = {
336       where: {
337         followersUrl: {
338           [Op.in]: followersUrls
339         }
340       },
341       transaction
342     }
343
344     return ActorModel.scope(ScopeNames.FULL).findAll(query)
345   }
346
347   static loadLocalByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorFull> {
348     // The server actor never change, so we can easily cache it
349     if (preferredUsername === SERVER_ACTOR_NAME && ActorModel.localNameCache[preferredUsername]) {
350       return Bluebird.resolve(ActorModel.localNameCache[preferredUsername])
351     }
352
353     const query = {
354       where: {
355         preferredUsername,
356         serverId: null
357       },
358       transaction
359     }
360
361     return ActorModel.scope(ScopeNames.FULL)
362                      .findOne(query)
363                      .then(actor => {
364                        if (preferredUsername === SERVER_ACTOR_NAME) {
365                          ActorModel.localNameCache[preferredUsername] = actor
366                        }
367
368                        return actor
369                      })
370   }
371
372   static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Bluebird<MActorUrl> {
373     // The server actor never change, so we can easily cache it
374     if (preferredUsername === SERVER_ACTOR_NAME && ActorModel.localUrlCache[preferredUsername]) {
375       return Bluebird.resolve(ActorModel.localUrlCache[preferredUsername])
376     }
377
378     const query = {
379       attributes: [ 'url' ],
380       where: {
381         preferredUsername,
382         serverId: null
383       },
384       transaction
385     }
386
387     return ActorModel.unscoped()
388                      .findOne(query)
389                      .then(actor => {
390                        if (preferredUsername === SERVER_ACTOR_NAME) {
391                          ActorModel.localUrlCache[preferredUsername] = actor
392                        }
393
394                        return actor
395                      })
396   }
397
398   static loadByNameAndHost (preferredUsername: string, host: string): Bluebird<MActorFull> {
399     const query = {
400       where: {
401         preferredUsername
402       },
403       include: [
404         {
405           model: ServerModel,
406           required: true,
407           where: {
408             host
409           }
410         }
411       ]
412     }
413
414     return ActorModel.scope(ScopeNames.FULL).findOne(query)
415   }
416
417   static loadByUrl (url: string, transaction?: Transaction): Bluebird<MActorAccountChannelId> {
418     const query = {
419       where: {
420         url
421       },
422       transaction,
423       include: [
424         {
425           attributes: [ 'id' ],
426           model: AccountModel.unscoped(),
427           required: false
428         },
429         {
430           attributes: [ 'id' ],
431           model: VideoChannelModel.unscoped(),
432           required: false
433         }
434       ]
435     }
436
437     return ActorModel.unscoped().findOne(query)
438   }
439
440   static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Bluebird<MActorFull> {
441     const query = {
442       where: {
443         url
444       },
445       transaction
446     }
447
448     return ActorModel.scope(ScopeNames.FULL).findOne(query)
449   }
450
451   static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) {
452     const sanitizedOfId = parseInt(ofId + '', 10)
453     const where = { id: sanitizedOfId }
454
455     let columnToUpdate: string
456     let columnOfCount: string
457
458     if (type === 'followers') {
459       columnToUpdate = 'followersCount'
460       columnOfCount = 'targetActorId'
461     } else {
462       columnToUpdate = 'followingCount'
463       columnOfCount = 'actorId'
464     }
465
466     return ActorModel.update({
467       [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId})`)
468     }, { where, transaction })
469   }
470
471   getSharedInbox (this: MActorWithInboxes) {
472     return this.sharedInboxUrl || this.inboxUrl
473   }
474
475   toFormattedSummaryJSON (this: MActorSummaryFormattable) {
476     let avatar: Avatar = null
477     if (this.Avatar) {
478       avatar = this.Avatar.toFormattedJSON()
479     }
480
481     return {
482       url: this.url,
483       name: this.preferredUsername,
484       host: this.getHost(),
485       avatar
486     }
487   }
488
489   toFormattedJSON (this: MActorFormattable) {
490     const base = this.toFormattedSummaryJSON()
491
492     return Object.assign(base, {
493       id: this.id,
494       hostRedundancyAllowed: this.getRedundancyAllowed(),
495       followingCount: this.followingCount,
496       followersCount: this.followersCount,
497       createdAt: this.createdAt,
498       updatedAt: this.updatedAt
499     })
500   }
501
502   toActivityPubObject (this: MActorAP, name: string) {
503     let icon: ActivityIconObject
504
505     if (this.avatarId) {
506       const extension = extname(this.Avatar.filename)
507
508       icon = {
509         type: 'Image',
510         mediaType: extension === '.png' ? 'image/png' : 'image/jpeg',
511         url: this.getAvatarUrl()
512       }
513     }
514
515     const json = {
516       type: this.type,
517       id: this.url,
518       following: this.getFollowingUrl(),
519       followers: this.getFollowersUrl(),
520       playlists: this.getPlaylistsUrl(),
521       inbox: this.inboxUrl,
522       outbox: this.outboxUrl,
523       preferredUsername: this.preferredUsername,
524       url: this.url,
525       name,
526       endpoints: {
527         sharedInbox: this.sharedInboxUrl
528       },
529       publicKey: {
530         id: this.getPublicKeyUrl(),
531         owner: this.url,
532         publicKeyPem: this.publicKey
533       },
534       icon
535     }
536
537     return activityPubContextify(json)
538   }
539
540   getFollowerSharedInboxUrls (t: Transaction) {
541     const query = {
542       attributes: [ 'sharedInboxUrl' ],
543       include: [
544         {
545           attribute: [],
546           model: ActorFollowModel.unscoped(),
547           required: true,
548           as: 'ActorFollowing',
549           where: {
550             state: 'accepted',
551             targetActorId: this.id
552           }
553         }
554       ],
555       transaction: t
556     }
557
558     return ActorModel.findAll(query)
559       .then(accounts => accounts.map(a => a.sharedInboxUrl))
560   }
561
562   getFollowingUrl () {
563     return this.url + '/following'
564   }
565
566   getFollowersUrl () {
567     return this.url + '/followers'
568   }
569
570   getPlaylistsUrl () {
571     return this.url + '/playlists'
572   }
573
574   getPublicKeyUrl () {
575     return this.url + '#main-key'
576   }
577
578   isOwned () {
579     return this.serverId === null
580   }
581
582   getWebfingerUrl (this: MActorServer) {
583     return 'acct:' + this.preferredUsername + '@' + this.getHost()
584   }
585
586   getIdentifier () {
587     return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
588   }
589
590   getHost (this: MActorHost) {
591     return this.Server ? this.Server.host : WEBSERVER.HOST
592   }
593
594   getRedundancyAllowed () {
595     return this.Server ? this.Server.redundancyAllowed : false
596   }
597
598   getAvatarUrl () {
599     if (!this.avatarId) return undefined
600
601     return WEBSERVER.URL + this.Avatar.getStaticPath()
602   }
603
604   isOutdated () {
605     if (this.isOwned()) return false
606
607     return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
608   }
609 }