Merge branch 'develop' into cli-wrapper
[oweals/peertube.git] / server / models / activitypub / actor-follow.ts
1 import * as Bluebird from 'bluebird'
2 import { values } from 'lodash'
3 import * as Sequelize from 'sequelize'
4 import {
5   AfterCreate,
6   AfterDestroy,
7   AfterUpdate,
8   AllowNull,
9   BelongsTo,
10   Column,
11   CreatedAt,
12   DataType,
13   Default,
14   ForeignKey,
15   IsInt,
16   Max,
17   Model,
18   Table,
19   UpdatedAt
20 } from 'sequelize-typescript'
21 import { FollowState } from '../../../shared/models/actors'
22 import { ActorFollow } from '../../../shared/models/actors/follow.model'
23 import { logger } from '../../helpers/logger'
24 import { getServerActor } from '../../helpers/utils'
25 import { ACTOR_FOLLOW_SCORE } from '../../initializers'
26 import { FOLLOW_STATES } from '../../initializers/constants'
27 import { ServerModel } from '../server/server'
28 import { getSort } from '../utils'
29 import { ActorModel, unusedActorAttributesForAPI } from './actor'
30 import { VideoChannelModel } from '../video/video-channel'
31 import { IIncludeOptions } from '../../../node_modules/sequelize-typescript/lib/interfaces/IIncludeOptions'
32 import { AccountModel } from '../account/account'
33
34 @Table({
35   tableName: 'actorFollow',
36   indexes: [
37     {
38       fields: [ 'actorId' ]
39     },
40     {
41       fields: [ 'targetActorId' ]
42     },
43     {
44       fields: [ 'actorId', 'targetActorId' ],
45       unique: true
46     },
47     {
48       fields: [ 'score' ]
49     }
50   ]
51 })
52 export class ActorFollowModel extends Model<ActorFollowModel> {
53
54   @AllowNull(false)
55   @Column(DataType.ENUM(values(FOLLOW_STATES)))
56   state: FollowState
57
58   @AllowNull(false)
59   @Default(ACTOR_FOLLOW_SCORE.BASE)
60   @IsInt
61   @Max(ACTOR_FOLLOW_SCORE.MAX)
62   @Column
63   score: number
64
65   @CreatedAt
66   createdAt: Date
67
68   @UpdatedAt
69   updatedAt: Date
70
71   @ForeignKey(() => ActorModel)
72   @Column
73   actorId: number
74
75   @BelongsTo(() => ActorModel, {
76     foreignKey: {
77       name: 'actorId',
78       allowNull: false
79     },
80     as: 'ActorFollower',
81     onDelete: 'CASCADE'
82   })
83   ActorFollower: ActorModel
84
85   @ForeignKey(() => ActorModel)
86   @Column
87   targetActorId: number
88
89   @BelongsTo(() => ActorModel, {
90     foreignKey: {
91       name: 'targetActorId',
92       allowNull: false
93     },
94     as: 'ActorFollowing',
95     onDelete: 'CASCADE'
96   })
97   ActorFollowing: ActorModel
98
99   @AfterCreate
100   @AfterUpdate
101   static incrementFollowerAndFollowingCount (instance: ActorFollowModel) {
102     if (instance.state !== 'accepted') return undefined
103
104     return Promise.all([
105       ActorModel.incrementFollows(instance.actorId, 'followingCount', 1),
106       ActorModel.incrementFollows(instance.targetActorId, 'followersCount', 1)
107     ])
108   }
109
110   @AfterDestroy
111   static decrementFollowerAndFollowingCount (instance: ActorFollowModel) {
112     return Promise.all([
113       ActorModel.incrementFollows(instance.actorId, 'followingCount',-1),
114       ActorModel.incrementFollows(instance.targetActorId, 'followersCount', -1)
115     ])
116   }
117
118   // Remove actor follows with a score of 0 (too many requests where they were unreachable)
119   static async removeBadActorFollows () {
120     const actorFollows = await ActorFollowModel.listBadActorFollows()
121
122     const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy())
123     await Promise.all(actorFollowsRemovePromises)
124
125     const numberOfActorFollowsRemoved = actorFollows.length
126
127     if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
128   }
129
130   static updateActorFollowsScore (goodInboxes: string[], badInboxes: string[], t: Sequelize.Transaction | undefined) {
131     if (goodInboxes.length === 0 && badInboxes.length === 0) return
132
133     logger.info('Updating %d good actor follows and %d bad actor follows scores.', goodInboxes.length, badInboxes.length)
134
135     if (goodInboxes.length !== 0) {
136       ActorFollowModel.incrementScores(goodInboxes, ACTOR_FOLLOW_SCORE.BONUS, t)
137         .catch(err => logger.error('Cannot increment scores of good actor follows.', { err }))
138     }
139
140     if (badInboxes.length !== 0) {
141       ActorFollowModel.incrementScores(badInboxes, ACTOR_FOLLOW_SCORE.PENALTY, t)
142         .catch(err => logger.error('Cannot decrement scores of bad actor follows.', { err }))
143     }
144   }
145
146   static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) {
147     const query = {
148       where: {
149         actorId,
150         targetActorId: targetActorId
151       },
152       include: [
153         {
154           model: ActorModel,
155           required: true,
156           as: 'ActorFollower'
157         },
158         {
159           model: ActorModel,
160           required: true,
161           as: 'ActorFollowing'
162         }
163       ],
164       transaction: t
165     }
166
167     return ActorFollowModel.findOne(query)
168   }
169
170   static loadByActorAndTargetNameAndHostForAPI (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) {
171     const actorFollowingPartInclude: IIncludeOptions = {
172       model: ActorModel,
173       required: true,
174       as: 'ActorFollowing',
175       where: {
176         preferredUsername: targetName
177       },
178       include: [
179         {
180           model: VideoChannelModel.unscoped(),
181           required: false
182         }
183       ]
184     }
185
186     if (targetHost === null) {
187       actorFollowingPartInclude.where['serverId'] = null
188     } else {
189       actorFollowingPartInclude.include.push({
190         model: ServerModel,
191         required: true,
192         where: {
193           host: targetHost
194         }
195       })
196     }
197
198     const query = {
199       where: {
200         actorId
201       },
202       include: [
203         actorFollowingPartInclude,
204         {
205           model: ActorModel,
206           required: true,
207           as: 'ActorFollower'
208         }
209       ],
210       transaction: t
211     }
212
213     return ActorFollowModel.findOne(query)
214       .then(result => {
215         if (result && result.ActorFollowing.VideoChannel) {
216           result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing
217         }
218
219         return result
220       })
221   }
222
223   static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]) {
224     const whereTab = targets
225       .map(t => {
226         if (t.host) {
227           return {
228             [ Sequelize.Op.and ]: [
229               {
230                 '$preferredUsername$': t.name
231               },
232               {
233                 '$host$': t.host
234               }
235             ]
236           }
237         }
238
239         return {
240           [ Sequelize.Op.and ]: [
241             {
242               '$preferredUsername$': t.name
243             },
244             {
245               '$serverId$': null
246             }
247           ]
248         }
249       })
250
251     const query = {
252       attributes: [],
253       where: {
254         [ Sequelize.Op.and ]: [
255           {
256             [ Sequelize.Op.or ]: whereTab
257           },
258           {
259             actorId
260           }
261         ]
262       },
263       include: [
264         {
265           attributes: [ 'preferredUsername' ],
266           model: ActorModel.unscoped(),
267           required: true,
268           as: 'ActorFollowing',
269           include: [
270             {
271               attributes: [ 'host' ],
272               model: ServerModel.unscoped(),
273               required: false
274             }
275           ]
276         }
277       ]
278     }
279
280     return ActorFollowModel.findAll(query)
281   }
282
283   static listFollowingForApi (id: number, start: number, count: number, sort: string) {
284     const query = {
285       distinct: true,
286       offset: start,
287       limit: count,
288       order: getSort(sort),
289       include: [
290         {
291           model: ActorModel,
292           required: true,
293           as: 'ActorFollower',
294           where: {
295             id
296           }
297         },
298         {
299           model: ActorModel,
300           as: 'ActorFollowing',
301           required: true,
302           include: [ ServerModel ]
303         }
304       ]
305     }
306
307     return ActorFollowModel.findAndCountAll(query)
308       .then(({ rows, count }) => {
309         return {
310           data: rows,
311           total: count
312         }
313       })
314   }
315
316   static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
317     const query = {
318       attributes: [],
319       distinct: true,
320       offset: start,
321       limit: count,
322       order: getSort(sort),
323       where: {
324         actorId: id
325       },
326       include: [
327         {
328           attributes: [ 'id' ],
329           model: ActorModel.unscoped(),
330           as: 'ActorFollowing',
331           required: true,
332           include: [
333             {
334               model: VideoChannelModel.unscoped(),
335               required: true,
336               include: [
337                 {
338                   attributes: {
339                     exclude: unusedActorAttributesForAPI
340                   },
341                   model: ActorModel,
342                   required: true
343                 },
344                 {
345                   model: AccountModel.unscoped(),
346                   required: true,
347                   include: [
348                     {
349                       attributes: {
350                         exclude: unusedActorAttributesForAPI
351                       },
352                       model: ActorModel,
353                       required: true
354                     }
355                   ]
356                 }
357               ]
358             }
359           ]
360         }
361       ]
362     }
363
364     return ActorFollowModel.findAndCountAll(query)
365                            .then(({ rows, count }) => {
366                              return {
367                                data: rows.map(r => r.ActorFollowing.VideoChannel),
368                                total: count
369                              }
370                            })
371   }
372
373   static listFollowersForApi (id: number, start: number, count: number, sort: string) {
374     const query = {
375       distinct: true,
376       offset: start,
377       limit: count,
378       order: getSort(sort),
379       include: [
380         {
381           model: ActorModel,
382           required: true,
383           as: 'ActorFollower',
384           include: [ ServerModel ]
385         },
386         {
387           model: ActorModel,
388           as: 'ActorFollowing',
389           required: true,
390           where: {
391             id
392           }
393         }
394       ]
395     }
396
397     return ActorFollowModel.findAndCountAll(query)
398       .then(({ rows, count }) => {
399         return {
400           data: rows,
401           total: count
402         }
403       })
404   }
405
406   static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
407     return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
408   }
409
410   static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Sequelize.Transaction) {
411     return ActorFollowModel.createListAcceptedFollowForApiQuery(
412       'followers',
413       actorIds,
414       t,
415       undefined,
416       undefined,
417       'sharedInboxUrl',
418       true
419     )
420   }
421
422   static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
423     return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
424   }
425
426   static async getStats () {
427     const serverActor = await getServerActor()
428
429     const totalInstanceFollowing = await ActorFollowModel.count({
430       where: {
431         actorId: serverActor.id
432       }
433     })
434
435     const totalInstanceFollowers = await ActorFollowModel.count({
436       where: {
437         targetActorId: serverActor.id
438       }
439     })
440
441     return {
442       totalInstanceFollowing,
443       totalInstanceFollowers
444     }
445   }
446
447   private static async createListAcceptedFollowForApiQuery (
448     type: 'followers' | 'following',
449     actorIds: number[],
450     t: Sequelize.Transaction,
451     start?: number,
452     count?: number,
453     columnUrl = 'url',
454     distinct = false
455   ) {
456     let firstJoin: string
457     let secondJoin: string
458
459     if (type === 'followers') {
460       firstJoin = 'targetActorId'
461       secondJoin = 'actorId'
462     } else {
463       firstJoin = 'actorId'
464       secondJoin = 'targetActorId'
465     }
466
467     const selections: string[] = []
468     if (distinct === true) selections.push('DISTINCT("Follows"."' + columnUrl + '") AS "url"')
469     else selections.push('"Follows"."' + columnUrl + '" AS "url"')
470
471     selections.push('COUNT(*) AS "total"')
472
473     const tasks: Bluebird<any>[] = []
474
475     for (let selection of selections) {
476       let query = 'SELECT ' + selection + ' FROM "actor" ' +
477         'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
478         'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
479         'WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = \'accepted\' '
480
481       if (count !== undefined) query += 'LIMIT ' + count
482       if (start !== undefined) query += ' OFFSET ' + start
483
484       const options = {
485         bind: { actorIds },
486         type: Sequelize.QueryTypes.SELECT,
487         transaction: t
488       }
489       tasks.push(ActorFollowModel.sequelize.query(query, options))
490     }
491
492     const [ followers, [ { total } ] ] = await Promise.all(tasks)
493     const urls: string[] = followers.map(f => f.url)
494
495     return {
496       data: urls,
497       total: parseInt(total, 10)
498     }
499   }
500
501   private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction | undefined) {
502     const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',')
503
504     const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
505       'WHERE id IN (' +
506         'SELECT "actorFollow"."id" FROM "actorFollow" ' +
507         'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
508         'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' +
509       ')'
510
511     const options = t ? {
512       type: Sequelize.QueryTypes.BULKUPDATE,
513       transaction: t
514     } : undefined
515
516     return ActorFollowModel.sequelize.query(query, options)
517   }
518
519   private static listBadActorFollows () {
520     const query = {
521       where: {
522         score: {
523           [Sequelize.Op.lte]: 0
524         }
525       },
526       logging: false
527     }
528
529     return ActorFollowModel.findAll(query)
530   }
531
532   toFormattedJSON (): ActorFollow {
533     const follower = this.ActorFollower.toFormattedJSON()
534     const following = this.ActorFollowing.toFormattedJSON()
535
536     return {
537       id: this.id,
538       follower,
539       following,
540       score: this.score,
541       state: this.state,
542       createdAt: this.createdAt,
543       updatedAt: this.updatedAt
544     }
545   }
546 }