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