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