]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/actor/actor-follow.ts
Cleanup models directory organization
[github/Chocobozzz/PeerTube.git] / server / models / actor / actor-follow.ts
1 import { difference, values } from 'lodash'
2 import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
3 import {
4 AfterCreate,
5 AfterDestroy,
6 AfterUpdate,
7 AllowNull,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 ForeignKey,
14 Is,
15 IsInt,
16 Max,
17 Model,
18 Table,
19 UpdatedAt
20 } from 'sequelize-typescript'
21 import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
22 import { getServerActor } from '@server/models/application/application'
23 import { VideoModel } from '@server/models/video/video'
24 import {
25 MActorFollowActorsDefault,
26 MActorFollowActorsDefaultSubscription,
27 MActorFollowFollowingHost,
28 MActorFollowFormattable,
29 MActorFollowSubscriptions
30 } from '@server/types/models'
31 import { ActivityPubActorType } from '@shared/models'
32 import { FollowState } from '../../../shared/models/actors'
33 import { ActorFollow } from '../../../shared/models/actors/follow.model'
34 import { logger } from '../../helpers/logger'
35 import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
36 import { AccountModel } from '../account/account'
37 import { ServerModel } from '../server/server'
38 import { createSafeIn, getFollowsSort, getSort, searchAttribute, throwIfNotValid } from '../utils'
39 import { VideoChannelModel } from '../video/video-channel'
40 import { ActorModel, unusedActorAttributesForAPI } from './actor'
41
42 @Table({
43 tableName: 'actorFollow',
44 indexes: [
45 {
46 fields: [ 'actorId' ]
47 },
48 {
49 fields: [ 'targetActorId' ]
50 },
51 {
52 fields: [ 'actorId', 'targetActorId' ],
53 unique: true
54 },
55 {
56 fields: [ 'score' ]
57 },
58 {
59 fields: [ 'url' ],
60 unique: true
61 }
62 ]
63 })
64 export class ActorFollowModel extends Model {
65
66 @AllowNull(false)
67 @Column(DataType.ENUM(...values(FOLLOW_STATES)))
68 state: FollowState
69
70 @AllowNull(false)
71 @Default(ACTOR_FOLLOW_SCORE.BASE)
72 @IsInt
73 @Max(ACTOR_FOLLOW_SCORE.MAX)
74 @Column
75 score: number
76
77 // Allow null because we added this column in PeerTube v3, and don't want to generate fake URLs of remote follows
78 @AllowNull(true)
79 @Is('ActorFollowUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
80 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
81 url: string
82
83 @CreatedAt
84 createdAt: Date
85
86 @UpdatedAt
87 updatedAt: Date
88
89 @ForeignKey(() => ActorModel)
90 @Column
91 actorId: number
92
93 @BelongsTo(() => ActorModel, {
94 foreignKey: {
95 name: 'actorId',
96 allowNull: false
97 },
98 as: 'ActorFollower',
99 onDelete: 'CASCADE'
100 })
101 ActorFollower: ActorModel
102
103 @ForeignKey(() => ActorModel)
104 @Column
105 targetActorId: number
106
107 @BelongsTo(() => ActorModel, {
108 foreignKey: {
109 name: 'targetActorId',
110 allowNull: false
111 },
112 as: 'ActorFollowing',
113 onDelete: 'CASCADE'
114 })
115 ActorFollowing: ActorModel
116
117 @AfterCreate
118 @AfterUpdate
119 static incrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
120 if (instance.state !== 'accepted') return undefined
121
122 return Promise.all([
123 ActorModel.rebuildFollowsCount(instance.actorId, 'following', options.transaction),
124 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers', options.transaction)
125 ])
126 }
127
128 @AfterDestroy
129 static decrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
130 return Promise.all([
131 ActorModel.rebuildFollowsCount(instance.actorId, 'following', options.transaction),
132 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers', options.transaction)
133 ])
134 }
135
136 static removeFollowsOf (actorId: number, t?: Transaction) {
137 const query = {
138 where: {
139 [Op.or]: [
140 {
141 actorId
142 },
143 {
144 targetActorId: actorId
145 }
146 ]
147 },
148 transaction: t
149 }
150
151 return ActorFollowModel.destroy(query)
152 }
153
154 // Remove actor follows with a score of 0 (too many requests where they were unreachable)
155 static async removeBadActorFollows () {
156 const actorFollows = await ActorFollowModel.listBadActorFollows()
157
158 const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy())
159 await Promise.all(actorFollowsRemovePromises)
160
161 const numberOfActorFollowsRemoved = actorFollows.length
162
163 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
164 }
165
166 static isFollowedBy (actorId: number, followerActorId: number) {
167 const query = 'SELECT 1 FROM "actorFollow" WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId LIMIT 1'
168 const options = {
169 type: QueryTypes.SELECT as QueryTypes.SELECT,
170 bind: { actorId, followerActorId },
171 raw: true
172 }
173
174 return VideoModel.sequelize.query(query, options)
175 .then(results => results.length === 1)
176 }
177
178 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
179 const query = {
180 where: {
181 actorId,
182 targetActorId: targetActorId
183 },
184 include: [
185 {
186 model: ActorModel,
187 required: true,
188 as: 'ActorFollower'
189 },
190 {
191 model: ActorModel,
192 required: true,
193 as: 'ActorFollowing'
194 }
195 ],
196 transaction: t
197 }
198
199 return ActorFollowModel.findOne(query)
200 }
201
202 static loadByActorAndTargetNameAndHostForAPI (
203 actorId: number,
204 targetName: string,
205 targetHost: string,
206 t?: Transaction
207 ): Promise<MActorFollowActorsDefaultSubscription> {
208 const actorFollowingPartInclude: IncludeOptions = {
209 model: ActorModel,
210 required: true,
211 as: 'ActorFollowing',
212 where: {
213 preferredUsername: targetName
214 },
215 include: [
216 {
217 model: VideoChannelModel.unscoped(),
218 required: false
219 }
220 ]
221 }
222
223 if (targetHost === null) {
224 actorFollowingPartInclude.where['serverId'] = null
225 } else {
226 actorFollowingPartInclude.include.push({
227 model: ServerModel,
228 required: true,
229 where: {
230 host: targetHost
231 }
232 })
233 }
234
235 const query = {
236 where: {
237 actorId
238 },
239 include: [
240 actorFollowingPartInclude,
241 {
242 model: ActorModel,
243 required: true,
244 as: 'ActorFollower'
245 }
246 ],
247 transaction: t
248 }
249
250 return ActorFollowModel.findOne(query)
251 }
252
253 static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
254 const whereTab = targets
255 .map(t => {
256 if (t.host) {
257 return {
258 [Op.and]: [
259 {
260 $preferredUsername$: t.name
261 },
262 {
263 $host$: t.host
264 }
265 ]
266 }
267 }
268
269 return {
270 [Op.and]: [
271 {
272 $preferredUsername$: t.name
273 },
274 {
275 $serverId$: null
276 }
277 ]
278 }
279 })
280
281 const query = {
282 attributes: [ 'id' ],
283 where: {
284 [Op.and]: [
285 {
286 [Op.or]: whereTab
287 },
288 {
289 actorId
290 }
291 ]
292 },
293 include: [
294 {
295 attributes: [ 'preferredUsername' ],
296 model: ActorModel.unscoped(),
297 required: true,
298 as: 'ActorFollowing',
299 include: [
300 {
301 attributes: [ 'host' ],
302 model: ServerModel.unscoped(),
303 required: false
304 }
305 ]
306 }
307 ]
308 }
309
310 return ActorFollowModel.findAll(query)
311 }
312
313 static listFollowingForApi (options: {
314 id: number
315 start: number
316 count: number
317 sort: string
318 state?: FollowState
319 actorType?: ActivityPubActorType
320 search?: string
321 }) {
322 const { id, start, count, sort, search, state, actorType } = options
323
324 const followWhere = state ? { state } : {}
325 const followingWhere: WhereOptions = {}
326 const followingServerWhere: WhereOptions = {}
327
328 if (search) {
329 Object.assign(followingServerWhere, {
330 host: {
331 [Op.iLike]: '%' + search + '%'
332 }
333 })
334 }
335
336 if (actorType) {
337 Object.assign(followingWhere, { type: actorType })
338 }
339
340 const query = {
341 distinct: true,
342 offset: start,
343 limit: count,
344 order: getFollowsSort(sort),
345 where: followWhere,
346 include: [
347 {
348 model: ActorModel,
349 required: true,
350 as: 'ActorFollower',
351 where: {
352 id
353 }
354 },
355 {
356 model: ActorModel,
357 as: 'ActorFollowing',
358 required: true,
359 where: followingWhere,
360 include: [
361 {
362 model: ServerModel,
363 required: true,
364 where: followingServerWhere
365 }
366 ]
367 }
368 ]
369 }
370
371 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
372 .then(({ rows, count }) => {
373 return {
374 data: rows,
375 total: count
376 }
377 })
378 }
379
380 static listFollowersForApi (options: {
381 actorId: number
382 start: number
383 count: number
384 sort: string
385 state?: FollowState
386 actorType?: ActivityPubActorType
387 search?: string
388 }) {
389 const { actorId, start, count, sort, search, state, actorType } = options
390
391 const followWhere = state ? { state } : {}
392 const followerWhere: WhereOptions = {}
393 const followerServerWhere: WhereOptions = {}
394
395 if (search) {
396 Object.assign(followerServerWhere, {
397 host: {
398 [Op.iLike]: '%' + search + '%'
399 }
400 })
401 }
402
403 if (actorType) {
404 Object.assign(followerWhere, { type: actorType })
405 }
406
407 const query = {
408 distinct: true,
409 offset: start,
410 limit: count,
411 order: getFollowsSort(sort),
412 where: followWhere,
413 include: [
414 {
415 model: ActorModel,
416 required: true,
417 as: 'ActorFollower',
418 where: followerWhere,
419 include: [
420 {
421 model: ServerModel,
422 required: true,
423 where: followerServerWhere
424 }
425 ]
426 },
427 {
428 model: ActorModel,
429 as: 'ActorFollowing',
430 required: true,
431 where: {
432 id: actorId
433 }
434 }
435 ]
436 }
437
438 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
439 .then(({ rows, count }) => {
440 return {
441 data: rows,
442 total: count
443 }
444 })
445 }
446
447 static listSubscriptionsForApi (options: {
448 actorId: number
449 start: number
450 count: number
451 sort: string
452 search?: string
453 }) {
454 const { actorId, start, count, sort } = options
455 const where = {
456 actorId: actorId
457 }
458
459 if (options.search) {
460 Object.assign(where, {
461 [Op.or]: [
462 searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
463 searchAttribute(options.search, '$ActorFollowing.VideoChannel.name$')
464 ]
465 })
466 }
467
468 const query = {
469 attributes: [],
470 distinct: true,
471 offset: start,
472 limit: count,
473 order: getSort(sort),
474 where,
475 include: [
476 {
477 attributes: [ 'id' ],
478 model: ActorModel.unscoped(),
479 as: 'ActorFollowing',
480 required: true,
481 include: [
482 {
483 model: VideoChannelModel.unscoped(),
484 required: true,
485 include: [
486 {
487 attributes: {
488 exclude: unusedActorAttributesForAPI
489 },
490 model: ActorModel,
491 required: true
492 },
493 {
494 model: AccountModel.unscoped(),
495 required: true,
496 include: [
497 {
498 attributes: {
499 exclude: unusedActorAttributesForAPI
500 },
501 model: ActorModel,
502 required: true
503 }
504 ]
505 }
506 ]
507 }
508 ]
509 }
510 ]
511 }
512
513 return ActorFollowModel.findAndCountAll<MActorFollowSubscriptions>(query)
514 .then(({ rows, count }) => {
515 return {
516 data: rows.map(r => r.ActorFollowing.VideoChannel),
517 total: count
518 }
519 })
520 }
521
522 static async keepUnfollowedInstance (hosts: string[]) {
523 const followerId = (await getServerActor()).id
524
525 const query = {
526 attributes: [ 'id' ],
527 where: {
528 actorId: followerId
529 },
530 include: [
531 {
532 attributes: [ 'id' ],
533 model: ActorModel.unscoped(),
534 required: true,
535 as: 'ActorFollowing',
536 where: {
537 preferredUsername: SERVER_ACTOR_NAME
538 },
539 include: [
540 {
541 attributes: [ 'host' ],
542 model: ServerModel.unscoped(),
543 required: true,
544 where: {
545 host: {
546 [Op.in]: hosts
547 }
548 }
549 }
550 ]
551 }
552 ]
553 }
554
555 const res = await ActorFollowModel.findAll(query)
556 const followedHosts = res.map(row => row.ActorFollowing.Server.host)
557
558 return difference(hosts, followedHosts)
559 }
560
561 static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
562 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
563 }
564
565 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Transaction) {
566 return ActorFollowModel.createListAcceptedFollowForApiQuery(
567 'followers',
568 actorIds,
569 t,
570 undefined,
571 undefined,
572 'sharedInboxUrl',
573 true
574 )
575 }
576
577 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Transaction, start?: number, count?: number) {
578 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
579 }
580
581 static async getStats () {
582 const serverActor = await getServerActor()
583
584 const totalInstanceFollowing = await ActorFollowModel.count({
585 where: {
586 actorId: serverActor.id
587 }
588 })
589
590 const totalInstanceFollowers = await ActorFollowModel.count({
591 where: {
592 targetActorId: serverActor.id
593 }
594 })
595
596 return {
597 totalInstanceFollowing,
598 totalInstanceFollowers
599 }
600 }
601
602 static updateScore (inboxUrl: string, value: number, t?: Transaction) {
603 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
604 'WHERE id IN (' +
605 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
606 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
607 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
608 ')'
609
610 const options = {
611 type: QueryTypes.BULKUPDATE,
612 transaction: t
613 }
614
615 return ActorFollowModel.sequelize.query(query, options)
616 }
617
618 static async updateScoreByFollowingServers (serverIds: number[], value: number, t?: Transaction) {
619 if (serverIds.length === 0) return
620
621 const me = await getServerActor()
622 const serverIdsString = createSafeIn(ActorFollowModel, serverIds)
623
624 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
625 'WHERE id IN (' +
626 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
627 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."targetActorId" ' +
628 `WHERE "actorFollow"."actorId" = ${me.Account.actorId} ` + // I'm the follower
629 `AND "actor"."serverId" IN (${serverIdsString})` + // Criteria on followings
630 ')'
631
632 const options = {
633 type: QueryTypes.BULKUPDATE,
634 transaction: t
635 }
636
637 return ActorFollowModel.sequelize.query(query, options)
638 }
639
640 private static async createListAcceptedFollowForApiQuery (
641 type: 'followers' | 'following',
642 actorIds: number[],
643 t: Transaction,
644 start?: number,
645 count?: number,
646 columnUrl = 'url',
647 distinct = false
648 ) {
649 let firstJoin: string
650 let secondJoin: string
651
652 if (type === 'followers') {
653 firstJoin = 'targetActorId'
654 secondJoin = 'actorId'
655 } else {
656 firstJoin = 'actorId'
657 secondJoin = 'targetActorId'
658 }
659
660 const selections: string[] = []
661 if (distinct === true) selections.push(`DISTINCT("Follows"."${columnUrl}") AS "selectionUrl"`)
662 else selections.push(`"Follows"."${columnUrl}" AS "selectionUrl"`)
663
664 selections.push('COUNT(*) AS "total"')
665
666 const tasks: Promise<any>[] = []
667
668 for (const selection of selections) {
669 let query = 'SELECT ' + selection + ' FROM "actor" ' +
670 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
671 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
672 `WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = 'accepted' AND "Follows"."${columnUrl}" IS NOT NULL `
673
674 if (count !== undefined) query += 'LIMIT ' + count
675 if (start !== undefined) query += ' OFFSET ' + start
676
677 const options = {
678 bind: { actorIds },
679 type: QueryTypes.SELECT,
680 transaction: t
681 }
682 tasks.push(ActorFollowModel.sequelize.query(query, options))
683 }
684
685 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
686 const urls: string[] = followers.map(f => f.selectionUrl)
687
688 return {
689 data: urls,
690 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
691 }
692 }
693
694 private static listBadActorFollows () {
695 const query = {
696 where: {
697 score: {
698 [Op.lte]: 0
699 }
700 },
701 logging: false
702 }
703
704 return ActorFollowModel.findAll(query)
705 }
706
707 toFormattedJSON (this: MActorFollowFormattable): ActorFollow {
708 const follower = this.ActorFollower.toFormattedJSON()
709 const following = this.ActorFollowing.toFormattedJSON()
710
711 return {
712 id: this.id,
713 follower,
714 following,
715 score: this.score,
716 state: this.state,
717 createdAt: this.createdAt,
718 updatedAt: this.updatedAt
719 }
720 }
721 }