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