Automatically remove bad followings
[oweals/peertube.git] / server / models / activitypub / actor-follow.ts
1 import * as Bluebird from 'bluebird'
2 import { values } from 'lodash'
3 import {
4   AfterCreate,
5   AfterDestroy,
6   AfterUpdate,
7   AllowNull,
8   BelongsTo,
9   Column,
10   CreatedAt,
11   DataType,
12   Default,
13   ForeignKey,
14   IsInt,
15   Max,
16   Model,
17   Table,
18   UpdatedAt
19 } from 'sequelize-typescript'
20 import { FollowState } from '../../../shared/models/actors'
21 import { ActorFollow } from '../../../shared/models/actors/follow.model'
22 import { logger } from '../../helpers/logger'
23 import { getServerActor } from '../../helpers/utils'
24 import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES } from '../../initializers/constants'
25 import { ServerModel } from '../server/server'
26 import { createSafeIn, getSort } from '../utils'
27 import { ActorModel, unusedActorAttributesForAPI } from './actor'
28 import { VideoChannelModel } from '../video/video-channel'
29 import { AccountModel } from '../account/account'
30 import { IncludeOptions, Op, Transaction, QueryTypes } from 'sequelize'
31
32 @Table({
33   tableName: 'actorFollow',
34   indexes: [
35     {
36       fields: [ 'actorId' ]
37     },
38     {
39       fields: [ 'targetActorId' ]
40     },
41     {
42       fields: [ 'actorId', 'targetActorId' ],
43       unique: true
44     },
45     {
46       fields: [ 'score' ]
47     }
48   ]
49 })
50 export class ActorFollowModel extends Model<ActorFollowModel> {
51
52   @AllowNull(false)
53   @Column(DataType.ENUM(...values(FOLLOW_STATES)))
54   state: FollowState
55
56   @AllowNull(false)
57   @Default(ACTOR_FOLLOW_SCORE.BASE)
58   @IsInt
59   @Max(ACTOR_FOLLOW_SCORE.MAX)
60   @Column
61   score: number
62
63   @CreatedAt
64   createdAt: Date
65
66   @UpdatedAt
67   updatedAt: Date
68
69   @ForeignKey(() => ActorModel)
70   @Column
71   actorId: number
72
73   @BelongsTo(() => ActorModel, {
74     foreignKey: {
75       name: 'actorId',
76       allowNull: false
77     },
78     as: 'ActorFollower',
79     onDelete: 'CASCADE'
80   })
81   ActorFollower: ActorModel
82
83   @ForeignKey(() => ActorModel)
84   @Column
85   targetActorId: number
86
87   @BelongsTo(() => ActorModel, {
88     foreignKey: {
89       name: 'targetActorId',
90       allowNull: false
91     },
92     as: 'ActorFollowing',
93     onDelete: 'CASCADE'
94   })
95   ActorFollowing: ActorModel
96
97   @AfterCreate
98   @AfterUpdate
99   static incrementFollowerAndFollowingCount (instance: ActorFollowModel) {
100     if (instance.state !== 'accepted') return undefined
101
102     return Promise.all([
103       ActorModel.incrementFollows(instance.actorId, 'followingCount', 1),
104       ActorModel.incrementFollows(instance.targetActorId, 'followersCount', 1)
105     ])
106   }
107
108   @AfterDestroy
109   static decrementFollowerAndFollowingCount (instance: ActorFollowModel) {
110     return Promise.all([
111       ActorModel.incrementFollows(instance.actorId, 'followingCount',-1),
112       ActorModel.incrementFollows(instance.targetActorId, 'followersCount', -1)
113     ])
114   }
115
116   static removeFollowsOf (actorId: number, t?: Transaction) {
117     const query = {
118       where: {
119         [Op.or]: [
120           {
121             actorId
122           },
123           {
124             targetActorId: actorId
125           }
126         ]
127       },
128       transaction: t
129     }
130
131     return ActorFollowModel.destroy(query)
132   }
133
134   // Remove actor follows with a score of 0 (too many requests where they were unreachable)
135   static async removeBadActorFollows () {
136     const actorFollows = await ActorFollowModel.listBadActorFollows()
137
138     const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy())
139     await Promise.all(actorFollowsRemovePromises)
140
141     const numberOfActorFollowsRemoved = actorFollows.length
142
143     if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
144   }
145
146   static loadByActorAndTarget (actorId: number, targetActorId: number, t?: 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?: Transaction) {
171     const actorFollowingPartInclude: IncludeOptions = {
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             [ Op.and ]: [
229               {
230                 '$preferredUsername$': t.name
231               },
232               {
233                 '$host$': t.host
234               }
235             ]
236           }
237         }
238
239         return {
240           [ 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         [ Op.and ]: [
255           {
256             [ 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                   [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 (actorId: 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                   [ Op.iLike ]: '%' + search + '%'
344                 }
345               } : undefined
346             }
347           ]
348         },
349         {
350           model: ActorModel,
351           as: 'ActorFollowing',
352           required: true,
353           where: {
354             id: actorId
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 (actorId: 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: actorId
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 listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
427     return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
428   }
429
430   static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: 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: 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   static updateScore (inboxUrl: string, value: number, t?: Transaction) {
468     const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
469       'WHERE id IN (' +
470         'SELECT "actorFollow"."id" FROM "actorFollow" ' +
471         'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
472         `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
473       ')'
474
475     const options = {
476       type: QueryTypes.BULKUPDATE,
477       transaction: t
478     }
479
480     return ActorFollowModel.sequelize.query(query, options)
481   }
482
483   static async updateScoreByFollowingServers (serverIds: number[], value: number, t?: Transaction) {
484     if (serverIds.length === 0) return
485
486     const me = await getServerActor()
487     const serverIdsString = createSafeIn(ActorFollowModel, serverIds)
488
489     const query = `UPDATE "actorFollow" SET "score" = "score" + ${value} ` +
490       'WHERE id IN (' +
491         'SELECT "actorFollow"."id" FROM "actorFollow" ' +
492         'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."targetActorId" ' +
493         `WHERE "actorFollow"."actorId" = ${me.Account.actorId} ` + // I'm the follower
494         `AND "actor"."serverId" IN (${serverIdsString})` + // Criteria on followings
495       ')'
496
497     const options = {
498       type: QueryTypes.BULKUPDATE,
499       transaction: t
500     }
501
502     return ActorFollowModel.sequelize.query(query, options)
503   }
504
505   private static async createListAcceptedFollowForApiQuery (
506     type: 'followers' | 'following',
507     actorIds: number[],
508     t: Transaction,
509     start?: number,
510     count?: number,
511     columnUrl = 'url',
512     distinct = false
513   ) {
514     let firstJoin: string
515     let secondJoin: string
516
517     if (type === 'followers') {
518       firstJoin = 'targetActorId'
519       secondJoin = 'actorId'
520     } else {
521       firstJoin = 'actorId'
522       secondJoin = 'targetActorId'
523     }
524
525     const selections: string[] = []
526     if (distinct === true) selections.push('DISTINCT("Follows"."' + columnUrl + '") AS "url"')
527     else selections.push('"Follows"."' + columnUrl + '" AS "url"')
528
529     selections.push('COUNT(*) AS "total"')
530
531     const tasks: Bluebird<any>[] = []
532
533     for (let selection of selections) {
534       let query = 'SELECT ' + selection + ' FROM "actor" ' +
535         'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
536         'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
537         'WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = \'accepted\' '
538
539       if (count !== undefined) query += 'LIMIT ' + count
540       if (start !== undefined) query += ' OFFSET ' + start
541
542       const options = {
543         bind: { actorIds },
544         type: QueryTypes.SELECT,
545         transaction: t
546       }
547       tasks.push(ActorFollowModel.sequelize.query(query, options))
548     }
549
550     const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
551     const urls: string[] = followers.map(f => f.url)
552
553     return {
554       data: urls,
555       total: dataTotal ? parseInt(dataTotal.total, 10) : 0
556     }
557   }
558
559   private static listBadActorFollows () {
560     const query = {
561       where: {
562         score: {
563           [Op.lte]: 0
564         }
565       },
566       logging: false
567     }
568
569     return ActorFollowModel.findAll(query)
570   }
571
572   toFormattedJSON (): ActorFollow {
573     const follower = this.ActorFollower.toFormattedJSON()
574     const following = this.ActorFollowing.toFormattedJSON()
575
576     return {
577       id: this.id,
578       follower,
579       following,
580       score: this.score,
581       state: this.state,
582       createdAt: this.createdAt,
583       updatedAt: this.updatedAt
584     }
585   }
586 }