Fix AP collections pagination
[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, search?: 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: [
303             {
304               model: ServerModel,
305               required: true,
306               where: search ? {
307                 host: {
308                   [Sequelize.Op.iLike]: '%' + search + '%'
309                 }
310               } : undefined
311             }
312           ]
313         }
314       ]
315     }
316
317     return ActorFollowModel.findAndCountAll(query)
318       .then(({ rows, count }) => {
319         return {
320           data: rows,
321           total: count
322         }
323       })
324   }
325
326   static listFollowersForApi (id: number, start: number, count: number, sort: string, search?: string) {
327     const query = {
328       distinct: true,
329       offset: start,
330       limit: count,
331       order: getSort(sort),
332       include: [
333         {
334           model: ActorModel,
335           required: true,
336           as: 'ActorFollower',
337           include: [
338             {
339               model: ServerModel,
340               required: true,
341               where: search ? {
342                 host: {
343                   [ Sequelize.Op.iLike ]: '%' + search + '%'
344                 }
345               } : undefined
346             }
347           ]
348         },
349         {
350           model: ActorModel,
351           as: 'ActorFollowing',
352           required: true,
353           where: {
354             id
355           }
356         }
357       ]
358     }
359
360     return ActorFollowModel.findAndCountAll(query)
361                            .then(({ rows, count }) => {
362                              return {
363                                data: rows,
364                                total: count
365                              }
366                            })
367   }
368
369   static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
370     const query = {
371       attributes: [],
372       distinct: true,
373       offset: start,
374       limit: count,
375       order: getSort(sort),
376       where: {
377         actorId: id
378       },
379       include: [
380         {
381           attributes: [ 'id' ],
382           model: ActorModel.unscoped(),
383           as: 'ActorFollowing',
384           required: true,
385           include: [
386             {
387               model: VideoChannelModel.unscoped(),
388               required: true,
389               include: [
390                 {
391                   attributes: {
392                     exclude: unusedActorAttributesForAPI
393                   },
394                   model: ActorModel,
395                   required: true
396                 },
397                 {
398                   model: AccountModel.unscoped(),
399                   required: true,
400                   include: [
401                     {
402                       attributes: {
403                         exclude: unusedActorAttributesForAPI
404                       },
405                       model: ActorModel,
406                       required: true
407                     }
408                   ]
409                 }
410               ]
411             }
412           ]
413         }
414       ]
415     }
416
417     return ActorFollowModel.findAndCountAll(query)
418                            .then(({ rows, count }) => {
419                              return {
420                                data: rows.map(r => r.ActorFollowing.VideoChannel),
421                                total: count
422                              }
423                            })
424   }
425
426   static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
427     return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
428   }
429
430   static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Sequelize.Transaction) {
431     return ActorFollowModel.createListAcceptedFollowForApiQuery(
432       'followers',
433       actorIds,
434       t,
435       undefined,
436       undefined,
437       'sharedInboxUrl',
438       true
439     )
440   }
441
442   static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
443     return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
444   }
445
446   static async getStats () {
447     const serverActor = await getServerActor()
448
449     const totalInstanceFollowing = await ActorFollowModel.count({
450       where: {
451         actorId: serverActor.id
452       }
453     })
454
455     const totalInstanceFollowers = await ActorFollowModel.count({
456       where: {
457         targetActorId: serverActor.id
458       }
459     })
460
461     return {
462       totalInstanceFollowing,
463       totalInstanceFollowers
464     }
465   }
466
467   private static async createListAcceptedFollowForApiQuery (
468     type: 'followers' | 'following',
469     actorIds: number[],
470     t: Sequelize.Transaction,
471     start?: number,
472     count?: number,
473     columnUrl = 'url',
474     distinct = false
475   ) {
476     let firstJoin: string
477     let secondJoin: string
478
479     if (type === 'followers') {
480       firstJoin = 'targetActorId'
481       secondJoin = 'actorId'
482     } else {
483       firstJoin = 'actorId'
484       secondJoin = 'targetActorId'
485     }
486
487     const selections: string[] = []
488     if (distinct === true) selections.push('DISTINCT("Follows"."' + columnUrl + '") AS "url"')
489     else selections.push('"Follows"."' + columnUrl + '" AS "url"')
490
491     selections.push('COUNT(*) AS "total"')
492
493     const tasks: Bluebird<any>[] = []
494
495     for (let selection of selections) {
496       let query = 'SELECT ' + selection + ' FROM "actor" ' +
497         'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
498         'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
499         'WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = \'accepted\' '
500
501       if (count !== undefined) query += 'LIMIT ' + count
502       if (start !== undefined) query += ' OFFSET ' + start
503
504       const options = {
505         bind: { actorIds },
506         type: Sequelize.QueryTypes.SELECT,
507         transaction: t
508       }
509       tasks.push(ActorFollowModel.sequelize.query(query, options))
510     }
511
512     const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
513     const urls: string[] = followers.map(f => f.url)
514
515     return {
516       data: urls,
517       total: dataTotal ? parseInt(dataTotal.total, 10) : 0
518     }
519   }
520
521   private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction | undefined) {
522     const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',')
523
524     const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
525       'WHERE id IN (' +
526         'SELECT "actorFollow"."id" FROM "actorFollow" ' +
527         'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
528         'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' +
529       ')'
530
531     const options = t ? {
532       type: Sequelize.QueryTypes.BULKUPDATE,
533       transaction: t
534     } : undefined
535
536     return ActorFollowModel.sequelize.query(query, options)
537   }
538
539   private static listBadActorFollows () {
540     const query = {
541       where: {
542         score: {
543           [Sequelize.Op.lte]: 0
544         }
545       },
546       logging: false
547     }
548
549     return ActorFollowModel.findAll(query)
550   }
551
552   toFormattedJSON (): ActorFollow {
553     const follower = this.ActorFollower.toFormattedJSON()
554     const following = this.ActorFollowing.toFormattedJSON()
555
556     return {
557       id: this.id,
558       follower,
559       following,
560       score: this.score,
561       state: this.state,
562       createdAt: this.createdAt,
563       updatedAt: this.updatedAt
564     }
565   }
566 }