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