]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/actor/actor.ts
Reduce joins need to generate AP url
[github/Chocobozzz/PeerTube.git] / server / models / actor / actor.ts
CommitLineData
50d6de9c 1import { values } from 'lodash'
3396e653 2import { literal, Op, QueryTypes, Transaction } from 'sequelize'
fadf619a 3import {
2422c46b
C
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
2422c46b
C
9 DefaultScope,
10 ForeignKey,
11 HasMany,
12 HasOne,
13 Is,
2422c46b
C
14 Model,
15 Scopes,
16 Table,
17 UpdatedAt
fadf619a 18} from 'sequelize-typescript'
7e98a7df 19import { activityPubContextify } from '@server/lib/activitypub/context'
d0800f76 20import { getBiggestActorImage } from '@server/lib/actor-image'
b49f22d8 21import { ModelCache } from '@server/models/model-cache'
6b5f72be 22import { getLowercaseExtension } from '@shared/core-utils'
d0800f76 23import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
6b5f72be 24import { AttributesOnly } from '@shared/typescript-utils'
fadf619a 25import {
2422c46b
C
26 isActorFollowersCountValid,
27 isActorFollowingCountValid,
28 isActorPreferredUsernameValid,
29 isActorPrivateKeyValid,
da854ddd
C
30 isActorPublicKeyValid
31} from '../../helpers/custom-validators/activitypub/actor'
32import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
2cb03dc1
C
33import {
34 ACTIVITY_PUB,
35 ACTIVITY_PUB_ACTOR_TYPES,
36 CONSTRAINTS_FIELDS,
37 MIMETYPES,
38 SERVER_ACTOR_NAME,
39 WEBSERVER
40} from '../../initializers/constants'
1ca9f7c3
C
41import {
42 MActor,
43 MActorAccountChannelId,
2cb03dc1
C
44 MActorAPAccount,
45 MActorAPChannel,
3396e653 46 MActorFollowersUrl,
1ca9f7c3 47 MActorFormattable,
b5fecbf4
C
48 MActorFull,
49 MActorHost,
3396e653 50 MActorId,
1ca9f7c3 51 MActorServer,
b49f22d8
C
52 MActorSummaryFormattable,
53 MActorUrl,
47581df0 54 MActorWithInboxes
26d6bf65 55} from '../../types/models'
b49f22d8 56import { AccountModel } from '../account/account'
3396e653 57import { getServerActor } from '../application/application'
b49f22d8
C
58import { ServerModel } from '../server/server'
59import { isOutdated, throwIfNotValid } from '../utils'
60import { VideoModel } from '../video/video'
61import { VideoChannelModel } from '../video/video-channel'
62import { ActorFollowModel } from './actor-follow'
7d9ba5c0 63import { ActorImageModel } from './actor-image'
fadf619a 64
50d6de9c
C
65enum ScopeNames {
66 FULL = 'FULL'
67}
68
f37dc0dd
C
69export const unusedActorAttributesForAPI = [
70 'publicKey',
71 'privateKey',
72 'inboxUrl',
73 'outboxUrl',
74 'sharedInboxUrl',
75 'followersUrl',
a66c2e32 76 'followingUrl'
f37dc0dd
C
77]
78
3acc5084 79@DefaultScope(() => ({
ce33ee01
C
80 include: [
81 {
3acc5084 82 model: ServerModel,
ce33ee01 83 required: false
c5911fd3
C
84 },
85 {
f4796856 86 model: ActorImageModel,
d0800f76 87 as: 'Avatars',
c5911fd3 88 required: false
ce33ee01
C
89 }
90 ]
3acc5084
C
91}))
92@Scopes(() => ({
50d6de9c
C
93 [ScopeNames.FULL]: {
94 include: [
95 {
3acc5084 96 model: AccountModel.unscoped(),
50d6de9c
C
97 required: false
98 },
99 {
3acc5084 100 model: VideoChannelModel.unscoped(),
c48e82b5
C
101 required: false,
102 include: [
103 {
3acc5084 104 model: AccountModel,
c48e82b5
C
105 required: true
106 }
107 ]
ce33ee01
C
108 },
109 {
3acc5084 110 model: ServerModel,
ce33ee01 111 required: false
c5911fd3
C
112 },
113 {
f4796856 114 model: ActorImageModel,
d0800f76 115 as: 'Avatars',
c5911fd3 116 required: false
2cb03dc1
C
117 },
118 {
119 model: ActorImageModel,
d0800f76 120 as: 'Banners',
2cb03dc1 121 required: false
50d6de9c 122 }
3acc5084 123 ]
50d6de9c 124 }
3acc5084 125}))
fadf619a 126@Table({
50d6de9c
C
127 tableName: 'actor',
128 indexes: [
2ccaeeb3 129 {
8cd72bd3
C
130 fields: [ 'url' ],
131 unique: true
2ccaeeb3 132 },
50d6de9c 133 {
e12a0092 134 fields: [ 'preferredUsername', 'serverId' ],
77e08517
C
135 unique: true,
136 where: {
137 serverId: {
138 [Op.ne]: null
139 }
140 }
141 },
0f06c4de
C
142 {
143 fields: [ 'preferredUsername' ],
144 unique: true,
145 where: {
146 serverId: null
147 }
148 },
6502c3d4
C
149 {
150 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
57c36b27 151 },
a3d1026b
C
152 {
153 fields: [ 'sharedInboxUrl' ]
154 },
57c36b27
C
155 {
156 fields: [ 'serverId' ]
157 },
8cd72bd3
C
158 {
159 fields: [ 'followersUrl' ]
50d6de9c
C
160 }
161 ]
fadf619a 162})
16c016e8 163export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
fadf619a 164
50d6de9c 165 @AllowNull(false)
3acc5084 166 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
50d6de9c
C
167 type: ActivityPubActorType
168
fadf619a 169 @AllowNull(false)
e12a0092 170 @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
fadf619a 171 @Column
e12a0092 172 preferredUsername: string
fadf619a
C
173
174 @AllowNull(false)
175 @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
01de67b9 176 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
fadf619a
C
177 url: string
178
179 @AllowNull(true)
1735c825 180 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
01de67b9 181 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
fadf619a
C
182 publicKey: string
183
184 @AllowNull(true)
1735c825 185 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
01de67b9 186 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
fadf619a
C
187 privateKey: string
188
189 @AllowNull(false)
190 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
191 @Column
192 followersCount: number
193
194 @AllowNull(false)
195 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
196 @Column
197 followingCount: number
198
199 @AllowNull(false)
200 @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
01de67b9 201 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
fadf619a
C
202 inboxUrl: string
203
0b5c385b
C
204 @AllowNull(true)
205 @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url', true))
01de67b9 206 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
fadf619a
C
207 outboxUrl: string
208
47581df0
C
209 @AllowNull(true)
210 @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url', true))
01de67b9 211 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
fadf619a
C
212 sharedInboxUrl: string
213
0b5c385b
C
214 @AllowNull(true)
215 @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url', true))
01de67b9 216 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
fadf619a
C
217 followersUrl: string
218
0b5c385b
C
219 @AllowNull(true)
220 @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url', true))
01de67b9 221 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
fadf619a
C
222 followingUrl: string
223
a66c2e32
C
224 @AllowNull(true)
225 @Column
226 remoteCreatedAt: Date
227
fadf619a
C
228 @CreatedAt
229 createdAt: Date
230
231 @UpdatedAt
232 updatedAt: Date
233
d0800f76 234 @HasMany(() => ActorImageModel, {
235 as: 'Avatars',
236 onDelete: 'cascade',
237 hooks: true,
f4796856 238 foreignKey: {
d0800f76 239 allowNull: false
f4796856 240 },
d0800f76 241 scope: {
242 type: ActorImageType.AVATAR
243 }
f4796856 244 })
d0800f76 245 Avatars: ActorImageModel[]
f4796856 246
d0800f76 247 @HasMany(() => ActorImageModel, {
248 as: 'Banners',
249 onDelete: 'cascade',
250 hooks: true,
fadf619a 251 foreignKey: {
d0800f76 252 allowNull: false
fadf619a 253 },
d0800f76 254 scope: {
255 type: ActorImageType.BANNER
256 }
fadf619a 257 })
d0800f76 258 Banners: ActorImageModel[]
fadf619a 259
50d6de9c 260 @HasMany(() => ActorFollowModel, {
fadf619a 261 foreignKey: {
50d6de9c 262 name: 'actorId',
fadf619a
C
263 allowNull: false
264 },
cef534ed 265 as: 'ActorFollowings',
fadf619a
C
266 onDelete: 'cascade'
267 })
54e74059 268 ActorFollowing: ActorFollowModel[]
fadf619a 269
50d6de9c 270 @HasMany(() => ActorFollowModel, {
fadf619a 271 foreignKey: {
50d6de9c 272 name: 'targetActorId',
fadf619a
C
273 allowNull: false
274 },
54e74059 275 as: 'ActorFollowers',
fadf619a
C
276 onDelete: 'cascade'
277 })
54e74059 278 ActorFollowers: ActorFollowModel[]
fadf619a
C
279
280 @ForeignKey(() => ServerModel)
281 @Column
282 serverId: number
283
284 @BelongsTo(() => ServerModel, {
285 foreignKey: {
286 allowNull: true
287 },
288 onDelete: 'cascade'
289 })
290 Server: ServerModel
291
50d6de9c
C
292 @HasOne(() => AccountModel, {
293 foreignKey: {
c5a893d5
C
294 allowNull: true
295 },
296 onDelete: 'cascade',
297 hooks: true
50d6de9c
C
298 })
299 Account: AccountModel
300
301 @HasOne(() => VideoChannelModel, {
302 foreignKey: {
c5a893d5
C
303 allowNull: true
304 },
305 onDelete: 'cascade',
306 hooks: true
50d6de9c
C
307 })
308 VideoChannel: VideoChannelModel
309
3396e653
C
310 static async load (id: number): Promise<MActor> {
311 const actorServer = await getServerActor()
312 if (id === actorServer.id) return actorServer
313
9b39106d 314 return ActorModel.unscoped().findByPk(id)
50d6de9c
C
315 }
316
b49f22d8 317 static loadFull (id: number): Promise<MActorFull> {
453e83ea
C
318 return ActorModel.scope(ScopeNames.FULL).findByPk(id)
319 }
320
3396e653
C
321 static loadAccountActorFollowerUrlByVideoId (videoId: number, transaction: Transaction) {
322 const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
323 `FROM "actor" ` +
324 `INNER JOIN "account" ON "actor"."id" = "account"."actorId" ` +
325 `INNER JOIN "videoChannel" ON "videoChannel"."accountId" = "account"."id" ` +
326 `INNER JOIN "video" ON "video"."channelId" = "videoChannel"."id" AND "video"."id" = :videoId`
327
328 const options = {
329 type: QueryTypes.SELECT as QueryTypes.SELECT,
330 replacements: { videoId },
331 plain: true as true,
e5565833
C
332 transaction
333 }
334
3396e653 335 return ActorModel.sequelize.query<MActorId & MActorFollowersUrl>(query, options)
d4defe07
C
336 }
337
b49f22d8 338 static listByFollowersUrls (followersUrls: string[], transaction?: Transaction): Promise<MActorFull[]> {
fadf619a
C
339 const query = {
340 where: {
341 followersUrl: {
a1587156 342 [Op.in]: followersUrls
fadf619a
C
343 }
344 },
345 transaction
346 }
347
50d6de9c
C
348 return ActorModel.scope(ScopeNames.FULL).findAll(query)
349 }
350
b49f22d8 351 static loadLocalByName (preferredUsername: string, transaction?: Transaction): Promise<MActorFull> {
0ffd6d32
C
352 const fun = () => {
353 const query = {
354 where: {
355 preferredUsername,
356 serverId: null
357 },
358 transaction
359 }
e4a686b4 360
d0800f76 361 return ActorModel.scope(ScopeNames.FULL).findOne(query)
50d6de9c
C
362 }
363
0ffd6d32
C
364 return ModelCache.Instance.doCache({
365 cacheType: 'local-actor-name',
366 key: preferredUsername,
367 // The server actor never change, so we can easily cache it
368 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
369 fun
370 })
0374b6b5
C
371 }
372
b49f22d8 373 static loadLocalUrlByName (preferredUsername: string, transaction?: Transaction): Promise<MActorUrl> {
0ffd6d32
C
374 const fun = () => {
375 const query = {
376 attributes: [ 'url' ],
377 where: {
378 preferredUsername,
379 serverId: null
380 },
381 transaction
382 }
0374b6b5 383
d0800f76 384 return ActorModel.unscoped().findOne(query)
0374b6b5
C
385 }
386
0ffd6d32
C
387 return ModelCache.Instance.doCache({
388 cacheType: 'local-actor-name',
389 key: preferredUsername,
390 // The server actor never change, so we can easily cache it
391 whitelist: () => preferredUsername === SERVER_ACTOR_NAME,
392 fun
393 })
50d6de9c
C
394 }
395
b49f22d8 396 static loadByNameAndHost (preferredUsername: string, host: string): Promise<MActorFull> {
50d6de9c
C
397 const query = {
398 where: {
e12a0092 399 preferredUsername
50d6de9c
C
400 },
401 include: [
402 {
403 model: ServerModel,
404 required: true,
405 where: {
406 host
407 }
408 }
409 ]
410 }
411
412 return ActorModel.scope(ScopeNames.FULL).findOne(query)
413 }
414
b49f22d8 415 static loadByUrl (url: string, transaction?: Transaction): Promise<MActorAccountChannelId> {
e587e0ec
C
416 const query = {
417 where: {
418 url
419 },
420 transaction,
421 include: [
422 {
423 attributes: [ 'id' ],
424 model: AccountModel.unscoped(),
425 required: false
426 },
427 {
428 attributes: [ 'id' ],
429 model: VideoChannelModel.unscoped(),
430 required: false
431 }
432 ]
433 }
434
435 return ActorModel.unscoped().findOne(query)
436 }
437
b49f22d8 438 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Transaction): Promise<MActorFull> {
50d6de9c
C
439 const query = {
440 where: {
441 url
442 },
443 transaction
444 }
445
446 return ActorModel.scope(ScopeNames.FULL).findOne(query)
fadf619a
C
447 }
448
e6122097
C
449 static rebuildFollowsCount (ofId: number, type: 'followers' | 'following', transaction?: Transaction) {
450 const sanitizedOfId = parseInt(ofId + '', 10)
451 const where = { id: sanitizedOfId }
452
453 let columnToUpdate: string
454 let columnOfCount: string
455
456 if (type === 'followers') {
457 columnToUpdate = 'followersCount'
458 columnOfCount = 'targetActorId'
459 } else {
460 columnToUpdate = 'followingCount'
461 columnOfCount = 'actorId'
462 }
463
464 return ActorModel.update({
465 [columnToUpdate]: literal(`(SELECT COUNT(*) FROM "actorFollow" WHERE "${columnOfCount}" = ${sanitizedOfId})`)
466 }, { where, transaction })
32b2b43c
C
467 }
468
06c27593 469 static loadAccountActorByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
2c8776fc
C
470 const query = {
471 include: [
472 {
473 attributes: [ 'id' ],
474 model: AccountModel.unscoped(),
475 required: true,
476 include: [
477 {
478 attributes: [ 'id', 'accountId' ],
479 model: VideoChannelModel.unscoped(),
480 required: true,
481 include: [
482 {
483 attributes: [ 'id', 'channelId' ],
484 model: VideoModel.unscoped(),
485 where: {
486 id: videoId
487 }
488 }
489 ]
490 }
491 ]
492 }
06c27593
C
493 ],
494 transaction
2c8776fc
C
495 }
496
497 return ActorModel.unscoped().findOne(query)
498 }
499
47581df0
C
500 getSharedInbox (this: MActorWithInboxes) {
501 return this.sharedInboxUrl || this.inboxUrl
502 }
503
1ca9f7c3 504 toFormattedSummaryJSON (this: MActorSummaryFormattable) {
fadf619a 505 return {
4cb6d457 506 url: this.url,
60650c77 507 name: this.preferredUsername,
e12a0092 508 host: this.getHost(),
d0800f76 509 avatars: (this.Avatars || []).map(a => a.toFormattedJSON()),
510
511 // TODO: remove, deprecated in 4.2
512 avatar: this.hasImage(ActorImageType.AVATAR)
513 ? this.Avatars[0].toFormattedJSON()
514 : undefined
1ca9f7c3
C
515 }
516 }
517
518 toFormattedJSON (this: MActorFormattable) {
d0800f76 519 return {
520 ...this.toFormattedSummaryJSON(),
2cb03dc1 521
1ca9f7c3 522 id: this.id,
c48e82b5 523 hostRedundancyAllowed: this.getRedundancyAllowed(),
fadf619a
C
524 followingCount: this.followingCount,
525 followersCount: this.followersCount,
d0800f76 526 createdAt: this.getCreatedAt(),
527
528 banners: (this.Banners || []).map(b => b.toFormattedJSON()),
529
530 // TODO: remove, deprecated in 4.2
531 banner: this.hasImage(ActorImageType.BANNER)
532 ? this.Banners[0].toFormattedJSON()
533 : undefined
534 }
fadf619a
C
535 }
536
2cb03dc1 537 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
a1587156 538 let icon: ActivityIconObject
d0800f76 539 let icons: ActivityIconObject[]
2cb03dc1 540 let image: ActivityIconObject
a1587156 541
d0800f76 542 if (this.hasImage(ActorImageType.AVATAR)) {
543 icon = getBiggestActorImage(this.Avatars).toActivityPubObject()
544 icons = this.Avatars.map(a => a.toActivityPubObject())
c5911fd3
C
545 }
546
d0800f76 547 if (this.hasImage(ActorImageType.BANNER)) {
548 const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
ea54cd04 549 const extension = getLowercaseExtension(banner.filename)
2cb03dc1
C
550
551 image = {
552 type: 'Image',
553 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
84531547
C
554 height: banner.height,
555 width: banner.width,
d0800f76 556 url: ActorImageModel.getImageUrl(banner)
2cb03dc1
C
557 }
558 }
559
fadf619a 560 const json = {
8424c402 561 type: this.type,
fadf619a
C
562 id: this.url,
563 following: this.getFollowingUrl(),
564 followers: this.getFollowersUrl(),
418d092a 565 playlists: this.getPlaylistsUrl(),
fadf619a
C
566 inbox: this.inboxUrl,
567 outbox: this.outboxUrl,
e12a0092 568 preferredUsername: this.preferredUsername,
fadf619a 569 url: this.url,
e12a0092 570 name,
fadf619a
C
571 endpoints: {
572 sharedInbox: this.sharedInboxUrl
573 },
fadf619a
C
574 publicKey: {
575 id: this.getPublicKeyUrl(),
576 owner: this.url,
577 publicKeyPem: this.publicKey
c5911fd3 578 },
a66c2e32 579 published: this.getCreatedAt().toISOString(),
d0800f76 580
2cb03dc1 581 icon,
d0800f76 582 icons,
583
2cb03dc1 584 image
fadf619a
C
585 }
586
a219c910 587 return activityPubContextify(json, 'Actor')
fadf619a
C
588 }
589
941c5eac 590 getFollowerSharedInboxUrls (t: Transaction) {
fadf619a
C
591 const query = {
592 attributes: [ 'sharedInboxUrl' ],
593 include: [
594 {
54e74059
C
595 attribute: [],
596 model: ActorFollowModel.unscoped(),
fadf619a 597 required: true,
d6e99e53 598 as: 'ActorFollowing',
fadf619a 599 where: {
54e74059 600 state: 'accepted',
50d6de9c 601 targetActorId: this.id
fadf619a
C
602 }
603 }
604 ],
605 transaction: t
606 }
607
608 return ActorModel.findAll(query)
609 .then(accounts => accounts.map(a => a.sharedInboxUrl))
610 }
611
612 getFollowingUrl () {
613 return this.url + '/following'
614 }
615
616 getFollowersUrl () {
617 return this.url + '/followers'
618 }
619
418d092a
C
620 getPlaylistsUrl () {
621 return this.url + '/playlists'
622 }
623
fadf619a
C
624 getPublicKeyUrl () {
625 return this.url + '#main-key'
626 }
627
628 isOwned () {
629 return this.serverId === null
630 }
e12a0092 631
1ca9f7c3 632 getWebfingerUrl (this: MActorServer) {
e12a0092
C
633 return 'acct:' + this.preferredUsername + '@' + this.getHost()
634 }
635
80e36cd9
AB
636 getIdentifier () {
637 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
638 }
639
1ca9f7c3 640 getHost (this: MActorHost) {
6dd9de95 641 return this.Server ? this.Server.host : WEBSERVER.HOST
e12a0092 642 }
c5911fd3 643
b5fecbf4 644 getRedundancyAllowed () {
c48e82b5
C
645 return this.Server ? this.Server.redundancyAllowed : false
646 }
647
d0800f76 648 hasImage (type: ActorImageType) {
649 const images = type === ActorImageType.AVATAR
650 ? this.Avatars
651 : this.Banners
2cb03dc1 652
d0800f76 653 return Array.isArray(images) && images.length !== 0
2cb03dc1
C
654 }
655
a5625b41
C
656 isOutdated () {
657 if (this.isOwned()) return false
658
9f79ade6 659 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
a5625b41 660 }
a66c2e32
C
661
662 getCreatedAt (this: MActorAPChannel | MActorAPAccount | MActorFormattable) {
663 return this.remoteCreatedAt || this.createdAt
664 }
fadf619a 665}