aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/actor
diff options
context:
space:
mode:
Diffstat (limited to 'server/models/actor')
-rw-r--r--server/models/actor/actor-follow.ts721
-rw-r--r--server/models/actor/actor-image.ts100
-rw-r--r--server/models/actor/actor.ts699
3 files changed, 1520 insertions, 0 deletions
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
new file mode 100644
index 000000000..4c5f37620
--- /dev/null
+++ b/server/models/actor/actor-follow.ts
@@ -0,0 +1,721 @@
1import { difference, values } from 'lodash'
2import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
3import {
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'
21import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
22import { getServerActor } from '@server/models/application/application'
23import { VideoModel } from '@server/models/video/video'
24import {
25 MActorFollowActorsDefault,
26 MActorFollowActorsDefaultSubscription,
27 MActorFollowFollowingHost,
28 MActorFollowFormattable,
29 MActorFollowSubscriptions
30} from '@server/types/models'
31import { ActivityPubActorType } from '@shared/models'
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'
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})
64export 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}
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
new file mode 100644
index 000000000..ae05b4969
--- /dev/null
+++ b/server/models/actor/actor-image.ts
@@ -0,0 +1,100 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { MActorImageFormattable } from '@server/types/models'
5import { ActorImageType } from '@shared/models'
6import { ActorImage } from '../../../shared/models/actors/actor-image.model'
7import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
8import { logger } from '../../helpers/logger'
9import { CONFIG } from '../../initializers/config'
10import { LAZY_STATIC_PATHS } from '../../initializers/constants'
11import { throwIfNotValid } from '../utils'
12
13@Table({
14 tableName: 'actorImage',
15 indexes: [
16 {
17 fields: [ 'filename' ],
18 unique: true
19 }
20 ]
21})
22export class ActorImageModel extends Model {
23
24 @AllowNull(false)
25 @Column
26 filename: string
27
28 @AllowNull(true)
29 @Default(null)
30 @Column
31 height: number
32
33 @AllowNull(true)
34 @Default(null)
35 @Column
36 width: number
37
38 @AllowNull(true)
39 @Is('ActorImageFileUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'fileUrl', true))
40 @Column
41 fileUrl: string
42
43 @AllowNull(false)
44 @Column
45 onDisk: boolean
46
47 @AllowNull(false)
48 @Column
49 type: ActorImageType
50
51 @CreatedAt
52 createdAt: Date
53
54 @UpdatedAt
55 updatedAt: Date
56
57 @AfterDestroy
58 static removeFilesAndSendDelete (instance: ActorImageModel) {
59 logger.info('Removing actor image file %s.', instance.filename)
60
61 // Don't block the transaction
62 instance.removeImage()
63 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, err))
64 }
65
66 static loadByName (filename: string) {
67 const query = {
68 where: {
69 filename
70 }
71 }
72
73 return ActorImageModel.findOne(query)
74 }
75
76 toFormattedJSON (this: MActorImageFormattable): ActorImage {
77 return {
78 path: this.getStaticPath(),
79 createdAt: this.createdAt,
80 updatedAt: this.updatedAt
81 }
82 }
83
84 getStaticPath () {
85 if (this.type === ActorImageType.AVATAR) {
86 return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
87 }
88
89 return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
90 }
91
92 getPath () {
93 return join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename)
94 }
95
96 removeImage () {
97 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename)
98 return remove(imagePath)
99 }
100}
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
new file mode 100644
index 000000000..5cf6fb8f1
--- /dev/null
+++ b/server/models/actor/actor.ts
@@ -0,0 +1,699 @@
1import { values } from 'lodash'
2import { extname } from 'path'
3import { literal, Op, Transaction } from 'sequelize'
4import {
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 DefaultScope,
11 ForeignKey,
12 HasMany,
13 HasOne,
14 Is,
15 Model,
16 Scopes,
17 Table,
18 UpdatedAt
19} from 'sequelize-typescript'
20import { ModelCache } from '@server/models/model-cache'
21import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
22import { ActorImage } from '../../../shared/models/actors/actor-image.model'
23import { activityPubContextify } from '../../helpers/activitypub'
24import {
25 isActorFollowersCountValid,
26 isActorFollowingCountValid,
27 isActorPreferredUsernameValid,
28 isActorPrivateKeyValid,
29 isActorPublicKeyValid
30} from '../../helpers/custom-validators/activitypub/actor'
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import {
33 ACTIVITY_PUB,
34 ACTIVITY_PUB_ACTOR_TYPES,
35 CONSTRAINTS_FIELDS,
36 MIMETYPES,
37 SERVER_ACTOR_NAME,
38 WEBSERVER
39} from '../../initializers/constants'
40import {
41 MActor,
42 MActorAccountChannelId,
43 MActorAPAccount,
44 MActorAPChannel,
45 MActorFormattable,
46 MActorFull,
47 MActorHost,
48 MActorServer,
49 MActorSummaryFormattable,
50 MActorUrl,
51 MActorWithInboxes
52} from '../../types/models'
53import { AccountModel } from '../account/account'
54import { ServerModel } from '../server/server'
55import { isOutdated, throwIfNotValid } from '../utils'
56import { VideoModel } from '../video/video'
57import { VideoChannelModel } from '../video/video-channel'
58import { ActorFollowModel } from './actor-follow'
59import { ActorImageModel } from './actor-image'
60
61enum ScopeNames {
62 FULL = 'FULL'
63}
64
65export const unusedActorAttributesForAPI = [
66 'publicKey',
67 'privateKey',
68 'inboxUrl',
69 'outboxUrl',
70 'sharedInboxUrl',
71 'followersUrl',
72 'followingUrl'
73]
74
75@DefaultScope(() => ({
76 include: [
77 {
78 model: ServerModel,
79 required: false
80 },
81 {
82 model: ActorImageModel,
83 as: 'Avatar',
84 required: false
85 }
86 ]
87}))
88@Scopes(() => ({
89 [ScopeNames.FULL]: {
90 include: [
91 {
92 model: AccountModel.unscoped(),
93 required: false
94 },
95 {
96 model: VideoChannelModel.unscoped(),
97 required: false,
98 include: [
99 {
100 model: AccountModel,
101 required: true
102 }
103 ]
104 },
105 {
106 model: ServerModel,
107 required: false
108 },
109 {
110 model: ActorImageModel,
111 as: 'Avatar',
112 required: false
113 },
114 {
115 model: ActorImageModel,
116 as: 'Banner',
117 required: false
118 }
119 ]
120 }
121}))
122@Table({
123 tableName: 'actor',
124 indexes: [
125 {
126 fields: [ 'url' ],
127 unique: true
128 },
129 {
130 fields: [ 'preferredUsername', 'serverId' ],
131 unique: true,
132 where: {
133 serverId: {
134 [Op.ne]: null
135 }
136 }
137 },
138 {
139 fields: [ 'preferredUsername' ],
140 unique: true,
141 where: {
142 serverId: null
143 }
144 },
145 {
146 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
147 },
148 {
149 fields: [ 'sharedInboxUrl' ]
150 },
151 {
152 fields: [ 'serverId' ]
153 },
154 {
155 fields: [ 'avatarId' ]
156 },
157 {
158 fields: [ 'followersUrl' ]
159 }
160 ]
161})
162export class ActorModel extends Model {
163
164 @AllowNull(false)
165 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
166 type: ActivityPubActorType
167
168 @AllowNull(false)
169 @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
170 @Column
171 preferredUsername: string
172
173 @AllowNull(false)
174 @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
175 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
176 url: string
177
178 @AllowNull(true)
179 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
180 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
181 publicKey: string
182
183 @AllowNull(true)
184 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
185 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
186 privateKey: string
187
188 @AllowNull(false)
189 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
190 @Column
191 followersCount: number
192
193 @AllowNull(false)
194 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
195 @Column
196 followingCount: number
197
198 @AllowNull(false)
199 @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
200 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
201 inboxUrl: string
202
203 @AllowNull(true)
204 @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true))
205 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
206 outboxUrl: string
207
208 @AllowNull(true)
209 @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true))
210 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
211 sharedInboxUrl: string
212
213 @AllowNull(true)
214 @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true))
215 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
216 followersUrl: string
217
218 @AllowNull(true)
219 @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true))
220 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
221 followingUrl: string
222
223 @AllowNull(true)
224 @Column
225 remoteCreatedAt: Date
226
227 @CreatedAt
228 createdAt: Date
229
230 @UpdatedAt
231 updatedAt: Date
232
233 @ForeignKey(() => ActorImageModel)
234 @Column
235 avatarId: number
236
237 @ForeignKey(() => ActorImageModel)
238 @Column
239 bannerId: number
240
241 @BelongsTo(() => ActorImageModel, {
242 foreignKey: {
243 name: 'avatarId',
244 allowNull: true
245 },
246 as: 'Avatar',
247 onDelete: 'set null',
248 hooks: true
249 })
250 Avatar: ActorImageModel
251
252 @BelongsTo(() => ActorImageModel, {
253 foreignKey: {
254 name: 'bannerId',
255 allowNull: true
256 },
257 as: 'Banner',
258 onDelete: 'set null',
259 hooks: true
260 })
261 Banner: ActorImageModel
262
263 @HasMany(() => ActorFollowModel, {
264 foreignKey: {
265 name: 'actorId',
266 allowNull: false
267 },
268 as: 'ActorFollowings',
269 onDelete: 'cascade'
270 })
271 ActorFollowing: ActorFollowModel[]
272
273 @HasMany(() => ActorFollowModel, {
274 foreignKey: {
275 name: 'targetActorId',
276 allowNull: false
277 },
278 as: 'ActorFollowers',
279 onDelete: 'cascade'
280 })
281 ActorFollowers: ActorFollowModel[]
282
283 @ForeignKey(() => ServerModel)
284 @Column
285 serverId: number
286
287 @BelongsTo(() => ServerModel, {
288 foreignKey: {
289 allowNull: true
290 },
291 onDelete: 'cascade'
292 })
293 Server: ServerModel
294
295 @HasOne(() => AccountModel, {
296 foreignKey: {
297 allowNull: true
298 },
299 onDelete: 'cascade',
300 hooks: true
301 })
302 Account: AccountModel
303
304 @HasOne(() => VideoChannelModel, {
305 foreignKey: {
306 allowNull: true
307 },
308 onDelete: 'cascade',
309 hooks: true
310 })
311 VideoChannel: VideoChannelModel
312
313 static load (id: number): Promise<MActor> {
314 return ActorModel.unscoped().findByPk(id)
315 }
316
317 static loadFull (id: number): Promise<MActorFull> {
318 return ActorModel.scope(ScopeNames.FULL).findByPk(id)
319 }
320
321 static loadFromAccountByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
322 const query = {
323 include: [
324 {
325 attributes: [ 'id' ],
326 model: AccountModel.unscoped(),
327 required: true,
328 include: [
329 {
330 attributes: [ 'id' ],
331 model: VideoChannelModel.unscoped(),
332 required: true,
333 include: [
334 {
335 attributes: [ 'id' ],
336 model: VideoModel.unscoped(),
337 required: true,
338 where: {
339 id: videoId
340 }
341 }
342 ]
343 }
344 ]
345 }
346 ],
347 transaction
348 }
349
350 return ActorModel.unscoped().findOne(query)
351 }
352
353 static isActorUrlExist (url: string) {
354 const query = {
355 raw: true,
356 where: {
357 url
358 }
359 }
360
361 return ActorModel.unscoped().findOne(query)
362 .then(a => !!a)
363 }
364
365 static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Promise<MActorFull[]> {
366 const query = {
367 where: {
368 followersUrl: {
369 [Op.in]: followersUrls
370 }
371 },
372 transaction
373 }
374
375 return ActorModel.scope(ScopeNames.FULL).findAll(query)
376 }
377
378 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Promise<MActorFull> {
379 const fun = () => {
380 const query = {
381 where: {
382 preferredUsername,
383 serverId: null
384 },
385 transaction
386 }
387
388 return ActorModel.scope(ScopeNames.FULL)
389 .findOne(query)
390 }
391
392 return ModelCache.Instance.doCache({
393 cacheType: 'local-actor-name',
394 key: preferredUsername,
395 // The server actor never change, so we can easily cache it
396 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
397 fun
398 })
399 }
400
401 static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Promise<MActorUrl> {
402 const fun = () => {
403 const query = {
404 attributes: [ 'url' ],
405 where: {
406 preferredUsername,
407 serverId: null
408 },
409 transaction
410 }
411
412 return ActorModel.unscoped()
413 .findOne(query)
414 }
415
416 return ModelCache.Instance.doCache({
417 cacheType: 'local-actor-name',
418 key: preferredUsername,
419 // The server actor never change, so we can easily cache it
420 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
421 fun
422 })
423 }
424
425 static loadByNameAndHost (preferredUsername: string, host: string): Promise<MActorFull> {
426 const query = {
427 where: {
428 preferredUsername
429 },
430 include: [
431 {
432 model: ServerModel,
433 required: true,
434 where: {
435 host
436 }
437 }
438 ]
439 }
440
441 return ActorModel.scope(ScopeNames.FULL).findOne(query)
442 }
443
444 static loadByUrl (url: string, transaction?: Transaction): Promise<MActorAccountChannelId> {
445 const query = {
446 where: {
447 url
448 },
449 transaction,
450 include: [
451 {
452 attributes: [ 'id' ],
453 model: AccountModel.unscoped(),
454 required: false
455 },
456 {
457 attributes: [ 'id' ],
458 model: VideoChannelModel.unscoped(),
459 required: false
460 }
461 ]
462 }
463
464 return ActorModel.unscoped().findOne(query)
465 }
466
467 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Promise<MActorFull> {
468 const query = {
469 where: {
470 url
471 },
472 transaction
473 }
474
475 return ActorModel.scope(ScopeNames.FULL).findOne(query)
476 }
477
478 static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) {
479 const sanitizedOfId = parseInt(ofId + '', 10)
480 const where = { id: sanitizedOfId }
481
482 let columnToUpdate: string
483 let columnOfCount: string
484
485 if (type === 'followers') {
486 columnToUpdate = 'followersCount'
487 columnOfCount = 'targetActorId'
488 } else {
489 columnToUpdate = 'followingCount'
490 columnOfCount = 'actorId'
491 }
492
493 return ActorModel.update({
494 [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId})`)
495 }, { where, transaction })
496 }
497
498 static loadAccountActorByVideoId (videoId: number): Promise<MActor> {
499 const query = {
500 include: [
501 {
502 attributes: [ 'id' ],
503 model: AccountModel.unscoped(),
504 required: true,
505 include: [
506 {
507 attributes: [ 'id', 'accountId' ],
508 model: VideoChannelModel.unscoped(),
509 required: true,
510 include: [
511 {
512 attributes: [ 'id', 'channelId' ],
513 model: VideoModel.unscoped(),
514 where: {
515 id: videoId
516 }
517 }
518 ]
519 }
520 ]
521 }
522 ]
523 }
524
525 return ActorModel.unscoped().findOne(query)
526 }
527
528 getSharedInbox (this: MActorWithInboxes) {
529 return this.sharedInboxUrl || this.inboxUrl
530 }
531
532 toFormattedSummaryJSON (this: MActorSummaryFormattable) {
533 let avatar: ActorImage = null
534 if (this.Avatar) {
535 avatar = this.Avatar.toFormattedJSON()
536 }
537
538 return {
539 url: this.url,
540 name: this.preferredUsername,
541 host: this.getHost(),
542 avatar
543 }
544 }
545
546 toFormattedJSON (this: MActorFormattable) {
547 const base = this.toFormattedSummaryJSON()
548
549 let banner: ActorImage = null
550 if (this.Banner) {
551 banner = this.Banner.toFormattedJSON()
552 }
553
554 return Object.assign(base, {
555 id: this.id,
556 hostRedundancyAllowed: this.getRedundancyAllowed(),
557 followingCount: this.followingCount,
558 followersCount: this.followersCount,
559 banner,
560 createdAt: this.getCreatedAt()
561 })
562 }
563
564 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
565 let icon: ActivityIconObject
566 let image: ActivityIconObject
567
568 if (this.avatarId) {
569 const extension = extname(this.Avatar.filename)
570
571 icon = {
572 type: 'Image',
573 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
574 height: this.Avatar.height,
575 width: this.Avatar.width,
576 url: this.getAvatarUrl()
577 }
578 }
579
580 if (this.bannerId) {
581 const banner = (this as MActorAPChannel).Banner
582 const extension = extname(banner.filename)
583
584 image = {
585 type: 'Image',
586 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
587 height: banner.height,
588 width: banner.width,
589 url: this.getBannerUrl()
590 }
591 }
592
593 const json = {
594 type: this.type,
595 id: this.url,
596 following: this.getFollowingUrl(),
597 followers: this.getFollowersUrl(),
598 playlists: this.getPlaylistsUrl(),
599 inbox: this.inboxUrl,
600 outbox: this.outboxUrl,
601 preferredUsername: this.preferredUsername,
602 url: this.url,
603 name,
604 endpoints: {
605 sharedInbox: this.sharedInboxUrl
606 },
607 publicKey: {
608 id: this.getPublicKeyUrl(),
609 owner: this.url,
610 publicKeyPem: this.publicKey
611 },
612 published: this.getCreatedAt().toISOString(),
613 icon,
614 image
615 }
616
617 return activityPubContextify(json)
618 }
619
620 getFollowerSharedInboxUrls (t: Transaction) {
621 const query = {
622 attributes: [ 'sharedInboxUrl' ],
623 include: [
624 {
625 attribute: [],
626 model: ActorFollowModel.unscoped(),
627 required: true,
628 as: 'ActorFollowing',
629 where: {
630 state: 'accepted',
631 targetActorId: this.id
632 }
633 }
634 ],
635 transaction: t
636 }
637
638 return ActorModel.findAll(query)
639 .then(accounts => accounts.map(a => a.sharedInboxUrl))
640 }
641
642 getFollowingUrl () {
643 return this.url + '/following'
644 }
645
646 getFollowersUrl () {
647 return this.url + '/followers'
648 }
649
650 getPlaylistsUrl () {
651 return this.url + '/playlists'
652 }
653
654 getPublicKeyUrl () {
655 return this.url + '#main-key'
656 }
657
658 isOwned () {
659 return this.serverId === null
660 }
661
662 getWebfingerUrl (this: MActorServer) {
663 return 'acct:' + this.preferredUsername + '@' + this.getHost()
664 }
665
666 getIdentifier () {
667 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
668 }
669
670 getHost (this: MActorHost) {
671 return this.Server ? this.Server.host : WEBSERVER.HOST
672 }
673
674 getRedundancyAllowed () {
675 return this.Server ? this.Server.redundancyAllowed : false
676 }
677
678 getAvatarUrl () {
679 if (!this.avatarId) return undefined
680
681 return WEBSERVER.URL + this.Avatar.getStaticPath()
682 }
683
684 getBannerUrl () {
685 if (!this.bannerId) return undefined
686
687 return WEBSERVER.URL + this.Banner.getStaticPath()
688 }
689
690 isOutdated () {
691 if (this.isOwned()) return false
692
693 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
694 }
695
696 getCreatedAt (this: MActorAPChannel | MActorAPAccount | MActorFormattable) {
697 return this.remoteCreatedAt || this.createdAt
698 }
699}