]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/activitypub/actor-follow.ts
Update channel updatedAt when uploading a video
[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
C
251 }
252
b49f22d8 253 static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
f37dc0dd
C
254 const whereTab = targets
255 .map(t => {
256 if (t.host) {
257 return {
a1587156 258 [Op.and]: [
f37dc0dd 259 {
a1587156 260 $preferredUsername$: t.name
f37dc0dd
C
261 },
262 {
a1587156 263 $host$: t.host
f37dc0dd
C
264 }
265 ]
266 }
267 }
268
269 return {
a1587156 270 [Op.and]: [
f37dc0dd 271 {
a1587156 272 $preferredUsername$: t.name
f37dc0dd
C
273 },
274 {
a1587156 275 $serverId$: null
f37dc0dd
C
276 }
277 ]
278 }
279 })
280
281 const query = {
b49f22d8 282 attributes: [ 'id' ],
f37dc0dd 283 where: {
a1587156 284 [Op.and]: [
f37dc0dd 285 {
a1587156 286 [Op.or]: whereTab
f37dc0dd
C
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)
6502c3d4
C
311 }
312
b8f4167f 313 static listFollowingForApi (options: {
a1587156
C
314 id: number
315 start: number
316 count: number
317 sort: string
318 state?: FollowState
319 actorType?: ActivityPubActorType
b8f4167f
C
320 search?: string
321 }) {
97ecddae 322 const { id, start, count, sort, search, state, actorType } = options
b8f4167f
C
323
324 const followWhere = state ? { state } : {}
97ecddae
C
325 const followingWhere: WhereOptions = {}
326 const followingServerWhere: WhereOptions = {}
327
328 if (search) {
329 Object.assign(followingServerWhere, {
330 host: {
a1587156 331 [Op.iLike]: '%' + search + '%'
97ecddae
C
332 }
333 })
334 }
335
336 if (actorType) {
337 Object.assign(followingWhere, { type: actorType })
338 }
b8f4167f 339
50d6de9c
C
340 const query = {
341 distinct: true,
342 offset: start,
343 limit: count,
cb5ce4cb 344 order: getFollowsSort(sort),
b8f4167f 345 where: followWhere,
50d6de9c
C
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,
97ecddae 359 where: followingWhere,
b014b6b9
C
360 include: [
361 {
362 model: ServerModel,
363 required: true,
97ecddae 364 where: followingServerWhere
b014b6b9
C
365 }
366 ]
50d6de9c
C
367 }
368 ]
369 }
370
453e83ea 371 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
50d6de9c
C
372 .then(({ rows, count }) => {
373 return {
374 data: rows,
375 total: count
376 }
377 })
378 }
379
b8f4167f 380 static listFollowersForApi (options: {
a1587156
C
381 actorId: number
382 start: number
383 count: number
384 sort: string
385 state?: FollowState
386 actorType?: ActivityPubActorType
b8f4167f
C
387 search?: string
388 }) {
97ecddae 389 const { actorId, start, count, sort, search, state, actorType } = options
b8f4167f
C
390
391 const followWhere = state ? { state } : {}
97ecddae
C
392 const followerWhere: WhereOptions = {}
393 const followerServerWhere: WhereOptions = {}
394
395 if (search) {
396 Object.assign(followerServerWhere, {
397 host: {
a1587156 398 [Op.iLike]: '%' + search + '%'
97ecddae
C
399 }
400 })
401 }
402
403 if (actorType) {
404 Object.assign(followerWhere, { type: actorType })
405 }
b8f4167f 406
b014b6b9
C
407 const query = {
408 distinct: true,
409 offset: start,
410 limit: count,
cb5ce4cb 411 order: getFollowsSort(sort),
b8f4167f 412 where: followWhere,
b014b6b9
C
413 include: [
414 {
415 model: ActorModel,
416 required: true,
417 as: 'ActorFollower',
97ecddae 418 where: followerWhere,
b014b6b9
C
419 include: [
420 {
421 model: ServerModel,
422 required: true,
97ecddae 423 where: followerServerWhere
b014b6b9
C
424 }
425 ]
426 },
427 {
428 model: ActorModel,
429 as: 'ActorFollowing',
430 required: true,
431 where: {
cef534ed 432 id: actorId
b014b6b9
C
433 }
434 }
435 ]
436 }
437
453e83ea 438 return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
b014b6b9
C
439 .then(({ rows, count }) => {
440 return {
441 data: rows,
442 total: count
443 }
444 })
445 }
446
4f5d0459
RK
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
06a05d5f 468 const query = {
f37dc0dd 469 attributes: [],
06a05d5f
C
470 distinct: true,
471 offset: start,
472 limit: count,
473 order: getSort(sort),
4f5d0459 474 where,
06a05d5f
C
475 include: [
476 {
f5b0af50
C
477 attributes: [ 'id' ],
478 model: ActorModel.unscoped(),
06a05d5f
C
479 as: 'ActorFollowing',
480 required: true,
481 include: [
482 {
f5b0af50 483 model: VideoChannelModel.unscoped(),
22a16e36
C
484 required: true,
485 include: [
486 {
f37dc0dd
C
487 attributes: {
488 exclude: unusedActorAttributesForAPI
489 },
490 model: ActorModel,
22a16e36 491 required: true
f37dc0dd
C
492 },
493 {
f5b0af50 494 model: AccountModel.unscoped(),
f37dc0dd
C
495 required: true,
496 include: [
497 {
498 attributes: {
499 exclude: unusedActorAttributesForAPI
500 },
501 model: ActorModel,
502 required: true
503 }
504 ]
22a16e36
C
505 }
506 ]
06a05d5f
C
507 }
508 ]
509 }
510 ]
511 }
512
453e83ea 513 return ActorFollowModel.findAndCountAll<MActorFollowSubscriptions>(query)
06a05d5f
C
514 .then(({ rows, count }) => {
515 return {
516 data: rows.map(r => r.ActorFollowing.VideoChannel),
517 total: count
518 }
519 })
520 }
521
6f1b4fa4
C
522 static async keepUnfollowedInstance (hosts: string[]) {
523 const followerId = (await getServerActor()).id
524
525 const query = {
10a105f0 526 attributes: [ 'id' ],
6f1b4fa4
C
527 where: {
528 actorId: followerId
529 },
530 include: [
531 {
10a105f0 532 attributes: [ 'id' ],
6f1b4fa4
C
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)
10a105f0 556 const followedHosts = res.map(row => row.ActorFollowing.Server.host)
6f1b4fa4
C
557
558 return difference(hosts, followedHosts)
559 }
560
1735c825 561 static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
50d6de9c
C
562 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
563 }
564
1735c825 565 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Transaction) {
ca309a9f 566 return ActorFollowModel.createListAcceptedFollowForApiQuery(
759f8a29 567 'followers',
ca309a9f
C
568 actorIds,
569 t,
570 undefined,
571 undefined,
759f8a29
C
572 'sharedInboxUrl',
573 true
ca309a9f 574 )
50d6de9c
C
575 }
576
1735c825 577 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Transaction, start?: number, count?: number) {
50d6de9c
C
578 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
579 }
580
09cababd
C
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
6b9c966f 602 static updateScore (inboxUrl: string, value: number, t?: Transaction) {
2f5c6b2f
C
603 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
604 'WHERE id IN (' +
cef534ed
C
605 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
606 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
607 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
2f5c6b2f
C
608 ')'
609
610 const options = {
1735c825 611 type: QueryTypes.BULKUPDATE,
2f5c6b2f
C
612 transaction: t
613 }
614
615 return ActorFollowModel.sequelize.query(query, options)
616 }
617
6b9c966f
C
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
327b3318 624 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
6b9c966f
C
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
759f8a29
C
640 private static async createListAcceptedFollowForApiQuery (
641 type: 'followers' | 'following',
642 actorIds: number[],
1735c825 643 t: Transaction,
759f8a29
C
644 start?: number,
645 count?: number,
646 columnUrl = 'url',
647 distinct = false
648 ) {
50d6de9c
C
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
759f8a29 660 const selections: string[] = []
862ead21
C
661 if (distinct === true) selections.push(`DISTINCT("Follows"."${columnUrl}") AS "selectionUrl"`)
662 else selections.push(`"Follows"."${columnUrl}" AS "selectionUrl"`)
759f8a29
C
663
664 selections.push('COUNT(*) AS "total"')
665
b49f22d8 666 const tasks: Promise<any>[] = []
50d6de9c 667
a1587156 668 for (const selection of selections) {
50d6de9c
C
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" ' +
862ead21 672 `WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = 'accepted' AND "Follows"."${columnUrl}" IS NOT NULL `
50d6de9c
C
673
674 if (count !== undefined) query += 'LIMIT ' + count
675 if (start !== undefined) query += ' OFFSET ' + start
676
677 const options = {
678 bind: { actorIds },
1735c825 679 type: QueryTypes.SELECT,
50d6de9c
C
680 transaction: t
681 }
682 tasks.push(ActorFollowModel.sequelize.query(query, options))
683 }
684
babecc3c 685 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
47581df0 686 const urls: string[] = followers.map(f => f.selectionUrl)
50d6de9c
C
687
688 return {
689 data: urls,
babecc3c 690 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
50d6de9c
C
691 }
692 }
693
60650c77
C
694 private static listBadActorFollows () {
695 const query = {
696 where: {
697 score: {
1735c825 698 [Op.lte]: 0
60650c77 699 }
54e74059 700 },
23e27dd5 701 logging: false
60650c77
C
702 }
703
704 return ActorFollowModel.findAll(query)
705 }
706
1ca9f7c3 707 toFormattedJSON (this: MActorFollowFormattable): ActorFollow {
50d6de9c
C
708 const follower = this.ActorFollower.toFormattedJSON()
709 const following = this.ActorFollowing.toFormattedJSON()
710
711 return {
712 id: this.id,
713 follower,
714 following,
60650c77 715 score: this.score,
50d6de9c
C
716 state: this.state,
717 createdAt: this.createdAt,
718 updatedAt: this.updatedAt
719 }
720 }
721}