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.ts662
-rw-r--r--server/models/actor/actor-image.ts171
-rw-r--r--server/models/actor/actor.ts686
-rw-r--r--server/models/actor/sql/instance-list-followers-query-builder.ts69
-rw-r--r--server/models/actor/sql/instance-list-following-query-builder.ts69
-rw-r--r--server/models/actor/sql/shared/actor-follow-table-attributes.ts28
-rw-r--r--server/models/actor/sql/shared/instance-list-follows-query-builder.ts97
7 files changed, 0 insertions, 1782 deletions
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
deleted file mode 100644
index 71ce9fa6f..000000000
--- a/server/models/actor/actor-follow.ts
+++ /dev/null
@@ -1,662 +0,0 @@
1import { difference } from 'lodash'
2import { Attributes, FindOptions, Includeable, IncludeOptions, Op, QueryTypes, Transaction, WhereAttributeHash } 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 { afterCommitIfTransaction } from '@server/helpers/database-utils'
23import { getServerActor } from '@server/models/application/application'
24import {
25 MActor,
26 MActorFollowActors,
27 MActorFollowActorsDefault,
28 MActorFollowActorsDefaultSubscription,
29 MActorFollowFollowingHost,
30 MActorFollowFormattable,
31 MActorFollowSubscriptions
32} from '@server/types/models'
33import { AttributesOnly } from '@shared/typescript-utils'
34import { FollowState } from '../../../shared/models/actors'
35import { ActorFollow } from '../../../shared/models/actors/follow.model'
36import { logger } from '../../helpers/logger'
37import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants'
38import { AccountModel } from '../account/account'
39import { ServerModel } from '../server/server'
40import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
41import { doesExist } from '../shared/query'
42import { VideoChannelModel } from '../video/video-channel'
43import { ActorModel, unusedActorAttributesForAPI } from './actor'
44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
45import { InstanceListFollowingQueryBuilder, ListFollowingOptions } from './sql/instance-list-following-query-builder'
46
47@Table({
48 tableName: 'actorFollow',
49 indexes: [
50 {
51 fields: [ 'actorId' ]
52 },
53 {
54 fields: [ 'targetActorId' ]
55 },
56 {
57 fields: [ 'actorId', 'targetActorId' ],
58 unique: true
59 },
60 {
61 fields: [ 'score' ]
62 },
63 {
64 fields: [ 'url' ],
65 unique: true
66 }
67 ]
68})
69export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowModel>>> {
70
71 @AllowNull(false)
72 @Column(DataType.ENUM(...Object.values(FOLLOW_STATES)))
73 state: FollowState
74
75 @AllowNull(false)
76 @Default(ACTOR_FOLLOW_SCORE.BASE)
77 @IsInt
78 @Max(ACTOR_FOLLOW_SCORE.MAX)
79 @Column
80 score: number
81
82 // Allow null because we added this column in PeerTube v3, and don't want to generate fake URLs of remote follows
83 @AllowNull(true)
84 @Is('ActorFollowUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
85 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
86 url: string
87
88 @CreatedAt
89 createdAt: Date
90
91 @UpdatedAt
92 updatedAt: Date
93
94 @ForeignKey(() => ActorModel)
95 @Column
96 actorId: number
97
98 @BelongsTo(() => ActorModel, {
99 foreignKey: {
100 name: 'actorId',
101 allowNull: false
102 },
103 as: 'ActorFollower',
104 onDelete: 'CASCADE'
105 })
106 ActorFollower: ActorModel
107
108 @ForeignKey(() => ActorModel)
109 @Column
110 targetActorId: number
111
112 @BelongsTo(() => ActorModel, {
113 foreignKey: {
114 name: 'targetActorId',
115 allowNull: false
116 },
117 as: 'ActorFollowing',
118 onDelete: 'CASCADE'
119 })
120 ActorFollowing: ActorModel
121
122 @AfterCreate
123 @AfterUpdate
124 static incrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
125 return afterCommitIfTransaction(options.transaction, () => {
126 return Promise.all([
127 ActorModel.rebuildFollowsCount(instance.actorId, 'following'),
128 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers')
129 ])
130 })
131 }
132
133 @AfterDestroy
134 static decrementFollowerAndFollowingCount (instance: ActorFollowModel, options: any) {
135 return afterCommitIfTransaction(options.transaction, () => {
136 return Promise.all([
137 ActorModel.rebuildFollowsCount(instance.actorId, 'following'),
138 ActorModel.rebuildFollowsCount(instance.targetActorId, 'followers')
139 ])
140 })
141 }
142
143 // ---------------------------------------------------------------------------
144
145 static getSQLAttributes (tableName: string, aliasPrefix = '') {
146 return buildSQLAttributes({
147 model: this,
148 tableName,
149 aliasPrefix
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
155 /*
156 * @deprecated Use `findOrCreateCustom` instead
157 */
158 static findOrCreate (): any {
159 throw new Error('Must not be called')
160 }
161
162 // findOrCreate has issues with actor follow hooks
163 static async findOrCreateCustom (options: {
164 byActor: MActor
165 targetActor: MActor
166 activityId: string
167 state: FollowState
168 transaction: Transaction
169 }): Promise<[ MActorFollowActors, boolean ]> {
170 const { byActor, targetActor, activityId, state, transaction } = options
171
172 let created = false
173 let actorFollow: MActorFollowActors = await ActorFollowModel.loadByActorAndTarget(byActor.id, targetActor.id, transaction)
174
175 if (!actorFollow) {
176 created = true
177
178 actorFollow = await ActorFollowModel.create({
179 actorId: byActor.id,
180 targetActorId: targetActor.id,
181 url: activityId,
182
183 state
184 }, { transaction })
185
186 actorFollow.ActorFollowing = targetActor
187 actorFollow.ActorFollower = byActor
188 }
189
190 return [ actorFollow, created ]
191 }
192
193 static removeFollowsOf (actorId: number, t?: Transaction) {
194 const query = {
195 where: {
196 [Op.or]: [
197 {
198 actorId
199 },
200 {
201 targetActorId: actorId
202 }
203 ]
204 },
205 transaction: t
206 }
207
208 return ActorFollowModel.destroy(query)
209 }
210
211 // Remove actor follows with a score of 0 (too many requests where they were unreachable)
212 static async removeBadActorFollows () {
213 const actorFollows = await ActorFollowModel.listBadActorFollows()
214
215 const actorFollowsRemovePromises = actorFollows.map(actorFollow => actorFollow.destroy())
216 await Promise.all(actorFollowsRemovePromises)
217
218 const numberOfActorFollowsRemoved = actorFollows.length
219
220 if (numberOfActorFollowsRemoved) logger.info('Removed bad %d actor follows.', numberOfActorFollowsRemoved)
221 }
222
223 static isFollowedBy (actorId: number, followerActorId: number) {
224 const query = `SELECT 1 FROM "actorFollow" ` +
225 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
226 `LIMIT 1`
227
228 return doesExist(this.sequelize, query, { actorId, followerActorId })
229 }
230
231 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
232 const query = {
233 where: {
234 actorId,
235 targetActorId
236 },
237 include: [
238 {
239 model: ActorModel,
240 required: true,
241 as: 'ActorFollower'
242 },
243 {
244 model: ActorModel,
245 required: true,
246 as: 'ActorFollowing'
247 }
248 ],
249 transaction: t
250 }
251
252 return ActorFollowModel.findOne(query)
253 }
254
255 static loadByActorAndTargetNameAndHostForAPI (options: {
256 actorId: number
257 targetName: string
258 targetHost: string
259 state?: FollowState
260 transaction?: Transaction
261 }): Promise<MActorFollowActorsDefaultSubscription> {
262 const { actorId, targetHost, targetName, state, transaction } = options
263
264 const actorFollowingPartInclude: IncludeOptions = {
265 model: ActorModel,
266 required: true,
267 as: 'ActorFollowing',
268 where: ActorModel.wherePreferredUsername(targetName),
269 include: [
270 {
271 model: VideoChannelModel.unscoped(),
272 required: false
273 }
274 ]
275 }
276
277 if (targetHost === null) {
278 actorFollowingPartInclude.where['serverId'] = null
279 } else {
280 actorFollowingPartInclude.include.push({
281 model: ServerModel,
282 required: true,
283 where: {
284 host: targetHost
285 }
286 })
287 }
288
289 const where: WhereAttributeHash<Attributes<ActorFollowModel>> = { actorId }
290 if (state) where.state = state
291
292 const query: FindOptions<Attributes<ActorFollowModel>> = {
293 where,
294 include: [
295 actorFollowingPartInclude,
296 {
297 model: ActorModel,
298 required: true,
299 as: 'ActorFollower'
300 }
301 ],
302 transaction
303 }
304
305 return ActorFollowModel.findOne(query)
306 }
307
308 static listSubscriptionsOf (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
309 const whereTab = targets
310 .map(t => {
311 if (t.host) {
312 return {
313 [Op.and]: [
314 ActorModel.wherePreferredUsername(t.name),
315 { $host$: t.host }
316 ]
317 }
318 }
319
320 return {
321 [Op.and]: [
322 ActorModel.wherePreferredUsername(t.name),
323 { $serverId$: null }
324 ]
325 }
326 })
327
328 const query = {
329 attributes: [ 'id' ],
330 where: {
331 [Op.and]: [
332 {
333 [Op.or]: whereTab
334 },
335 {
336 state: 'accepted',
337 actorId
338 }
339 ]
340 },
341 include: [
342 {
343 attributes: [ 'preferredUsername' ],
344 model: ActorModel.unscoped(),
345 required: true,
346 as: 'ActorFollowing',
347 include: [
348 {
349 attributes: [ 'host' ],
350 model: ServerModel.unscoped(),
351 required: false
352 }
353 ]
354 }
355 ]
356 }
357
358 return ActorFollowModel.findAll(query)
359 }
360
361 static listInstanceFollowingForApi (options: ListFollowingOptions) {
362 return Promise.all([
363 new InstanceListFollowingQueryBuilder(this.sequelize, options).countFollowing(),
364 new InstanceListFollowingQueryBuilder(this.sequelize, options).listFollowing()
365 ]).then(([ total, data ]) => ({ total, data }))
366 }
367
368 static listFollowersForApi (options: ListFollowersOptions) {
369 return Promise.all([
370 new InstanceListFollowersQueryBuilder(this.sequelize, options).countFollowers(),
371 new InstanceListFollowersQueryBuilder(this.sequelize, options).listFollowers()
372 ]).then(([ total, data ]) => ({ total, data }))
373 }
374
375 static listSubscriptionsForApi (options: {
376 actorId: number
377 start: number
378 count: number
379 sort: string
380 search?: string
381 }) {
382 const { actorId, start, count, sort } = options
383 const where = {
384 state: 'accepted',
385 actorId
386 }
387
388 if (options.search) {
389 Object.assign(where, {
390 [Op.or]: [
391 searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
392 searchAttribute(options.search, '$ActorFollowing.VideoChannel.name$')
393 ]
394 })
395 }
396
397 const getQuery = (forCount: boolean) => {
398 let channelInclude: Includeable[] = []
399
400 if (forCount !== true) {
401 channelInclude = [
402 {
403 attributes: {
404 exclude: unusedActorAttributesForAPI
405 },
406 model: ActorModel,
407 required: true
408 },
409 {
410 model: AccountModel.unscoped(),
411 required: true,
412 include: [
413 {
414 attributes: {
415 exclude: unusedActorAttributesForAPI
416 },
417 model: ActorModel,
418 required: true
419 }
420 ]
421 }
422 ]
423 }
424
425 return {
426 attributes: forCount === true
427 ? []
428 : SORTABLE_COLUMNS.USER_SUBSCRIPTIONS,
429 distinct: true,
430 offset: start,
431 limit: count,
432 order: getSort(sort),
433 where,
434 include: [
435 {
436 attributes: [ 'id' ],
437 model: ActorModel.unscoped(),
438 as: 'ActorFollowing',
439 required: true,
440 include: [
441 {
442 model: VideoChannelModel.unscoped(),
443 required: true,
444 include: channelInclude
445 }
446 ]
447 }
448 ]
449 }
450 }
451
452 return Promise.all([
453 ActorFollowModel.count(getQuery(true)),
454 ActorFollowModel.findAll<MActorFollowSubscriptions>(getQuery(false))
455 ]).then(([ total, rows ]) => ({
456 total,
457 data: rows.map(r => r.ActorFollowing.VideoChannel)
458 }))
459 }
460
461 static async keepUnfollowedInstance (hosts: string[]) {
462 const followerId = (await getServerActor()).id
463
464 const query = {
465 attributes: [ 'id' ],
466 where: {
467 actorId: followerId
468 },
469 include: [
470 {
471 attributes: [ 'id' ],
472 model: ActorModel.unscoped(),
473 required: true,
474 as: 'ActorFollowing',
475 where: {
476 preferredUsername: SERVER_ACTOR_NAME
477 },
478 include: [
479 {
480 attributes: [ 'host' ],
481 model: ServerModel.unscoped(),
482 required: true,
483 where: {
484 host: {
485 [Op.in]: hosts
486 }
487 }
488 }
489 ]
490 }
491 ]
492 }
493
494 const res = await ActorFollowModel.findAll(query)
495 const followedHosts = res.map(row => row.ActorFollowing.Server.host)
496
497 return difference(hosts, followedHosts)
498 }
499
500 static listAcceptedFollowerUrlsForAP (actorIds: number[], t: Transaction, start?: number, count?: number) {
501 return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count)
502 }
503
504 static listAcceptedFollowerSharedInboxUrls (actorIds: number[], t: Transaction) {
505 return ActorFollowModel.createListAcceptedFollowForApiQuery(
506 'followers',
507 actorIds,
508 t,
509 undefined,
510 undefined,
511 'sharedInboxUrl',
512 true
513 )
514 }
515
516 static listAcceptedFollowingUrlsForApi (actorIds: number[], t: Transaction, start?: number, count?: number) {
517 return ActorFollowModel.createListAcceptedFollowForApiQuery('following', actorIds, t, start, count)
518 }
519
520 static async getStats () {
521 const serverActor = await getServerActor()
522
523 const totalInstanceFollowing = await ActorFollowModel.count({
524 where: {
525 actorId: serverActor.id,
526 state: 'accepted'
527 }
528 })
529
530 const totalInstanceFollowers = await ActorFollowModel.count({
531 where: {
532 targetActorId: serverActor.id,
533 state: 'accepted'
534 }
535 })
536
537 return {
538 totalInstanceFollowing,
539 totalInstanceFollowers
540 }
541 }
542
543 static updateScore (inboxUrl: string, value: number, t?: Transaction) {
544 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
545 'WHERE id IN (' +
546 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
547 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
548 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
549 ')'
550
551 const options = {
552 type: QueryTypes.BULKUPDATE,
553 transaction: t
554 }
555
556 return ActorFollowModel.sequelize.query(query, options)
557 }
558
559 static async updateScoreByFollowingServers (serverIds: number[], value: number, t?: Transaction) {
560 if (serverIds.length === 0) return
561
562 const me = await getServerActor()
563 const serverIdsString = createSafeIn(ActorFollowModel.sequelize, serverIds)
564
565 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
566 'WHERE id IN (' +
567 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
568 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."targetActorId" ' +
569 `WHERE "actorFollow"."actorId" = ${me.Account.actorId} ` + // I'm the follower
570 `AND "actor"."serverId" IN (${serverIdsString})` + // Criteria on followings
571 ')'
572
573 const options = {
574 type: QueryTypes.BULKUPDATE,
575 transaction: t
576 }
577
578 return ActorFollowModel.sequelize.query(query, options)
579 }
580
581 private static async createListAcceptedFollowForApiQuery (
582 type: 'followers' | 'following',
583 actorIds: number[],
584 t: Transaction,
585 start?: number,
586 count?: number,
587 columnUrl = 'url',
588 distinct = false
589 ) {
590 let firstJoin: string
591 let secondJoin: string
592
593 if (type === 'followers') {
594 firstJoin = 'targetActorId'
595 secondJoin = 'actorId'
596 } else {
597 firstJoin = 'actorId'
598 secondJoin = 'targetActorId'
599 }
600
601 const selections: string[] = []
602 if (distinct === true) selections.push(`DISTINCT("Follows"."${columnUrl}") AS "selectionUrl"`)
603 else selections.push(`"Follows"."${columnUrl}" AS "selectionUrl"`)
604
605 selections.push('COUNT(*) AS "total"')
606
607 const tasks: Promise<any>[] = []
608
609 for (const selection of selections) {
610 let query = 'SELECT ' + selection + ' FROM "actor" ' +
611 'INNER JOIN "actorFollow" ON "actorFollow"."' + firstJoin + '" = "actor"."id" ' +
612 'INNER JOIN "actor" AS "Follows" ON "actorFollow"."' + secondJoin + '" = "Follows"."id" ' +
613 `WHERE "actor"."id" = ANY ($actorIds) AND "actorFollow"."state" = 'accepted' AND "Follows"."${columnUrl}" IS NOT NULL `
614
615 if (count !== undefined) query += 'LIMIT ' + count
616 if (start !== undefined) query += ' OFFSET ' + start
617
618 const options = {
619 bind: { actorIds },
620 type: QueryTypes.SELECT,
621 transaction: t
622 }
623 tasks.push(ActorFollowModel.sequelize.query(query, options))
624 }
625
626 const [ followers, [ dataTotal ] ] = await Promise.all(tasks)
627 const urls: string[] = followers.map(f => f.selectionUrl)
628
629 return {
630 data: urls,
631 total: dataTotal ? parseInt(dataTotal.total, 10) : 0
632 }
633 }
634
635 private static listBadActorFollows () {
636 const query = {
637 where: {
638 score: {
639 [Op.lte]: 0
640 }
641 },
642 logging: false
643 }
644
645 return ActorFollowModel.findAll(query)
646 }
647
648 toFormattedJSON (this: MActorFollowFormattable): ActorFollow {
649 const follower = this.ActorFollower.toFormattedJSON()
650 const following = this.ActorFollowing.toFormattedJSON()
651
652 return {
653 id: this.id,
654 follower,
655 following,
656 score: this.score,
657 state: this.state,
658 createdAt: this.createdAt,
659 updatedAt: this.updatedAt
660 }
661 }
662}
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
deleted file mode 100644
index 51085a16d..000000000
--- a/server/models/actor/actor-image.ts
+++ /dev/null
@@ -1,171 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import {
4 AfterDestroy,
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 Default,
10 ForeignKey,
11 Is,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { MActorImage, MActorImageFormattable } from '@server/types/models'
17import { getLowercaseExtension } from '@shared/core-utils'
18import { ActivityIconObject, ActorImageType } from '@shared/models'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { ActorImage } from '../../../shared/models/actors/actor-image.model'
21import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config'
24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
25import { buildSQLAttributes, throwIfNotValid } from '../shared'
26import { ActorModel } from './actor'
27
28@Table({
29 tableName: 'actorImage',
30 indexes: [
31 {
32 fields: [ 'filename' ],
33 unique: true
34 },
35 {
36 fields: [ 'actorId', 'type', 'width' ],
37 unique: true
38 }
39 ]
40})
41export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageModel>>> {
42
43 @AllowNull(false)
44 @Column
45 filename: string
46
47 @AllowNull(true)
48 @Default(null)
49 @Column
50 height: number
51
52 @AllowNull(true)
53 @Default(null)
54 @Column
55 width: number
56
57 @AllowNull(true)
58 @Is('ActorImageFileUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'fileUrl', true))
59 @Column
60 fileUrl: string
61
62 @AllowNull(false)
63 @Column
64 onDisk: boolean
65
66 @AllowNull(false)
67 @Column
68 type: ActorImageType
69
70 @CreatedAt
71 createdAt: Date
72
73 @UpdatedAt
74 updatedAt: Date
75
76 @ForeignKey(() => ActorModel)
77 @Column
78 actorId: number
79
80 @BelongsTo(() => ActorModel, {
81 foreignKey: {
82 allowNull: false
83 },
84 onDelete: 'CASCADE'
85 })
86 Actor: ActorModel
87
88 @AfterDestroy
89 static removeFilesAndSendDelete (instance: ActorImageModel) {
90 logger.info('Removing actor image file %s.', instance.filename)
91
92 // Don't block the transaction
93 instance.removeImage()
94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
95 }
96
97 // ---------------------------------------------------------------------------
98
99 static getSQLAttributes (tableName: string, aliasPrefix = '') {
100 return buildSQLAttributes({
101 model: this,
102 tableName,
103 aliasPrefix
104 })
105 }
106
107 // ---------------------------------------------------------------------------
108
109 static loadByName (filename: string) {
110 const query = {
111 where: {
112 filename
113 }
114 }
115
116 return ActorImageModel.findOne(query)
117 }
118
119 static getImageUrl (image: MActorImage) {
120 if (!image) return undefined
121
122 return WEBSERVER.URL + image.getStaticPath()
123 }
124
125 toFormattedJSON (this: MActorImageFormattable): ActorImage {
126 return {
127 width: this.width,
128 path: this.getStaticPath(),
129 createdAt: this.createdAt,
130 updatedAt: this.updatedAt
131 }
132 }
133
134 toActivityPubObject (): ActivityIconObject {
135 const extension = getLowercaseExtension(this.filename)
136
137 return {
138 type: 'Image',
139 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
140 height: this.height,
141 width: this.width,
142 url: ActorImageModel.getImageUrl(this)
143 }
144 }
145
146 getStaticPath () {
147 switch (this.type) {
148 case ActorImageType.AVATAR:
149 return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
150
151 case ActorImageType.BANNER:
152 return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
153
154 default:
155 throw new Error('Unknown actor image type: ' + this.type)
156 }
157 }
158
159 getPath () {
160 return join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, this.filename)
161 }
162
163 removeImage () {
164 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, this.filename)
165 return remove(imagePath)
166 }
167
168 isOwned () {
169 return !this.fileUrl
170 }
171}
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
deleted file mode 100644
index e2e85f3d6..000000000
--- a/server/models/actor/actor.ts
+++ /dev/null
@@ -1,686 +0,0 @@
1import { col, fn, literal, Op, QueryTypes, Transaction, where } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 DefaultScope,
9 ForeignKey,
10 HasMany,
11 HasOne,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { activityPubContextify } from '@server/lib/activitypub/context'
19import { getBiggestActorImage } from '@server/lib/actor-image'
20import { ModelCache } from '@server/models/shared/model-cache'
21import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils'
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 MActorFollowersUrl,
46 MActorFormattable,
47 MActorFull,
48 MActorHost,
49 MActorHostOnly,
50 MActorId,
51 MActorSummaryFormattable,
52 MActorUrl,
53 MActorWithInboxes
54} from '../../types/models'
55import { AccountModel } from '../account/account'
56import { getServerActor } from '../application/application'
57import { ServerModel } from '../server/server'
58import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
59import { VideoModel } from '../video/video'
60import { VideoChannelModel } from '../video/video-channel'
61import { ActorFollowModel } from './actor-follow'
62import { ActorImageModel } from './actor-image'
63
64enum ScopeNames {
65 FULL = 'FULL'
66}
67
68export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
69 'publicKey',
70 'privateKey',
71 'inboxUrl',
72 'outboxUrl',
73 'sharedInboxUrl',
74 'followersUrl',
75 'followingUrl'
76]
77
78@DefaultScope(() => ({
79 include: [
80 {
81 model: ServerModel,
82 required: false
83 },
84 {
85 model: ActorImageModel,
86 as: 'Avatars',
87 required: false
88 }
89 ]
90}))
91@Scopes(() => ({
92 [ScopeNames.FULL]: {
93 include: [
94 {
95 model: AccountModel.unscoped(),
96 required: false
97 },
98 {
99 model: VideoChannelModel.unscoped(),
100 required: false,
101 include: [
102 {
103 model: AccountModel,
104 required: true
105 }
106 ]
107 },
108 {
109 model: ServerModel,
110 required: false
111 },
112 {
113 model: ActorImageModel,
114 as: 'Avatars',
115 required: false
116 },
117 {
118 model: ActorImageModel,
119 as: 'Banners',
120 required: false
121 }
122 ]
123 }
124}))
125@Table({
126 tableName: 'actor',
127 indexes: [
128 {
129 fields: [ 'url' ],
130 unique: true
131 },
132 {
133 fields: [ fn('lower', col('preferredUsername')), 'serverId' ],
134 name: 'actor_preferred_username_lower_server_id',
135 unique: true,
136 where: {
137 serverId: {
138 [Op.ne]: null
139 }
140 }
141 },
142 {
143 fields: [ fn('lower', col('preferredUsername')) ],
144 name: 'actor_preferred_username_lower',
145 unique: true,
146 where: {
147 serverId: null
148 }
149 },
150 {
151 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
152 },
153 {
154 fields: [ 'sharedInboxUrl' ]
155 },
156 {
157 fields: [ 'serverId' ]
158 },
159 {
160 fields: [ 'followersUrl' ]
161 }
162 ]
163})
164export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
165
166 @AllowNull(false)
167 @Column(DataType.ENUM(...Object.values(ACTIVITY_PUB_ACTOR_TYPES)))
168 type: ActivityPubActorType
169
170 @AllowNull(false)
171 @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
172 @Column
173 preferredUsername: string
174
175 @AllowNull(false)
176 @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
177 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
178 url: string
179
180 @AllowNull(true)
181 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
182 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
183 publicKey: string
184
185 @AllowNull(true)
186 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
187 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
188 privateKey: string
189
190 @AllowNull(false)
191 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
192 @Column
193 followersCount: number
194
195 @AllowNull(false)
196 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
197 @Column
198 followingCount: number
199
200 @AllowNull(false)
201 @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
202 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
203 inboxUrl: string
204
205 @AllowNull(true)
206 @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true))
207 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
208 outboxUrl: string
209
210 @AllowNull(true)
211 @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true))
212 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
213 sharedInboxUrl: string
214
215 @AllowNull(true)
216 @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true))
217 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
218 followersUrl: string
219
220 @AllowNull(true)
221 @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true))
222 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
223 followingUrl: string
224
225 @AllowNull(true)
226 @Column
227 remoteCreatedAt: Date
228
229 @CreatedAt
230 createdAt: Date
231
232 @UpdatedAt
233 updatedAt: Date
234
235 @HasMany(() => ActorImageModel, {
236 as: 'Avatars',
237 onDelete: 'cascade',
238 hooks: true,
239 foreignKey: {
240 allowNull: false
241 },
242 scope: {
243 type: ActorImageType.AVATAR
244 }
245 })
246 Avatars: ActorImageModel[]
247
248 @HasMany(() => ActorImageModel, {
249 as: 'Banners',
250 onDelete: 'cascade',
251 hooks: true,
252 foreignKey: {
253 allowNull: false
254 },
255 scope: {
256 type: ActorImageType.BANNER
257 }
258 })
259 Banners: ActorImageModel[]
260
261 @HasMany(() => ActorFollowModel, {
262 foreignKey: {
263 name: 'actorId',
264 allowNull: false
265 },
266 as: 'ActorFollowings',
267 onDelete: 'cascade'
268 })
269 ActorFollowing: ActorFollowModel[]
270
271 @HasMany(() => ActorFollowModel, {
272 foreignKey: {
273 name: 'targetActorId',
274 allowNull: false
275 },
276 as: 'ActorFollowers',
277 onDelete: 'cascade'
278 })
279 ActorFollowers: ActorFollowModel[]
280
281 @ForeignKey(() => ServerModel)
282 @Column
283 serverId: number
284
285 @BelongsTo(() => ServerModel, {
286 foreignKey: {
287 allowNull: true
288 },
289 onDelete: 'cascade'
290 })
291 Server: ServerModel
292
293 @HasOne(() => AccountModel, {
294 foreignKey: {
295 allowNull: true
296 },
297 onDelete: 'cascade',
298 hooks: true
299 })
300 Account: AccountModel
301
302 @HasOne(() => VideoChannelModel, {
303 foreignKey: {
304 allowNull: true
305 },
306 onDelete: 'cascade',
307 hooks: true
308 })
309 VideoChannel: VideoChannelModel
310
311 // ---------------------------------------------------------------------------
312
313 static getSQLAttributes (tableName: string, aliasPrefix = '') {
314 return buildSQLAttributes({
315 model: this,
316 tableName,
317 aliasPrefix
318 })
319 }
320
321 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
322 return buildSQLAttributes({
323 model: this,
324 tableName,
325 aliasPrefix,
326 excludeAttributes: unusedActorAttributesForAPI
327 })
328 }
329
330 // ---------------------------------------------------------------------------
331
332 static wherePreferredUsername (preferredUsername: string, colName = 'preferredUsername') {
333 return where(fn('lower', col(colName)), preferredUsername.toLowerCase())
334 }
335
336 // ---------------------------------------------------------------------------
337
338 static async load (id: number): Promise<MActor> {
339 const actorServer = await getServerActor()
340 if (id === actorServer.id) return actorServer
341
342 return ActorModel.unscoped().findByPk(id)
343 }
344
345 static loadFull (id: number): Promise<MActorFull> {
346 return ActorModel.scope(ScopeNames.FULL).findByPk(id)
347 }
348
349 static loadAccountActorFollowerUrlByVideoId (videoId: number, transaction: Transaction) {
350 const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
351 `FROM "actor" ` +
352 `INNER JOIN "account" ON "actor"."id" = "account"."actorId" ` +
353 `INNER JOIN "videoChannel" ON "videoChannel"."accountId" = "account"."id" ` +
354 `INNER JOIN "video" ON "video"."channelId" = "videoChannel"."id" AND "video"."id" = :videoId`
355
356 const options = {
357 type: QueryTypes.SELECT as QueryTypes.SELECT,
358 replacements: { videoId },
359 plain: true as true,
360 transaction
361 }
362
363 return ActorModel.sequelize.query<MActorId & MActorFollowersUrl>(query, options)
364 }
365
366 static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Promise<MActorFull[]> {
367 const query = {
368 where: {
369 followersUrl: {
370 [Op.in]: followersUrls
371 }
372 },
373 transaction
374 }
375
376 return ActorModel.scope(ScopeNames.FULL).findAll(query)
377 }
378
379 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Promise<MActorFull> {
380 const fun = () => {
381 const query = {
382 where: {
383 [Op.and]: [
384 this.wherePreferredUsername(preferredUsername, '"ActorModel"."preferredUsername"'),
385 {
386 serverId: null
387 }
388 ]
389 },
390 transaction
391 }
392
393 return ActorModel.scope(ScopeNames.FULL).findOne(query)
394 }
395
396 return ModelCache.Instance.doCache({
397 cacheType: 'local-actor-name',
398 key: preferredUsername,
399 // The server actor never change, so we can easily cache it
400 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
401 fun
402 })
403 }
404
405 static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Promise<MActorUrl> {
406 const fun = () => {
407 const query = {
408 attributes: [ 'url' ],
409 where: {
410 [Op.and]: [
411 this.wherePreferredUsername(preferredUsername),
412 {
413 serverId: null
414 }
415 ]
416 },
417 transaction
418 }
419
420 return ActorModel.unscoped().findOne(query)
421 }
422
423 return ModelCache.Instance.doCache({
424 cacheType: 'local-actor-url',
425 key: preferredUsername,
426 // The server actor never change, so we can easily cache it
427 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
428 fun
429 })
430 }
431
432 static loadByNameAndHost (preferredUsername: string, host: string): Promise<MActorFull> {
433 const query = {
434 where: this.wherePreferredUsername(preferredUsername, '"ActorModel"."preferredUsername"'),
435 include: [
436 {
437 model: ServerModel,
438 required: true,
439 where: {
440 host
441 }
442 }
443 ]
444 }
445
446 return ActorModel.scope(ScopeNames.FULL).findOne(query)
447 }
448
449 static loadByUrl (url: string, transaction?: Transaction): Promise<MActorAccountChannelId> {
450 const query = {
451 where: {
452 url
453 },
454 transaction,
455 include: [
456 {
457 attributes: [ 'id' ],
458 model: AccountModel.unscoped(),
459 required: false
460 },
461 {
462 attributes: [ 'id' ],
463 model: VideoChannelModel.unscoped(),
464 required: false
465 }
466 ]
467 }
468
469 return ActorModel.unscoped().findOne(query)
470 }
471
472 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Promise<MActorFull> {
473 const query = {
474 where: {
475 url
476 },
477 transaction
478 }
479
480 return ActorModel.scope(ScopeNames.FULL).findOne(query)
481 }
482
483 static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) {
484 const sanitizedOfId = forceNumber(ofId)
485 const where = { id: sanitizedOfId }
486
487 let columnToUpdate: string
488 let columnOfCount: string
489
490 if (type === 'followers') {
491 columnToUpdate = 'followersCount'
492 columnOfCount = 'targetActorId'
493 } else {
494 columnToUpdate = 'followingCount'
495 columnOfCount = 'actorId'
496 }
497
498 return ActorModel.update({
499 [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId} AND "state" = 'accepted')`)
500 }, { where, transaction })
501 }
502
503 static loadAccountActorByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
504 const query = {
505 include: [
506 {
507 attributes: [ 'id' ],
508 model: AccountModel.unscoped(),
509 required: true,
510 include: [
511 {
512 attributes: [ 'id', 'accountId' ],
513 model: VideoChannelModel.unscoped(),
514 required: true,
515 include: [
516 {
517 attributes: [ 'id', 'channelId' ],
518 model: VideoModel.unscoped(),
519 where: {
520 id: videoId
521 }
522 }
523 ]
524 }
525 ]
526 }
527 ],
528 transaction
529 }
530
531 return ActorModel.unscoped().findOne(query)
532 }
533
534 getSharedInbox (this: MActorWithInboxes) {
535 return this.sharedInboxUrl || this.inboxUrl
536 }
537
538 toFormattedSummaryJSON (this: MActorSummaryFormattable) {
539 return {
540 url: this.url,
541 name: this.preferredUsername,
542 host: this.getHost(),
543 avatars: (this.Avatars || []).map(a => a.toFormattedJSON())
544 }
545 }
546
547 toFormattedJSON (this: MActorFormattable) {
548 return {
549 ...this.toFormattedSummaryJSON(),
550
551 id: this.id,
552 hostRedundancyAllowed: this.getRedundancyAllowed(),
553 followingCount: this.followingCount,
554 followersCount: this.followersCount,
555 createdAt: this.getCreatedAt(),
556
557 banners: (this.Banners || []).map(b => b.toFormattedJSON())
558 }
559 }
560
561 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
562 let icon: ActivityIconObject[]
563 let image: ActivityIconObject
564
565 if (this.hasImage(ActorImageType.AVATAR)) {
566 icon = this.Avatars.map(a => a.toActivityPubObject())
567 }
568
569 if (this.hasImage(ActorImageType.BANNER)) {
570 const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
571 const extension = getLowercaseExtension(banner.filename)
572
573 image = {
574 type: 'Image',
575 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
576 height: banner.height,
577 width: banner.width,
578 url: ActorImageModel.getImageUrl(banner)
579 }
580 }
581
582 const json = {
583 type: this.type,
584 id: this.url,
585 following: this.getFollowingUrl(),
586 followers: this.getFollowersUrl(),
587 playlists: this.getPlaylistsUrl(),
588 inbox: this.inboxUrl,
589 outbox: this.outboxUrl,
590 preferredUsername: this.preferredUsername,
591 url: this.url,
592 name,
593 endpoints: {
594 sharedInbox: this.sharedInboxUrl
595 },
596 publicKey: {
597 id: this.getPublicKeyUrl(),
598 owner: this.url,
599 publicKeyPem: this.publicKey
600 },
601 published: this.getCreatedAt().toISOString(),
602
603 icon,
604
605 image
606 }
607
608 return activityPubContextify(json, 'Actor')
609 }
610
611 getFollowerSharedInboxUrls (t: Transaction) {
612 const query = {
613 attributes: [ 'sharedInboxUrl' ],
614 include: [
615 {
616 attribute: [],
617 model: ActorFollowModel.unscoped(),
618 required: true,
619 as: 'ActorFollowing',
620 where: {
621 state: 'accepted',
622 targetActorId: this.id
623 }
624 }
625 ],
626 transaction: t
627 }
628
629 return ActorModel.findAll(query)
630 .then(accounts => accounts.map(a => a.sharedInboxUrl))
631 }
632
633 getFollowingUrl () {
634 return this.url + '/following'
635 }
636
637 getFollowersUrl () {
638 return this.url + '/followers'
639 }
640
641 getPlaylistsUrl () {
642 return this.url + '/playlists'
643 }
644
645 getPublicKeyUrl () {
646 return this.url + '#main-key'
647 }
648
649 isOwned () {
650 return this.serverId === null
651 }
652
653 getWebfingerUrl (this: MActorHost) {
654 return 'acct:' + this.preferredUsername + '@' + this.getHost()
655 }
656
657 getIdentifier (this: MActorHost) {
658 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
659 }
660
661 getHost (this: MActorHostOnly) {
662 return this.Server ? this.Server.host : WEBSERVER.HOST
663 }
664
665 getRedundancyAllowed () {
666 return this.Server ? this.Server.redundancyAllowed : false
667 }
668
669 hasImage (type: ActorImageType) {
670 const images = type === ActorImageType.AVATAR
671 ? this.Avatars
672 : this.Banners
673
674 return Array.isArray(images) && images.length !== 0
675 }
676
677 isOutdated () {
678 if (this.isOwned()) return false
679
680 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
681 }
682
683 getCreatedAt (this: MActorAPChannel | MActorAPAccount | MActorFormattable) {
684 return this.remoteCreatedAt || this.createdAt
685 }
686}
diff --git a/server/models/actor/sql/instance-list-followers-query-builder.ts b/server/models/actor/sql/instance-list-followers-query-builder.ts
deleted file mode 100644
index 34ce29b5d..000000000
--- a/server/models/actor/sql/instance-list-followers-query-builder.ts
+++ /dev/null
@@ -1,69 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared'
3import { MActorFollowActorsDefault } from '@server/types/models'
4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7
8export interface ListFollowersOptions {
9 actorIds: number[]
10 start: number
11 count: number
12 sort: string
13 state?: FollowState
14 actorType?: ActivityPubActorType
15 search?: string
16}
17
18export class InstanceListFollowersQueryBuilder extends InstanceListFollowsQueryBuilder <ListFollowersOptions> {
19
20 constructor (
21 protected readonly sequelize: Sequelize,
22 protected readonly options: ListFollowersOptions
23 ) {
24 super(sequelize, options)
25 }
26
27 async listFollowers () {
28 this.buildListQuery()
29
30 const results = await this.runQuery({ nest: true })
31 const modelBuilder = new ModelBuilder<MActorFollowActorsDefault>(this.sequelize)
32
33 return modelBuilder.createModels(results, 'ActorFollow')
34 }
35
36 async countFollowers () {
37 this.buildCountQuery()
38
39 const result = await this.runQuery()
40
41 return parseRowCountResult(result)
42 }
43
44 protected getWhere () {
45 let where = 'WHERE "ActorFollowing"."id" IN (:actorIds) '
46 this.replacements.actorIds = this.options.actorIds
47
48 if (this.options.state) {
49 where += 'AND "ActorFollowModel"."state" = :state '
50 this.replacements.state = this.options.state
51 }
52
53 if (this.options.search) {
54 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
55
56 where += `AND (` +
57 `"ActorFollower->Server"."host" ILIKE ${escapedLikeSearch} ` +
58 `OR "ActorFollower"."preferredUsername" ILIKE ${escapedLikeSearch} ` +
59 `)`
60 }
61
62 if (this.options.actorType) {
63 where += `AND "ActorFollower"."type" = :actorType `
64 this.replacements.actorType = this.options.actorType
65 }
66
67 return where
68 }
69}
diff --git a/server/models/actor/sql/instance-list-following-query-builder.ts b/server/models/actor/sql/instance-list-following-query-builder.ts
deleted file mode 100644
index 77b4e3dce..000000000
--- a/server/models/actor/sql/instance-list-following-query-builder.ts
+++ /dev/null
@@ -1,69 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared'
3import { MActorFollowActorsDefault } from '@server/types/models'
4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7
8export interface ListFollowingOptions {
9 followerId: number
10 start: number
11 count: number
12 sort: string
13 state?: FollowState
14 actorType?: ActivityPubActorType
15 search?: string
16}
17
18export class InstanceListFollowingQueryBuilder extends InstanceListFollowsQueryBuilder <ListFollowingOptions> {
19
20 constructor (
21 protected readonly sequelize: Sequelize,
22 protected readonly options: ListFollowingOptions
23 ) {
24 super(sequelize, options)
25 }
26
27 async listFollowing () {
28 this.buildListQuery()
29
30 const results = await this.runQuery({ nest: true })
31 const modelBuilder = new ModelBuilder<MActorFollowActorsDefault>(this.sequelize)
32
33 return modelBuilder.createModels(results, 'ActorFollow')
34 }
35
36 async countFollowing () {
37 this.buildCountQuery()
38
39 const result = await this.runQuery()
40
41 return parseRowCountResult(result)
42 }
43
44 protected getWhere () {
45 let where = 'WHERE "ActorFollowModel"."actorId" = :followerId '
46 this.replacements.followerId = this.options.followerId
47
48 if (this.options.state) {
49 where += 'AND "ActorFollowModel"."state" = :state '
50 this.replacements.state = this.options.state
51 }
52
53 if (this.options.search) {
54 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
55
56 where += `AND (` +
57 `"ActorFollowing->Server"."host" ILIKE ${escapedLikeSearch} ` +
58 `OR "ActorFollowing"."preferredUsername" ILIKE ${escapedLikeSearch} ` +
59 `)`
60 }
61
62 if (this.options.actorType) {
63 where += `AND "ActorFollowing"."type" = :actorType `
64 this.replacements.actorType = this.options.actorType
65 }
66
67 return where
68 }
69}
diff --git a/server/models/actor/sql/shared/actor-follow-table-attributes.ts b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
deleted file mode 100644
index 4431aa6d1..000000000
--- a/server/models/actor/sql/shared/actor-follow-table-attributes.ts
+++ /dev/null
@@ -1,28 +0,0 @@
1import { Memoize } from '@server/helpers/memoize'
2import { ServerModel } from '@server/models/server/server'
3import { ActorModel } from '../../actor'
4import { ActorFollowModel } from '../../actor-follow'
5import { ActorImageModel } from '../../actor-image'
6
7export class ActorFollowTableAttributes {
8
9 @Memoize()
10 getFollowAttributes () {
11 return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
12 }
13
14 @Memoize()
15 getActorAttributes (actorTableName: string) {
16 return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
17 }
18
19 @Memoize()
20 getServerAttributes (actorTableName: string) {
21 return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
22 }
23
24 @Memoize()
25 getAvatarAttributes (actorTableName: string) {
26 return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
27 }
28}
diff --git a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
deleted file mode 100644
index d9593e48b..000000000
--- a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery } from '@server/models/shared'
3import { ActorImageType } from '@shared/models'
4import { getInstanceFollowsSort } from '../../../shared'
5import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
6
7type BaseOptions = {
8 sort: string
9 count: number
10 start: number
11}
12
13export abstract class InstanceListFollowsQueryBuilder <T extends BaseOptions> extends AbstractRunQuery {
14 protected readonly tableAttributes = new ActorFollowTableAttributes()
15
16 protected innerQuery: string
17
18 constructor (
19 protected readonly sequelize: Sequelize,
20 protected readonly options: T
21 ) {
22 super(sequelize)
23 }
24
25 protected abstract getWhere (): string
26
27 protected getJoins () {
28 return 'INNER JOIN "actor" "ActorFollower" ON "ActorFollower"."id" = "ActorFollowModel"."actorId" ' +
29 'INNER JOIN "actor" "ActorFollowing" ON "ActorFollowing"."id" = "ActorFollowModel"."targetActorId" '
30 }
31
32 protected getServerJoin (actorName: string) {
33 return `LEFT JOIN "server" "${actorName}->Server" ON "${actorName}"."serverId" = "${actorName}->Server"."id" `
34 }
35
36 protected getAvatarsJoin (actorName: string) {
37 return `LEFT JOIN "actorImage" "${actorName}->Avatars" ON "${actorName}.id" = "${actorName}->Avatars"."actorId" ` +
38 `AND "${actorName}->Avatars"."type" = ${ActorImageType.AVATAR}`
39 }
40
41 private buildInnerQuery () {
42 this.innerQuery = `${this.getInnerSelect()} ` +
43 `FROM "actorFollow" AS "ActorFollowModel" ` +
44 `${this.getJoins()} ` +
45 `${this.getServerJoin('ActorFollowing')} ` +
46 `${this.getServerJoin('ActorFollower')} ` +
47 `${this.getWhere()} ` +
48 `${this.getOrder()} ` +
49 `LIMIT :limit OFFSET :offset `
50
51 this.replacements.limit = this.options.count
52 this.replacements.offset = this.options.start
53 }
54
55 protected buildListQuery () {
56 this.buildInnerQuery()
57
58 this.query = `${this.getSelect()} ` +
59 `FROM (${this.innerQuery}) AS "ActorFollowModel" ` +
60 `${this.getAvatarsJoin('ActorFollower')} ` +
61 `${this.getAvatarsJoin('ActorFollowing')} ` +
62 `${this.getOrder()}`
63 }
64
65 protected buildCountQuery () {
66 this.query = `SELECT COUNT(*) AS "total" ` +
67 `FROM "actorFollow" AS "ActorFollowModel" ` +
68 `${this.getJoins()} ` +
69 `${this.getServerJoin('ActorFollowing')} ` +
70 `${this.getServerJoin('ActorFollower')} ` +
71 `${this.getWhere()}`
72 }
73
74 private getInnerSelect () {
75 return this.buildSelect([
76 this.tableAttributes.getFollowAttributes(),
77 this.tableAttributes.getActorAttributes('ActorFollower'),
78 this.tableAttributes.getActorAttributes('ActorFollowing'),
79 this.tableAttributes.getServerAttributes('ActorFollower'),
80 this.tableAttributes.getServerAttributes('ActorFollowing')
81 ])
82 }
83
84 private getSelect () {
85 return this.buildSelect([
86 '"ActorFollowModel".*',
87 this.tableAttributes.getAvatarAttributes('ActorFollower'),
88 this.tableAttributes.getAvatarAttributes('ActorFollowing')
89 ])
90 }
91
92 private getOrder () {
93 const orders = getInstanceFollowsSort(this.options.sort)
94
95 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
96 }
97}