Add context on activitypub responses
[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, AfterDestroy, AfterUpdate, AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IsInt, Max, Model,
6   Table, UpdatedAt
7 } from 'sequelize-typescript'
8 import { FollowState } from '../../../shared/models/actors'
9 import { AccountFollow } from '../../../shared/models/actors/follow.model'
10 import { logger } from '../../helpers/logger'
11 import { ACTOR_FOLLOW_SCORE } from '../../initializers'
12 import { FOLLOW_STATES } from '../../initializers/constants'
13 import { ServerModel } from '../server/server'
14 import { getSort } from '../utils'
15 import { ActorModel } from './actor'
16
17 @Table({
18   tableName: 'actorFollow',
19   indexes: [
20     {
21       fields: [ 'actorId' ]
22     },
23     {
24       fields: [ 'targetActorId' ]
25     },
26     {
27       fields: [ 'actorId', 'targetActorId' ],
28       unique: true
29     },
30     {
31       fields: [ 'score' ]
32     }
33   ]
34 })
35 export class ActorFollowModel extends Model<ActorFollowModel> {
36
37   @AllowNull(false)
38   @Column(DataType.ENUM(values(FOLLOW_STATES)))
39   state: FollowState
40
41   @AllowNull(false)
42   @Default(ACTOR_FOLLOW_SCORE.BASE)
43   @IsInt
44   @Max(ACTOR_FOLLOW_SCORE.MAX)
45   @Column
46   score: number
47
48   @CreatedAt
49   createdAt: Date
50
51   @UpdatedAt
52   updatedAt: Date
53
54   @ForeignKey(() => ActorModel)
55   @Column
56   actorId: number
57
58   @BelongsTo(() => ActorModel, {
59     foreignKey: {
60       name: 'actorId',
61       allowNull: false
62     },
63     as: 'ActorFollower',
64     onDelete: 'CASCADE'
65   })
66   ActorFollower: ActorModel
67
68   @ForeignKey(() => ActorModel)
69   @Column
70   targetActorId: number
71
72   @BelongsTo(() => ActorModel, {
73     foreignKey: {
74       name: 'targetActorId',
75       allowNull: false
76     },
77     as: 'ActorFollowing',
78     onDelete: 'CASCADE'
79   })
80   ActorFollowing: ActorModel
81
82   @AfterCreate
83   @AfterUpdate
84   static incrementFollowerAndFollowingCount (instance: ActorFollowModel) {
85     if (instance.state !== 'accepted') return undefined
86
87     return Promise.all([
88       ActorModel.incrementFollows(instance.actorId, 'followingCount', 1),
89       ActorModel.incrementFollows(instance.targetActorId, 'followersCount', 1)
90     ])
91   }
92
93   @AfterDestroy
94   static decrementFollowerAndFollowingCount (instance: ActorFollowModel) {
95     return Promise.all([
96       ActorModel.incrementFollows(instance.actorId, 'followingCount',-1),
97       ActorModel.incrementFollows(instance.targetActorId, 'followersCount', -1)
98     ])
99   }
100
101   // Remove actor follows with a score of 0 (too many requests where they were unreachable)
102   static async removeBadActorFollows () {
103     const actorFollows = await ActorFollowModel.listBadActorFollows()
104
105     const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy())
106     await Promise.all(actorFollowsRemovePromises)
107
108     const numberOfActorFollowsRemoved = actorFollows.length
109
110     if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
111   }
112
113   static updateActorFollowsScoreAndRemoveBadOnes (goodInboxes: string[], badInboxes: string[], t: Sequelize.Transaction) {
114     if (goodInboxes.length === 0 && badInboxes.length === 0) return
115
116     logger.info('Updating %d good actor follows and %d bad actor follows scores.', goodInboxes.length, badInboxes.length)
117
118     if (goodInboxes.length !== 0) {
119       ActorFollowModel.incrementScores(goodInboxes, ACTOR_FOLLOW_SCORE.BONUS, t)
120         .catch(err => logger.error('Cannot increment scores of good actor follows.', err))
121     }
122
123     if (badInboxes.length !== 0) {
124       ActorFollowModel.incrementScores(badInboxes, ACTOR_FOLLOW_SCORE.PENALTY, t)
125         .catch(err => logger.error('Cannot decrement scores of bad actor follows.', err))
126     }
127   }
128
129   static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Sequelize.Transaction) {
130     const query = {
131       where: {
132         actorId,
133         targetActorId: targetActorId
134       },
135       include: [
136         {
137           model: ActorModel,
138           required: true,
139           as: 'ActorFollower'
140         },
141         {
142           model: ActorModel,
143           required: true,
144           as: 'ActorFollowing'
145         }
146       ],
147       transaction: t
148     }
149
150     return ActorFollowModel.findOne(query)
151   }
152
153   static loadByActorAndTargetHost (actorId: number, targetHost: string, t?: Sequelize.Transaction) {
154     const query = {
155       where: {
156         actorId
157       },
158       include: [
159         {
160           model: ActorModel,
161           required: true,
162           as: 'ActorFollower'
163         },
164         {
165           model: ActorModel,
166           required: true,
167           as: 'ActorFollowing',
168           include: [
169             {
170               model: ServerModel,
171               required: true,
172               where: {
173                 host: targetHost
174               }
175             }
176           ]
177         }
178       ],
179       transaction: t
180     }
181
182     return ActorFollowModel.findOne(query)
183   }
184
185   static loadByFollowerInbox (url: string, t?: Sequelize.Transaction) {
186     const query = {
187       where: {
188         state: 'accepted'
189       },
190       include: [
191         {
192           model: ActorModel,
193           required: true,
194           as: 'ActorFollower',
195           where: {
196             [Sequelize.Op.or]: [
197               {
198                 inboxUrl: url
199               },
200               {
201                 sharedInboxUrl: url
202               }
203             ]
204           }
205         }
206       ],
207       transaction: t
208     } as any // FIXME: typings does not work
209
210     return ActorFollowModel.findOne(query)
211   }
212
213   static listFollowingForApi (id: number, start: number, count: number, sort: string) {
214     const query = {
215       distinct: true,
216       offset: start,
217       limit: count,
218       order: [ getSort(sort) ],
219       include: [
220         {
221           model: ActorModel,
222           required: true,
223           as: 'ActorFollower',
224           where: {
225             id
226           }
227         },
228         {
229           model: ActorModel,
230           as: 'ActorFollowing',
231           required: true,
232           include: [ ServerModel ]
233         }
234       ]
235     }
236
237     return ActorFollowModel.findAndCountAll(query)
238       .then(({ rows, count }) => {
239         return {
240           data: rows,
241           total: count
242         }
243       })
244   }
245
246   static listFollowersForApi (id: number, start: number, count: number, sort: string) {
247     const query = {
248       distinct: true,
249       offset: start,
250       limit: count,
251       order: [ getSort(sort) ],
252       include: [
253         {
254           model: ActorModel,
255           required: true,
256           as: 'ActorFollower',
257           include: [ ServerModel ]
258         },
259         {
260           model: ActorModel,
261           as: 'ActorFollowing',
262           required: true,
263           where: {
264             id
265           }
266         }
267       ]
268     }
269
270     return ActorFollowModel.findAndCountAll(query)
271       .then(({ rows, count }) => {
272         return {
273           data: rows,
274           total: count
275         }
276       })
277   }
278
279   static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
280     return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
281   }
282
283   static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Sequelize.Transaction) {
284     return ActorFollowModel.createListAcceptedFollowForApiQuery(
285       'followers',
286       actorIds,
287       t,
288       undefined,
289       undefined,
290       'sharedInboxUrl',
291       true
292     )
293   }
294
295   static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) {
296     return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
297   }
298
299   private static async createListAcceptedFollowForApiQuery (
300     type: 'followers' | 'following',
301     actorIds: number[],
302     t: Sequelize.Transaction,
303     start?: number,
304     count?: number,
305     columnUrl = 'url',
306     distinct = false
307   ) {
308     let firstJoin: string
309     let secondJoin: string
310
311     if (type === 'followers') {
312       firstJoin = 'targetActorId'
313       secondJoin = 'actorId'
314     } else {
315       firstJoin = 'actorId'
316       secondJoin = 'targetActorId'
317     }
318
319     const selections: string[] = []
320     if (distinct === true) selections.push('DISTINCT("Follows"."' + columnUrl + '") AS "url"')
321     else selections.push('"Follows"."' + columnUrl + '" AS "url"')
322
323     selections.push('COUNT(*) AS "total"')
324
325     const tasks: Bluebird<any>[] = []
326
327     for (let selection of selections) {
328       let query = 'SELECT ' + selection + ' FROM "actor" ' +
329         'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
330         'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
331         'WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = \'accepted\' '
332
333       if (count !== undefined) query += 'LIMIT ' + count
334       if (start !== undefined) query += ' OFFSET ' + start
335
336       const options = {
337         bind: { actorIds },
338         type: Sequelize.QueryTypes.SELECT,
339         transaction: t
340       }
341       tasks.push(ActorFollowModel.sequelize.query(query, options))
342     }
343
344     const [ followers, [ { total } ] ] = await
345     Promise.all(tasks)
346     const urls: string[] = followers.map(f => f.url)
347
348     return {
349       data: urls,
350       total: parseInt(total, 10)
351     }
352   }
353
354   private static incrementScores (inboxUrls: string[], value: number, t: Sequelize.Transaction) {
355     const inboxUrlsString = inboxUrls.map(url => `'${url}'`).join(',')
356
357     const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
358       'WHERE id IN (' +
359         'SELECT "actorFollow"."id" FROM "actorFollow" ' +
360         'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
361         'WHERE "actor"."inboxUrl" IN (' + inboxUrlsString + ') OR "actor"."sharedInboxUrl" IN (' + inboxUrlsString + ')' +
362       ')'
363
364     const options = {
365       type: Sequelize.QueryTypes.BULKUPDATE,
366       transaction: t
367     }
368
369     return ActorFollowModel.sequelize.query(query, options)
370   }
371
372   private static listBadActorFollows () {
373     const query = {
374       where: {
375         score: {
376           [Sequelize.Op.lte]: 0
377         }
378       },
379       logging: false
380     }
381
382     return ActorFollowModel.findAll(query)
383   }
384
385   toFormattedJSON (): AccountFollow {
386     const follower = this.ActorFollower.toFormattedJSON()
387     const following = this.ActorFollowing.toFormattedJSON()
388
389     return {
390       id: this.id,
391       follower,
392       following,
393       score: this.score,
394       state: this.state,
395       createdAt: this.createdAt,
396       updatedAt: this.updatedAt
397     }
398   }
399 }