]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/actor/actor.ts
Refactor model utils
[github/Chocobozzz/PeerTube.git] / server / models / actor / actor.ts
1 import { literal, Op, QueryTypes, Transaction } 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 MActorId,
50 MActorServer,
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: [ 'preferredUsername', 'serverId' ],
134 unique: true,
135 where: {
136 serverId: {
137 [Op.ne]: null
138 }
139 }
140 },
141 {
142 fields: [ 'preferredUsername' ],
143 unique: true,
144 where: {
145 serverId: null
146 }
147 },
148 {
149 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
150 },
151 {
152 fields: [ 'sharedInboxUrl' ]
153 },
154 {
155 fields: [ 'serverId' ]
156 },
157 {
158 fields: [ 'followersUrl' ]
159 }
160 ]
161 })
162 export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
163
164 @AllowNull(false)
165 @Column(DataType.ENUM(...Object.values(ACTIVITY_PUB_ACTOR_TYPES)))
166 type: ActivityPubActorType
167
168 @AllowNull(false)
169 @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
170 @Column
171 preferredUsername: string
172
173 @AllowNull(false)
174 @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
175 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
176 url: string
177
178 @AllowNull(true)
179 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
180 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
181 publicKey: string
182
183 @AllowNull(true)
184 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
185 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
186 privateKey: string
187
188 @AllowNull(false)
189 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
190 @Column
191 followersCount: number
192
193 @AllowNull(false)
194 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
195 @Column
196 followingCount: number
197
198 @AllowNull(false)
199 @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
200 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
201 inboxUrl: string
202
203 @AllowNull(true)
204 @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true))
205 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
206 outboxUrl: string
207
208 @AllowNull(true)
209 @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true))
210 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
211 sharedInboxUrl: string
212
213 @AllowNull(true)
214 @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true))
215 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
216 followersUrl: string
217
218 @AllowNull(true)
219 @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true))
220 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
221 followingUrl: string
222
223 @AllowNull(true)
224 @Column
225 remoteCreatedAt: Date
226
227 @CreatedAt
228 createdAt: Date
229
230 @UpdatedAt
231 updatedAt: Date
232
233 @HasMany(() => ActorImageModel, {
234 as: 'Avatars',
235 onDelete: 'cascade',
236 hooks: true,
237 foreignKey: {
238 allowNull: false
239 },
240 scope: {
241 type: ActorImageType.AVATAR
242 }
243 })
244 Avatars: ActorImageModel[]
245
246 @HasMany(() => ActorImageModel, {
247 as: 'Banners',
248 onDelete: 'cascade',
249 hooks: true,
250 foreignKey: {
251 allowNull: false
252 },
253 scope: {
254 type: ActorImageType.BANNER
255 }
256 })
257 Banners: ActorImageModel[]
258
259 @HasMany(() => ActorFollowModel, {
260 foreignKey: {
261 name: 'actorId',
262 allowNull: false
263 },
264 as: 'ActorFollowings',
265 onDelete: 'cascade'
266 })
267 ActorFollowing: ActorFollowModel[]
268
269 @HasMany(() => ActorFollowModel, {
270 foreignKey: {
271 name: 'targetActorId',
272 allowNull: false
273 },
274 as: 'ActorFollowers',
275 onDelete: 'cascade'
276 })
277 ActorFollowers: ActorFollowModel[]
278
279 @ForeignKey(() => ServerModel)
280 @Column
281 serverId: number
282
283 @BelongsTo(() => ServerModel, {
284 foreignKey: {
285 allowNull: true
286 },
287 onDelete: 'cascade'
288 })
289 Server: ServerModel
290
291 @HasOne(() => AccountModel, {
292 foreignKey: {
293 allowNull: true
294 },
295 onDelete: 'cascade',
296 hooks: true
297 })
298 Account: AccountModel
299
300 @HasOne(() => VideoChannelModel, {
301 foreignKey: {
302 allowNull: true
303 },
304 onDelete: 'cascade',
305 hooks: true
306 })
307 VideoChannel: VideoChannelModel
308
309 // ---------------------------------------------------------------------------
310
311 static getSQLAttributes (tableName: string, aliasPrefix = '') {
312 return buildSQLAttributes({
313 model: this,
314 tableName,
315 aliasPrefix
316 })
317 }
318
319 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
320 return buildSQLAttributes({
321 model: this,
322 tableName,
323 aliasPrefix,
324 excludeAttributes: unusedActorAttributesForAPI
325 })
326 }
327
328 // ---------------------------------------------------------------------------
329
330 static async load (id: number): Promise<MActor> {
331 const actorServer = await getServerActor()
332 if (id === actorServer.id) return actorServer
333
334 return ActorModel.unscoped().findByPk(id)
335 }
336
337 static loadFull (id: number): Promise<MActorFull> {
338 return ActorModel.scope(ScopeNames.FULL).findByPk(id)
339 }
340
341 static loadAccountActorFollowerUrlByVideoId (videoId: number, transaction: Transaction) {
342 const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
343 `FROM "actor" ` +
344 `INNER JOIN "account" ON "actor"."id" = "account"."actorId" ` +
345 `INNER JOIN "videoChannel" ON "videoChannel"."accountId" = "account"."id" ` +
346 `INNER JOIN "video" ON "video"."channelId" = "videoChannel"."id" AND "video"."id" = :videoId`
347
348 const options = {
349 type: QueryTypes.SELECT as QueryTypes.SELECT,
350 replacements: { videoId },
351 plain: true as true,
352 transaction
353 }
354
355 return ActorModel.sequelize.query<MActorId & MActorFollowersUrl>(query, options)
356 }
357
358 static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Promise<MActorFull[]> {
359 const query = {
360 where: {
361 followersUrl: {
362 [Op.in]: followersUrls
363 }
364 },
365 transaction
366 }
367
368 return ActorModel.scope(ScopeNames.FULL).findAll(query)
369 }
370
371 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Promise<MActorFull> {
372 const fun = () => {
373 const query = {
374 where: {
375 preferredUsername,
376 serverId: null
377 },
378 transaction
379 }
380
381 return ActorModel.scope(ScopeNames.FULL).findOne(query)
382 }
383
384 return ModelCache.Instance.doCache({
385 cacheType: 'local-actor-name',
386 key: preferredUsername,
387 // The server actor never change, so we can easily cache it
388 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
389 fun
390 })
391 }
392
393 static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Promise<MActorUrl> {
394 const fun = () => {
395 const query = {
396 attributes: [ 'url' ],
397 where: {
398 preferredUsername,
399 serverId: null
400 },
401 transaction
402 }
403
404 return ActorModel.unscoped().findOne(query)
405 }
406
407 return ModelCache.Instance.doCache({
408 cacheType: 'local-actor-name',
409 key: preferredUsername,
410 // The server actor never change, so we can easily cache it
411 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
412 fun
413 })
414 }
415
416 static loadByNameAndHost (preferredUsername: string, host: string): Promise<MActorFull> {
417 const query = {
418 where: {
419 preferredUsername
420 },
421 include: [
422 {
423 model: ServerModel,
424 required: true,
425 where: {
426 host
427 }
428 }
429 ]
430 }
431
432 return ActorModel.scope(ScopeNames.FULL).findOne(query)
433 }
434
435 static loadByUrl (url: string, transaction?: Transaction): Promise<MActorAccountChannelId> {
436 const query = {
437 where: {
438 url
439 },
440 transaction,
441 include: [
442 {
443 attributes: [ 'id' ],
444 model: AccountModel.unscoped(),
445 required: false
446 },
447 {
448 attributes: [ 'id' ],
449 model: VideoChannelModel.unscoped(),
450 required: false
451 }
452 ]
453 }
454
455 return ActorModel.unscoped().findOne(query)
456 }
457
458 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Promise<MActorFull> {
459 const query = {
460 where: {
461 url
462 },
463 transaction
464 }
465
466 return ActorModel.scope(ScopeNames.FULL).findOne(query)
467 }
468
469 static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) {
470 const sanitizedOfId = forceNumber(ofId)
471 const where = { id: sanitizedOfId }
472
473 let columnToUpdate: string
474 let columnOfCount: string
475
476 if (type === 'followers') {
477 columnToUpdate = 'followersCount'
478 columnOfCount = 'targetActorId'
479 } else {
480 columnToUpdate = 'followingCount'
481 columnOfCount = 'actorId'
482 }
483
484 return ActorModel.update({
485 [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId} AND "state" = 'accepted')`)
486 }, { where, transaction })
487 }
488
489 static loadAccountActorByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
490 const query = {
491 include: [
492 {
493 attributes: [ 'id' ],
494 model: AccountModel.unscoped(),
495 required: true,
496 include: [
497 {
498 attributes: [ 'id', 'accountId' ],
499 model: VideoChannelModel.unscoped(),
500 required: true,
501 include: [
502 {
503 attributes: [ 'id', 'channelId' ],
504 model: VideoModel.unscoped(),
505 where: {
506 id: videoId
507 }
508 }
509 ]
510 }
511 ]
512 }
513 ],
514 transaction
515 }
516
517 return ActorModel.unscoped().findOne(query)
518 }
519
520 getSharedInbox (this: MActorWithInboxes) {
521 return this.sharedInboxUrl || this.inboxUrl
522 }
523
524 toFormattedSummaryJSON (this: MActorSummaryFormattable) {
525 return {
526 url: this.url,
527 name: this.preferredUsername,
528 host: this.getHost(),
529 avatars: (this.Avatars || []).map(a => a.toFormattedJSON()),
530
531 // TODO: remove, deprecated in 4.2
532 avatar: this.hasImage(ActorImageType.AVATAR)
533 ? this.Avatars[0].toFormattedJSON()
534 : undefined
535 }
536 }
537
538 toFormattedJSON (this: MActorFormattable) {
539 return {
540 ...this.toFormattedSummaryJSON(),
541
542 id: this.id,
543 hostRedundancyAllowed: this.getRedundancyAllowed(),
544 followingCount: this.followingCount,
545 followersCount: this.followersCount,
546 createdAt: this.getCreatedAt(),
547
548 banners: (this.Banners || []).map(b => b.toFormattedJSON()),
549
550 // TODO: remove, deprecated in 4.2
551 banner: this.hasImage(ActorImageType.BANNER)
552 ? this.Banners[0].toFormattedJSON()
553 : undefined
554 }
555 }
556
557 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
558 let icon: ActivityIconObject
559 let icons: ActivityIconObject[]
560 let image: ActivityIconObject
561
562 if (this.hasImage(ActorImageType.AVATAR)) {
563 icon = getBiggestActorImage(this.Avatars).toActivityPubObject()
564 icons = this.Avatars.map(a => a.toActivityPubObject())
565 }
566
567 if (this.hasImage(ActorImageType.BANNER)) {
568 const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
569 const extension = getLowercaseExtension(banner.filename)
570
571 image = {
572 type: 'Image',
573 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
574 height: banner.height,
575 width: banner.width,
576 url: ActorImageModel.getImageUrl(banner)
577 }
578 }
579
580 const json = {
581 type: this.type,
582 id: this.url,
583 following: this.getFollowingUrl(),
584 followers: this.getFollowersUrl(),
585 playlists: this.getPlaylistsUrl(),
586 inbox: this.inboxUrl,
587 outbox: this.outboxUrl,
588 preferredUsername: this.preferredUsername,
589 url: this.url,
590 name,
591 endpoints: {
592 sharedInbox: this.sharedInboxUrl
593 },
594 publicKey: {
595 id: this.getPublicKeyUrl(),
596 owner: this.url,
597 publicKeyPem: this.publicKey
598 },
599 published: this.getCreatedAt().toISOString(),
600
601 icon,
602 icons,
603
604 image
605 }
606
607 return activityPubContextify(json, 'Actor')
608 }
609
610 getFollowerSharedInboxUrls (t: Transaction) {
611 const query = {
612 attributes: [ 'sharedInboxUrl' ],
613 include: [
614 {
615 attribute: [],
616 model: ActorFollowModel.unscoped(),
617 required: true,
618 as: 'ActorFollowing',
619 where: {
620 state: 'accepted',
621 targetActorId: this.id
622 }
623 }
624 ],
625 transaction: t
626 }
627
628 return ActorModel.findAll(query)
629 .then(accounts => accounts.map(a => a.sharedInboxUrl))
630 }
631
632 getFollowingUrl () {
633 return this.url + '/following'
634 }
635
636 getFollowersUrl () {
637 return this.url + '/followers'
638 }
639
640 getPlaylistsUrl () {
641 return this.url + '/playlists'
642 }
643
644 getPublicKeyUrl () {
645 return this.url + '#main-key'
646 }
647
648 isOwned () {
649 return this.serverId === null
650 }
651
652 getWebfingerUrl (this: MActorServer) {
653 return 'acct:' + this.preferredUsername + '@' + this.getHost()
654 }
655
656 getIdentifier () {
657 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
658 }
659
660 getHost (this: MActorHost) {
661 return this.Server ? this.Server.host : WEBSERVER.HOST
662 }
663
664 getRedundancyAllowed () {
665 return this.Server ? this.Server.redundancyAllowed : false
666 }
667
668 hasImage (type: ActorImageType) {
669 const images = type === ActorImageType.AVATAR
670 ? this.Avatars
671 : this.Banners
672
673 return Array.isArray(images) && images.length !== 0
674 }
675
676 isOutdated () {
677 if (this.isOwned()) return false
678
679 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
680 }
681
682 getCreatedAt (this: MActorAPChannel | MActorAPAccount | MActorFormattable) {
683 return this.remoteCreatedAt || this.createdAt
684 }
685 }