]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/actor/actor.ts
Add Podcast RSS feeds (#5487)
[github/Chocobozzz/PeerTube.git] / server / models / actor / actor.ts
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 // TODO: remove, deprecated in 4.2
546 avatar: this.hasImage(ActorImageType.AVATAR)
547 ? this.Avatars[0].toFormattedJSON()
548 : undefined
549 }
550 }
551
552 toFormattedJSON (this: MActorFormattable) {
553 return {
554 ...this.toFormattedSummaryJSON(),
555
556 id: this.id,
557 hostRedundancyAllowed: this.getRedundancyAllowed(),
558 followingCount: this.followingCount,
559 followersCount: this.followersCount,
560 createdAt: this.getCreatedAt(),
561
562 banners: (this.Banners || []).map(b => b.toFormattedJSON()),
563
564 // TODO: remove, deprecated in 4.2
565 banner: this.hasImage(ActorImageType.BANNER)
566 ? this.Banners[0].toFormattedJSON()
567 : undefined
568 }
569 }
570
571 toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
572 let icon: ActivityIconObject
573 let icons: ActivityIconObject[]
574 let image: ActivityIconObject
575
576 if (this.hasImage(ActorImageType.AVATAR)) {
577 icon = getBiggestActorImage(this.Avatars).toActivityPubObject()
578 icons = this.Avatars.map(a => a.toActivityPubObject())
579 }
580
581 if (this.hasImage(ActorImageType.BANNER)) {
582 const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
583 const extension = getLowercaseExtension(banner.filename)
584
585 image = {
586 type: 'Image',
587 mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
588 height: banner.height,
589 width: banner.width,
590 url: ActorImageModel.getImageUrl(banner)
591 }
592 }
593
594 const json = {
595 type: this.type,
596 id: this.url,
597 following: this.getFollowingUrl(),
598 followers: this.getFollowersUrl(),
599 playlists: this.getPlaylistsUrl(),
600 inbox: this.inboxUrl,
601 outbox: this.outboxUrl,
602 preferredUsername: this.preferredUsername,
603 url: this.url,
604 name,
605 endpoints: {
606 sharedInbox: this.sharedInboxUrl
607 },
608 publicKey: {
609 id: this.getPublicKeyUrl(),
610 owner: this.url,
611 publicKeyPem: this.publicKey
612 },
613 published: this.getCreatedAt().toISOString(),
614
615 icon,
616 icons,
617
618 image
619 }
620
621 return activityPubContextify(json, 'Actor')
622 }
623
624 getFollowerSharedInboxUrls (t: Transaction) {
625 const query = {
626 attributes: [ 'sharedInboxUrl' ],
627 include: [
628 {
629 attribute: [],
630 model: ActorFollowModel.unscoped(),
631 required: true,
632 as: 'ActorFollowing',
633 where: {
634 state: 'accepted',
635 targetActorId: this.id
636 }
637 }
638 ],
639 transaction: t
640 }
641
642 return ActorModel.findAll(query)
643 .then(accounts => accounts.map(a => a.sharedInboxUrl))
644 }
645
646 getFollowingUrl () {
647 return this.url + '/following'
648 }
649
650 getFollowersUrl () {
651 return this.url + '/followers'
652 }
653
654 getPlaylistsUrl () {
655 return this.url + '/playlists'
656 }
657
658 getPublicKeyUrl () {
659 return this.url + '#main-key'
660 }
661
662 isOwned () {
663 return this.serverId === null
664 }
665
666 getWebfingerUrl (this: MActorHost) {
667 return 'acct:' + this.preferredUsername + '@' + this.getHost()
668 }
669
670 getIdentifier (this: MActorHost) {
671 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
672 }
673
674 getHost (this: MActorHostOnly) {
675 return this.Server ? this.Server.host : WEBSERVER.HOST
676 }
677
678 getRedundancyAllowed () {
679 return this.Server ? this.Server.redundancyAllowed : false
680 }
681
682 hasImage (type: ActorImageType) {
683 const images = type === ActorImageType.AVATAR
684 ? this.Avatars
685 : this.Banners
686
687 return Array.isArray(images) && images.length !== 0
688 }
689
690 isOutdated () {
691 if (this.isOwned()) return false
692
693 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
694 }
695
696 getCreatedAt (this: MActorAPChannel | MActorAPAccount | MActorFormattable) {
697 return this.remoteCreatedAt || this.createdAt
698 }
699 }