]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/activitypub/actor-follow.ts
Fix notification scrollbar color
[github/Chocobozzz/PeerTube.git] / server / models / activitypub / actor-follow.ts
CommitLineData
a1587156 1import { difference, values } from 'lodash'
de94ac86 2import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
60650c77 3import {
06a05d5f
C
4 AfterCreate,
5 AfterDestroy,
6 AfterUpdate,
7 AllowNull,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 ForeignKey,
de94ac86 14 Is,
06a05d5f
C
15 IsInt,
16 Max,
17 Model,
18 Table,
225a7682 19 UpdatedAt
60650c77 20} from 'sequelize-typescript'
de94ac86
C
21import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
22import { getServerActor } from '@server/models/application/application'
23import { VideoModel } from '@server/models/video/video'
453e83ea
C
24import {
25 MActorFollowActorsDefault,
26 MActorFollowActorsDefaultSubscription,
27 MActorFollowFollowingHost,
1ca9f7c3 28 MActorFollowFormattable,
453e83ea 29 MActorFollowSubscriptions
26d6bf65 30} from '@server/types/models'
97ecddae 31import { ActivityPubActorType } from '@shared/models'
de94ac86
C
32import { FollowState } from '../../../shared/models/actors'
33import { ActorFollow } from '../../../shared/models/actors/follow.model'
34import { logger } from '../../helpers/logger'
35import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
36import { AccountModel } from '../account/account'
37import { ServerModel } from '../server/server'
38import { createSafeIn, getFollowsSort, getSort, searchAttribute, throwIfNotValid } from '../utils'
39import { VideoChannelModel } from '../video/video-channel'
40import { ActorModel, unusedActorAttributesForAPI } from './actor'
50d6de9c
C
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
60650c77
C
54 },
55 {
56 fields: [ 'score' ]
de94ac86
C
57 },
58 {
59 fields: [ 'url' ],
60 unique: true
50d6de9c
C
61 }
62 ]
63})
b49f22d8 64export class ActorFollowModel extends Model {
50d6de9c
C
65
66 @AllowNull(false)
1735c825 67 @Column(DataType.ENUM(...values(FOLLOW_STATES)))
50d6de9c
C
68 state: FollowState
69
60650c77
C
70 @AllowNull(false)
71 @Default(ACTOR_FOLLOW_SCORE.BASE)
72 @IsInt
73 @Max(ACTOR_FOLLOW_SCORE.MAX)
74 @Column
75 score: number
76
de94ac86
C
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
50d6de9c
C
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
32b2b43c
C
117 @AfterCreate
118 @AfterUpdate
e6122097 119 static incrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
38768a36 120 if (instance.state !== 'accepted') return undefined
32b2b43c
C
121
122 return Promise.all([
e6122097
C
123 ActorModel.rebuildFollowsCount(instance.actorId, 'following', options.transaction),
124 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers', options.transaction)
32b2b43c
C
125 ])
126 }
127
128 @AfterDestroy
e6122097 129 static decrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
32b2b43c 130 return Promise.all([
e6122097
C
131 ActorModel.rebuildFollowsCount(instance.actorId, 'following', options.transaction),
132 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers', options.transaction)
32b2b43c
C
133 ])
134 }
135
44b88f18
C
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
60650c77
C
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
8c9e7875
C
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
b49f22d8 178 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
50d6de9c
C
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
453e83ea
C
202 static loadByActorAndTargetNameAndHostForAPI (
203 actorId: number,
204 targetName: string,
205 targetHost: string,
206 t?: Transaction
b49f22d8 207 ): Promise<MActorFollowActorsDefaultSubscription> {
1735c825 208 const actorFollowingPartInclude: IncludeOptions = {
06a05d5f
C
209 model: ActorModel,
210 required: true,
211 as: 'ActorFollowing',
212 where: {
213 preferredUsername: targetName
99492dbc
C
214 },
215 include: [
216 {
f37dc0dd 217 model: VideoChannelModel.unscoped(),
99492dbc
C
218 required: false
219 }
220 ]
06a05d5f
C
221 }
222
223 if (targetHost === null) {
224 actorFollowingPartInclude.where['serverId'] = null
225 } else {
99492dbc
C
226 actorFollowingPartInclude.include.push({
227 model: ServerModel,
228 required: true,
229 where: {
230 host: targetHost
231 }
06a05d5f
C
232 })
233 }
234
50d6de9c
C
235 const query = {
236 where: {
237 actorId
238 },
239 include: [
aa55a4da
C
240 actorFollowingPartInclude,
241 {
242 model: ActorModel,
243 required: true,
244 as: 'ActorFollower'
245 }
50d6de9c
C
246 ],
247 transaction: t
248 }
249
6502c3d4 250 return ActorFollowModel.findOne(query)
f37dc0dd 251 .then(result => {
a1587156 252 if (result?.ActorFollowing.VideoChannel) {
f37dc0dd
C
253 result.ActorFollowing.VideoChannel.Actor = result.ActorFollowing
254 }
255
256 return result
257 })
258 }
259
b49f22d8 260 static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
f37dc0dd
C
261 const whereTab = targets
262 .map(t => {
263 if (t.host) {
264 return {
a1587156 265 [Op.and]: [
f37dc0dd 266 {
a1587156 267 $preferredUsername$: t.name
f37dc0dd
C
268 },
269 {
a1587156 270 $host$: t.host
f37dc0dd
C
271 }
272 ]
273 }
274 }
275
276 return {
a1587156 277 [Op.and]: [
f37dc0dd 278 {
a1587156 279 $preferredUsername$: t.name
f37dc0dd
C
280 },
281 {
a1587156 282 $serverId$: null
f37dc0dd
C
283 }
284 ]
285 }
286 })
287
288 const query = {
b49f22d8 289 attributes: [ 'id' ],
f37dc0dd 290 where: {
a1587156 291 [Op.and]: [
f37dc0dd 292 {
a1587156 293 [Op.or]: whereTab
f37dc0dd
C
294 },
295 {
296 actorId
297 }
298 ]
299 },
300 include: [
301 {
302 attributes: [ 'preferredUsername' ],
303 model: ActorModel.unscoped(),
304 required: true,
305 as: 'ActorFollowing',
306 include: [
307 {
308 attributes: [ 'host' ],
309 model: ServerModel.unscoped(),
310 required: false
311 }
312 ]
313 }
314 ]
315 }
316
317 return ActorFollowModel.findAll(query)
6502c3d4
C
318 }
319
b8f4167f 320 static listFollowingForApi (options: {
a1587156
C
321 id: number
322 start: number
323 count: number
324 sort: string
325 state?: FollowState
326 actorType?: ActivityPubActorType
b8f4167f
C
327 search?: string
328 }) {
97ecddae 329 const { id, start, count, sort, search, state, actorType } = options
b8f4167f
C
330
331 const followWhere = state ? { state } : {}
97ecddae
C
332 const followingWhere: WhereOptions = {}
333 const followingServerWhere: WhereOptions = {}
334
335 if (search) {
336 Object.assign(followingServerWhere, {
337 host: {
a1587156 338 [Op.iLike]: '%' + search + '%'
97ecddae
C
339 }
340 })
341 }
342
343 if (actorType) {
344 Object.assign(followingWhere, { type: actorType })
345 }
b8f4167f 346
50d6de9c
C
347 const query = {
348 distinct: true,
349 offset: start,
350 limit: count,
cb5ce4cb 351 order: getFollowsSort(sort),
b8f4167f 352 where: followWhere,
50d6de9c
C
353 include: [
354 {
355 model: ActorModel,
356 required: true,
357 as: 'ActorFollower',
358 where: {
359 id
360 }
361 },
362 {
363 model: ActorModel,
364 as: 'ActorFollowing',
365 required: true,
97ecddae 366 where: followingWhere,
b014b6b9
C
367 include: [
368 {
369 model: ServerModel,
370 required: true,
97ecddae 371 where: followingServerWhere
b014b6b9
C
372 }
373 ]
50d6de9c
C
374 }
375 ]
376 }
377
453e83ea 378 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
50d6de9c
C
379 .then(({ rows, count }) => {
380 return {
381 data: rows,
382 total: count
383 }
384 })
385 }
386
b8f4167f 387 static listFollowersForApi (options: {
a1587156
C
388 actorId: number
389 start: number
390 count: number
391 sort: string
392 state?: FollowState
393 actorType?: ActivityPubActorType
b8f4167f
C
394 search?: string
395 }) {
97ecddae 396 const { actorId, start, count, sort, search, state, actorType } = options
b8f4167f
C
397
398 const followWhere = state ? { state } : {}
97ecddae
C
399 const followerWhere: WhereOptions = {}
400 const followerServerWhere: WhereOptions = {}
401
402 if (search) {
403 Object.assign(followerServerWhere, {
404 host: {
a1587156 405 [Op.iLike]: '%' + search + '%'
97ecddae
C
406 }
407 })
408 }
409
410 if (actorType) {
411 Object.assign(followerWhere, { type: actorType })
412 }
b8f4167f 413
b014b6b9
C
414 const query = {
415 distinct: true,
416 offset: start,
417 limit: count,
cb5ce4cb 418 order: getFollowsSort(sort),
b8f4167f 419 where: followWhere,
b014b6b9
C
420 include: [
421 {
422 model: ActorModel,
423 required: true,
424 as: 'ActorFollower',
97ecddae 425 where: followerWhere,
b014b6b9
C
426 include: [
427 {
428 model: ServerModel,
429 required: true,
97ecddae 430 where: followerServerWhere
b014b6b9
C
431 }
432 ]
433 },
434 {
435 model: ActorModel,
436 as: 'ActorFollowing',
437 required: true,
438 where: {
cef534ed 439 id: actorId
b014b6b9
C
440 }
441 }
442 ]
443 }
444
453e83ea 445 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
b014b6b9
C
446 .then(({ rows, count }) => {
447 return {
448 data: rows,
449 total: count
450 }
451 })
452 }
453
4f5d0459
RK
454 static listSubscriptionsForApi (options: {
455 actorId: number
456 start: number
457 count: number
458 sort: string
459 search?: string
460 }) {
461 const { actorId, start, count, sort } = options
462 const where = {
463 actorId: actorId
464 }
465
466 if (options.search) {
467 Object.assign(where, {
468 [Op.or]: [
469 searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
470 searchAttribute(options.search, '$ActorFollowing.VideoChannel.name$')
471 ]
472 })
473 }
474
06a05d5f 475 const query = {
f37dc0dd 476 attributes: [],
06a05d5f
C
477 distinct: true,
478 offset: start,
479 limit: count,
480 order: getSort(sort),
4f5d0459 481 where,
06a05d5f
C
482 include: [
483 {
f5b0af50
C
484 attributes: [ 'id' ],
485 model: ActorModel.unscoped(),
06a05d5f
C
486 as: 'ActorFollowing',
487 required: true,
488 include: [
489 {
f5b0af50 490 model: VideoChannelModel.unscoped(),
22a16e36
C
491 required: true,
492 include: [
493 {
f37dc0dd
C
494 attributes: {
495 exclude: unusedActorAttributesForAPI
496 },
497 model: ActorModel,
22a16e36 498 required: true
f37dc0dd
C
499 },
500 {
f5b0af50 501 model: AccountModel.unscoped(),
f37dc0dd
C
502 required: true,
503 include: [
504 {
505 attributes: {
506 exclude: unusedActorAttributesForAPI
507 },
508 model: ActorModel,
509 required: true
510 }
511 ]
22a16e36
C
512 }
513 ]
06a05d5f
C
514 }
515 ]
516 }
517 ]
518 }
519
453e83ea 520 return ActorFollowModel.findAndCountAll<MActorFollowSubscriptions>(query)
06a05d5f
C
521 .then(({ rows, count }) => {
522 return {
523 data: rows.map(r => r.ActorFollowing.VideoChannel),
524 total: count
525 }
526 })
527 }
528
6f1b4fa4
C
529 static async keepUnfollowedInstance (hosts: string[]) {
530 const followerId = (await getServerActor()).id
531
532 const query = {
10a105f0 533 attributes: [ 'id' ],
6f1b4fa4
C
534 where: {
535 actorId: followerId
536 },
537 include: [
538 {
10a105f0 539 attributes: [ 'id' ],
6f1b4fa4
C
540 model: ActorModel.unscoped(),
541 required: true,
542 as: 'ActorFollowing',
543 where: {
544 preferredUsername: SERVER_ACTOR_NAME
545 },
546 include: [
547 {
548 attributes: [ 'host' ],
549 model: ServerModel.unscoped(),
550 required: true,
551 where: {
552 host: {
553 [Op.in]: hosts
554 }
555 }
556 }
557 ]
558 }
559 ]
560 }
561
562 const res = await ActorFollowModel.findAll(query)
10a105f0 563 const followedHosts = res.map(row => row.ActorFollowing.Server.host)
6f1b4fa4
C
564
565 return difference(hosts, followedHosts)
566 }
567
1735c825 568 static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
50d6de9c
C
569 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
570 }
571
1735c825 572 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Transaction) {
ca309a9f 573 return ActorFollowModel.createListAcceptedFollowForApiQuery(
759f8a29 574 'followers',
ca309a9f
C
575 actorIds,
576 t,
577 undefined,
578 undefined,
759f8a29
C
579 'sharedInboxUrl',
580 true
ca309a9f 581 )
50d6de9c
C
582 }
583
1735c825 584 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Transaction, start?: number, count?: number) {
50d6de9c
C
585 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
586 }
587
09cababd
C
588 static async getStats () {
589 const serverActor = await getServerActor()
590
591 const totalInstanceFollowing = await ActorFollowModel.count({
592 where: {
593 actorId: serverActor.id
594 }
595 })
596
597 const totalInstanceFollowers = await ActorFollowModel.count({
598 where: {
599 targetActorId: serverActor.id
600 }
601 })
602
603 return {
604 totalInstanceFollowing,
605 totalInstanceFollowers
606 }
607 }
608
6b9c966f 609 static updateScore (inboxUrl: string, value: number, t?: Transaction) {
2f5c6b2f
C
610 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
611 'WHERE id IN (' +
cef534ed
C
612 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
613 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
614 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
2f5c6b2f
C
615 ')'
616
617 const options = {
1735c825 618 type: QueryTypes.BULKUPDATE,
2f5c6b2f
C
619 transaction: t
620 }
621
622 return ActorFollowModel.sequelize.query(query, options)
623 }
624
6b9c966f
C
625 static async updateScoreByFollowingServers (serverIds: number[], value: number, t?: Transaction) {
626 if (serverIds.length === 0) return
627
628 const me = await getServerActor()
629 const serverIdsString = createSafeIn(ActorFollowModel, serverIds)
630
327b3318 631 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
6b9c966f
C
632 'WHERE id IN (' +
633 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
634 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."targetActorId" ' +
635 `WHERE "actorFollow"."actorId" = ${me.Account.actorId} ` + // I'm the follower
636 `AND "actor"."serverId" IN (${serverIdsString})` + // Criteria on followings
637 ')'
638
639 const options = {
640 type: QueryTypes.BULKUPDATE,
641 transaction: t
642 }
643
644 return ActorFollowModel.sequelize.query(query, options)
645 }
646
759f8a29
C
647 private static async createListAcceptedFollowForApiQuery (
648 type: 'followers' | 'following',
649 actorIds: number[],
1735c825 650 t: Transaction,
759f8a29
C
651 start?: number,
652 count?: number,
653 columnUrl = 'url',
654 distinct = false
655 ) {
50d6de9c
C
656 let firstJoin: string
657 let secondJoin: string
658
659 if (type === 'followers') {
660 firstJoin = 'targetActorId'
661 secondJoin = 'actorId'
662 } else {
663 firstJoin = 'actorId'
664 secondJoin = 'targetActorId'
665 }
666
759f8a29 667 const selections: string[] = []
862ead21
C
668 if (distinct === true) selections.push(`DISTINCT("Follows"."${columnUrl}") AS "selectionUrl"`)
669 else selections.push(`"Follows"."${columnUrl}" AS "selectionUrl"`)
759f8a29
C
670
671 selections.push('COUNT(*) AS "total"')
672
b49f22d8 673 const tasks: Promise<any>[] = []
50d6de9c 674
a1587156 675 for (const selection of selections) {
50d6de9c
C
676 let query = 'SELECT ' + selection + ' FROM "actor" ' +
677 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
678 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
862ead21 679 `WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = 'accepted' AND "Follows"."${columnUrl}" IS NOT NULL `
50d6de9c
C
680
681 if (count !== undefined) query += 'LIMIT ' + count
682 if (start !== undefined) query += ' OFFSET ' + start
683
684 const options = {
685 bind: { actorIds },
1735c825 686 type: QueryTypes.SELECT,
50d6de9c
C
687 transaction: t
688 }
689 tasks.push(ActorFollowModel.sequelize.query(query, options))
690 }
691
babecc3c 692 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
47581df0 693 const urls: string[] = followers.map(f => f.selectionUrl)
50d6de9c
C
694
695 return {
696 data: urls,
babecc3c 697 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
50d6de9c
C
698 }
699 }
700
60650c77
C
701 private static listBadActorFollows () {
702 const query = {
703 where: {
704 score: {
1735c825 705 [Op.lte]: 0
60650c77 706 }
54e74059 707 },
23e27dd5 708 logging: false
60650c77
C
709 }
710
711 return ActorFollowModel.findAll(query)
712 }
713
1ca9f7c3 714 toFormattedJSON (this: MActorFollowFormattable): ActorFollow {
50d6de9c
C
715 const follower = this.ActorFollower.toFormattedJSON()
716 const following = this.ActorFollowing.toFormattedJSON()
717
718 return {
719 id: this.id,
720 follower,
721 following,
60650c77 722 score: this.score,
50d6de9c
C
723 state: this.state,
724 createdAt: this.createdAt,
725 updatedAt: this.updatedAt
726 }
727 }
728}