Add local user subscriptions
[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 { AccountFollow } 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 } 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 loadByActorAndTargetNameAndHost (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,
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         {
204           model: ActorModel,
205           required: true,
206           as: 'ActorFollower'
207         },
208         actorFollowingPartInclude
209       ],
210       transaction: t
211     }
212
213     return ActorFollowModel.findOne(query)
214   }
215
216   static listFollowingForApi (id: number, start: number, count: number, sort: string) {
217     const query = {
218       distinct: true,
219       offset: start,
220       limit: count,
221       order: getSort(sort),
222       include: [
223         {
224           model: ActorModel,
225           required: true,
226           as: 'ActorFollower',
227           where: {
228             id
229           }
230         },
231         {
232           model: ActorModel,
233           as: 'ActorFollowing',
234           required: true,
235           include: [ ServerModel ]
236         }
237       ]
238     }
239
240     return ActorFollowModel.findAndCountAll(query)
241       .then(({ rows, count }) => {
242         return {
243           data: rows,
244           total: count
245         }
246       })
247   }
248
249   static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
250     const query = {
251       distinct: true,
252       offset: start,
253       limit: count,
254       order: getSort(sort),
255       where: {
256         actorId: id
257       },
258       include: [
259         {
260           model: ActorModel,
261           as: 'ActorFollowing',
262           required: true,
263           include: [
264             {
265               model: VideoChannelModel,
266               required: true,
267               include: [
268                 {
269                   model: AccountModel,
270                   required: true
271                 }
272               ]
273             }
274           ]
275         }
276       ]
277     }
278
279     return ActorFollowModel.findAndCountAll(query)
280                            .then(({ rows, count }) => {
281                              return {
282                                data: rows.map(r => r.ActorFollowing.VideoChannel),
283                                total: count
284                              }
285                            })
286   }
287
288   static listFollowersForApi (id: number, start: number, count: number, sort: string) {
289     const query = {
290       distinct: true,
291       offset: start,
292       limit: count,
293       order: getSort(sort),
294       include: [
295         {
296           model: ActorModel,
297           required: true,
298           as: 'ActorFollower',
299           include: [ ServerModel ]
300         },
301         {
302           model: ActorModel,
303           as: 'ActorFollowing',
304           required: true,
305           where: {
306             id
307           }
308         }
309       ]
310     }
311
312     return ActorFollowModel.findAndCountAll(query)
313       .then(({ rows, count }) => {
314         return {
315           data: rows,
316           total: count
317         }
318       })
319   }
320
321   static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
322     return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
323   }
324
325   static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Sequelize.Transaction) {
326     return ActorFollowModel.createListAcceptedFollowForApiQuery(
327       'followers',
328       actorIds,
329       t,
330       undefined,
331       undefined,
332       'sharedInboxUrl',
333       true
334     )
335   }
336
337   static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
338     return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
339   }
340
341   static async getStats () {
342     const serverActor = await getServerActor()
343
344     const totalInstanceFollowing = await ActorFollowModel.count({
345       where: {
346         actorId: serverActor.id
347       }
348     })
349
350     const totalInstanceFollowers = await ActorFollowModel.count({
351       where: {
352         targetActorId: serverActor.id
353       }
354     })
355
356     return {
357       totalInstanceFollowing,
358       totalInstanceFollowers
359     }
360   }
361
362   private static async createListAcceptedFollowForApiQuery (
363     type: 'followers' | 'following',
364     actorIds: number[],
365     t: Sequelize.Transaction,
366     start?: number,
367     count?: number,
368     columnUrl = 'url',
369     distinct = false
370   ) {
371     let firstJoin: string
372     let secondJoin: string
373
374     if (type === 'followers') {
375       firstJoin = 'targetActorId'
376       secondJoin = 'actorId'
377     } else {
378       firstJoin = 'actorId'
379       secondJoin = 'targetActorId'
380     }
381
382     const selections: string[] = []
383     if (distinct === true) selections.push('DISTINCT("Follows"."' + columnUrl + '") AS "url"')
384     else selections.push('"Follows"."' + columnUrl + '" AS "url"')
385
386     selections.push('COUNT(*) AS "total"')
387
388     const tasks: Bluebird<any>[] = []
389
390     for (let selection of selections) {
391       let query = 'SELECT ' + selection + ' FROM "actor" ' +
392         'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
393         'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
394         'WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = \'accepted\' '
395
396       if (count !== undefined) query += 'LIMIT ' + count
397       if (start !== undefined) query += ' OFFSET ' + start
398
399       const options = {
400         bind: { actorIds },
401         type: Sequelize.QueryTypes.SELECT,
402         transaction: t
403       }
404       tasks.push(ActorFollowModel.sequelize.query(query, options))
405     }
406
407     const [ followers, [ { total } ] ] = await Promise.all(tasks)
408     const urls: string[] = followers.map(f => f.url)
409
410     return {
411       data: urls,
412       total: parseInt(total, 10)
413     }
414   }
415
416   private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction | undefined) {
417     const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',')
418
419     const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
420       'WHERE id IN (' +
421         'SELECT "actorFollow"."id" FROM "actorFollow" ' +
422         'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
423         'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' +
424       ')'
425
426     const options = t ? {
427       type: Sequelize.QueryTypes.BULKUPDATE,
428       transaction: t
429     } : undefined
430
431     return ActorFollowModel.sequelize.query(query, options)
432   }
433
434   private static listBadActorFollows () {
435     const query = {
436       where: {
437         score: {
438           [Sequelize.Op.lte]: 0
439         }
440       },
441       logging: false
442     }
443
444     return ActorFollowModel.findAll(query)
445   }
446
447   toFormattedJSON (): AccountFollow {
448     const follower = this.ActorFollower.toFormattedJSON()
449     const following = this.ActorFollowing.toFormattedJSON()
450
451     return {
452       id: this.id,
453       follower,
454       following,
455       score: this.score,
456       state: this.state,
457       createdAt: this.createdAt,
458       updatedAt: this.updatedAt
459     }
460   }
461 }