diff options
Diffstat (limited to 'server/models/actor')
-rw-r--r-- | server/models/actor/actor-follow.ts | 662 | ||||
-rw-r--r-- | server/models/actor/actor-image.ts | 171 | ||||
-rw-r--r-- | server/models/actor/actor.ts | 686 | ||||
-rw-r--r-- | server/models/actor/sql/instance-list-followers-query-builder.ts | 69 | ||||
-rw-r--r-- | server/models/actor/sql/instance-list-following-query-builder.ts | 69 | ||||
-rw-r--r-- | server/models/actor/sql/shared/actor-follow-table-attributes.ts | 28 | ||||
-rw-r--r-- | server/models/actor/sql/shared/instance-list-follows-query-builder.ts | 97 |
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 @@ | |||
1 | import { difference } from 'lodash' | ||
2 | import { Attributes, FindOptions, Includeable, IncludeOptions, Op, QueryTypes, Transaction, WhereAttributeHash } from 'sequelize' | ||
3 | import { | ||
4 | AfterCreate, | ||
5 | AfterDestroy, | ||
6 | AfterUpdate, | ||
7 | AllowNull, | ||
8 | BelongsTo, | ||
9 | Column, | ||
10 | CreatedAt, | ||
11 | DataType, | ||
12 | Default, | ||
13 | ForeignKey, | ||
14 | Is, | ||
15 | IsInt, | ||
16 | Max, | ||
17 | Model, | ||
18 | Table, | ||
19 | UpdatedAt | ||
20 | } from 'sequelize-typescript' | ||
21 | import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc' | ||
22 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
23 | import { getServerActor } from '@server/models/application/application' | ||
24 | import { | ||
25 | MActor, | ||
26 | MActorFollowActors, | ||
27 | MActorFollowActorsDefault, | ||
28 | MActorFollowActorsDefaultSubscription, | ||
29 | MActorFollowFollowingHost, | ||
30 | MActorFollowFormattable, | ||
31 | MActorFollowSubscriptions | ||
32 | } from '@server/types/models' | ||
33 | import { AttributesOnly } from '@shared/typescript-utils' | ||
34 | import { FollowState } from '../../../shared/models/actors' | ||
35 | import { ActorFollow } from '../../../shared/models/actors/follow.model' | ||
36 | import { logger } from '../../helpers/logger' | ||
37 | import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants' | ||
38 | import { AccountModel } from '../account/account' | ||
39 | import { ServerModel } from '../server/server' | ||
40 | import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared' | ||
41 | import { doesExist } from '../shared/query' | ||
42 | import { VideoChannelModel } from '../video/video-channel' | ||
43 | import { ActorModel, unusedActorAttributesForAPI } from './actor' | ||
44 | import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder' | ||
45 | import { 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 | }) | ||
69 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { | ||
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' | ||
16 | import { MActorImage, MActorImageFormattable } from '@server/types/models' | ||
17 | import { getLowercaseExtension } from '@shared/core-utils' | ||
18 | import { ActivityIconObject, ActorImageType } from '@shared/models' | ||
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { ActorImage } from '../../../shared/models/actors/actor-image.model' | ||
21 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
22 | import { logger } from '../../helpers/logger' | ||
23 | import { CONFIG } from '../../initializers/config' | ||
24 | import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' | ||
25 | import { buildSQLAttributes, throwIfNotValid } from '../shared' | ||
26 | import { 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 | }) | ||
41 | export 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 @@ | |||
1 | import { col, fn, literal, Op, QueryTypes, Transaction, where } from 'sequelize' | ||
2 | import { | ||
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' | ||
18 | import { activityPubContextify } from '@server/lib/activitypub/context' | ||
19 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
20 | import { ModelCache } from '@server/models/shared/model-cache' | ||
21 | import { forceNumber, getLowercaseExtension } from '@shared/core-utils' | ||
22 | import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' | ||
23 | import { AttributesOnly } from '@shared/typescript-utils' | ||
24 | import { | ||
25 | isActorFollowersCountValid, | ||
26 | isActorFollowingCountValid, | ||
27 | isActorPreferredUsernameValid, | ||
28 | isActorPrivateKeyValid, | ||
29 | isActorPublicKeyValid | ||
30 | } from '../../helpers/custom-validators/activitypub/actor' | ||
31 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
32 | import { | ||
33 | ACTIVITY_PUB, | ||
34 | ACTIVITY_PUB_ACTOR_TYPES, | ||
35 | CONSTRAINTS_FIELDS, | ||
36 | MIMETYPES, | ||
37 | SERVER_ACTOR_NAME, | ||
38 | WEBSERVER | ||
39 | } from '../../initializers/constants' | ||
40 | import { | ||
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' | ||
55 | import { AccountModel } from '../account/account' | ||
56 | import { getServerActor } from '../application/application' | ||
57 | import { ServerModel } from '../server/server' | ||
58 | import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared' | ||
59 | import { VideoModel } from '../video/video' | ||
60 | import { VideoChannelModel } from '../video/video-channel' | ||
61 | import { ActorFollowModel } from './actor-follow' | ||
62 | import { ActorImageModel } from './actor-image' | ||
63 | |||
64 | enum ScopeNames { | ||
65 | FULL = 'FULL' | ||
66 | } | ||
67 | |||
68 | export 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 | }) | ||
164 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { ModelBuilder } from '@server/models/shared' | ||
3 | import { MActorFollowActorsDefault } from '@server/types/models' | ||
4 | import { ActivityPubActorType, FollowState } from '@shared/models' | ||
5 | import { parseRowCountResult } from '../../shared' | ||
6 | import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' | ||
7 | |||
8 | export 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 | |||
18 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { ModelBuilder } from '@server/models/shared' | ||
3 | import { MActorFollowActorsDefault } from '@server/types/models' | ||
4 | import { ActivityPubActorType, FollowState } from '@shared/models' | ||
5 | import { parseRowCountResult } from '../../shared' | ||
6 | import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' | ||
7 | |||
8 | export 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 | |||
18 | export 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 @@ | |||
1 | import { Memoize } from '@server/helpers/memoize' | ||
2 | import { ServerModel } from '@server/models/server/server' | ||
3 | import { ActorModel } from '../../actor' | ||
4 | import { ActorFollowModel } from '../../actor-follow' | ||
5 | import { ActorImageModel } from '../../actor-image' | ||
6 | |||
7 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { AbstractRunQuery } from '@server/models/shared' | ||
3 | import { ActorImageType } from '@shared/models' | ||
4 | import { getInstanceFollowsSort } from '../../../shared' | ||
5 | import { ActorFollowTableAttributes } from './actor-follow-table-attributes' | ||
6 | |||
7 | type BaseOptions = { | ||
8 | sort: string | ||
9 | count: number | ||
10 | start: number | ||
11 | } | ||
12 | |||
13 | export 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 | } | ||