]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/activitypub/actor.ts
Merge branch 'release/v1.3.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / activitypub / actor.ts
1 import { values } from 'lodash'
2 import { extname } from 'path'
3 import * as Sequelize 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 { ActivityPubActorType } from '../../../shared/models/activitypub'
21 import { Avatar } from '../../../shared/models/avatars/avatar.model'
22 import { activityPubContextify } from '../../helpers/activitypub'
23 import {
24 isActorFollowersCountValid,
25 isActorFollowingCountValid,
26 isActorPreferredUsernameValid,
27 isActorPrivateKeyValid,
28 isActorPublicKeyValid
29 } from '../../helpers/custom-validators/activitypub/actor'
30 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
31 import { ACTIVITY_PUB, ACTIVITY_PUB_ACTOR_TYPES, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32 import { AccountModel } from '../account/account'
33 import { AvatarModel } from '../avatar/avatar'
34 import { ServerModel } from '../server/server'
35 import { isOutdated, throwIfNotValid } from '../utils'
36 import { VideoChannelModel } from '../video/video-channel'
37 import { ActorFollowModel } from './actor-follow'
38 import { VideoModel } from '../video/video'
39
40 enum ScopeNames {
41 FULL = 'FULL'
42 }
43
44 export const unusedActorAttributesForAPI = [
45 'publicKey',
46 'privateKey',
47 'inboxUrl',
48 'outboxUrl',
49 'sharedInboxUrl',
50 'followersUrl',
51 'followingUrl',
52 'url',
53 'createdAt',
54 'updatedAt'
55 ]
56
57 @DefaultScope(() => ({
58 include: [
59 {
60 model: ServerModel,
61 required: false
62 },
63 {
64 model: AvatarModel,
65 required: false
66 }
67 ]
68 }))
69 @Scopes(() => ({
70 [ScopeNames.FULL]: {
71 include: [
72 {
73 model: AccountModel.unscoped(),
74 required: false
75 },
76 {
77 model: VideoChannelModel.unscoped(),
78 required: false,
79 include: [
80 {
81 model: AccountModel,
82 required: true
83 }
84 ]
85 },
86 {
87 model: ServerModel,
88 required: false
89 },
90 {
91 model: AvatarModel,
92 required: false
93 }
94 ]
95 }
96 }))
97 @Table({
98 tableName: 'actor',
99 indexes: [
100 {
101 fields: [ 'url' ],
102 unique: true
103 },
104 {
105 fields: [ 'preferredUsername', 'serverId' ],
106 unique: true
107 },
108 {
109 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
110 },
111 {
112 fields: [ 'sharedInboxUrl' ]
113 },
114 {
115 fields: [ 'serverId' ]
116 },
117 {
118 fields: [ 'avatarId' ]
119 },
120 {
121 fields: [ 'followersUrl' ]
122 }
123 ]
124 })
125 export class ActorModel extends Model<ActorModel> {
126
127 @AllowNull(false)
128 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
129 type: ActivityPubActorType
130
131 @AllowNull(false)
132 @Is('ActorPreferredUsername', value => throwIfNotValid(value, isActorPreferredUsernameValid, 'actor preferred username'))
133 @Column
134 preferredUsername: string
135
136 @AllowNull(false)
137 @Is('ActorUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
138 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
139 url: string
140
141 @AllowNull(true)
142 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPublicKeyValid, 'public key', true))
143 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY.max))
144 publicKey: string
145
146 @AllowNull(true)
147 @Is('ActorPublicKey', value => throwIfNotValid(value, isActorPrivateKeyValid, 'private key', true))
148 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY.max))
149 privateKey: string
150
151 @AllowNull(false)
152 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowersCountValid, 'followers count'))
153 @Column
154 followersCount: number
155
156 @AllowNull(false)
157 @Is('ActorFollowersCount', value => throwIfNotValid(value, isActorFollowingCountValid, 'following count'))
158 @Column
159 followingCount: number
160
161 @AllowNull(false)
162 @Is('ActorInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'inbox url'))
163 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
164 inboxUrl: string
165
166 @AllowNull(false)
167 @Is('ActorOutboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'outbox url'))
168 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
169 outboxUrl: string
170
171 @AllowNull(false)
172 @Is('ActorSharedInboxUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'shared inbox url'))
173 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
174 sharedInboxUrl: string
175
176 @AllowNull(false)
177 @Is('ActorFollowersUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'followers url'))
178 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
179 followersUrl: string
180
181 @AllowNull(false)
182 @Is('ActorFollowingUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'following url'))
183 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ACTORS.URL.max))
184 followingUrl: string
185
186 @CreatedAt
187 createdAt: Date
188
189 @UpdatedAt
190 updatedAt: Date
191
192 @ForeignKey(() => AvatarModel)
193 @Column
194 avatarId: number
195
196 @BelongsTo(() => AvatarModel, {
197 foreignKey: {
198 allowNull: true
199 },
200 onDelete: 'set null',
201 hooks: true
202 })
203 Avatar: AvatarModel
204
205 @HasMany(() => ActorFollowModel, {
206 foreignKey: {
207 name: 'actorId',
208 allowNull: false
209 },
210 as: 'ActorFollowings',
211 onDelete: 'cascade'
212 })
213 ActorFollowing: ActorFollowModel[]
214
215 @HasMany(() => ActorFollowModel, {
216 foreignKey: {
217 name: 'targetActorId',
218 allowNull: false
219 },
220 as: 'ActorFollowers',
221 onDelete: 'cascade'
222 })
223 ActorFollowers: ActorFollowModel[]
224
225 @ForeignKey(() => ServerModel)
226 @Column
227 serverId: number
228
229 @BelongsTo(() => ServerModel, {
230 foreignKey: {
231 allowNull: true
232 },
233 onDelete: 'cascade'
234 })
235 Server: ServerModel
236
237 @HasOne(() => AccountModel, {
238 foreignKey: {
239 allowNull: true
240 },
241 onDelete: 'cascade',
242 hooks: true
243 })
244 Account: AccountModel
245
246 @HasOne(() => VideoChannelModel, {
247 foreignKey: {
248 allowNull: true
249 },
250 onDelete: 'cascade',
251 hooks: true
252 })
253 VideoChannel: VideoChannelModel
254
255 static load (id: number) {
256 return ActorModel.unscoped().findByPk(id)
257 }
258
259 static loadAccountActorByVideoId (videoId: number, transaction: Sequelize.Transaction) {
260 const query = {
261 include: [
262 {
263 attributes: [ 'id' ],
264 model: AccountModel.unscoped(),
265 required: true,
266 include: [
267 {
268 attributes: [ 'id' ],
269 model: VideoChannelModel.unscoped(),
270 required: true,
271 include: [
272 {
273 attributes: [ 'id' ],
274 model: VideoModel.unscoped(),
275 required: true,
276 where: {
277 id: videoId
278 }
279 }
280 ]
281 }
282 ]
283 }
284 ],
285 transaction
286 }
287
288 return ActorModel.unscoped().findOne(query)
289 }
290
291 static isActorUrlExist (url: string) {
292 const query = {
293 raw: true,
294 where: {
295 url
296 }
297 }
298
299 return ActorModel.unscoped().findOne(query)
300 .then(a => !!a)
301 }
302
303 static listByFollowersUrls (followersUrls: string[], transaction?: Sequelize.Transaction) {
304 const query = {
305 where: {
306 followersUrl: {
307 [ Sequelize.Op.in ]: followersUrls
308 }
309 },
310 transaction
311 }
312
313 return ActorModel.scope(ScopeNames.FULL).findAll(query)
314 }
315
316 static loadLocalByName (preferredUsername: string, transaction?: Sequelize.Transaction) {
317 const query = {
318 where: {
319 preferredUsername,
320 serverId: null
321 },
322 transaction
323 }
324
325 return ActorModel.scope(ScopeNames.FULL).findOne(query)
326 }
327
328 static loadByNameAndHost (preferredUsername: string, host: string) {
329 const query = {
330 where: {
331 preferredUsername
332 },
333 include: [
334 {
335 model: ServerModel,
336 required: true,
337 where: {
338 host
339 }
340 }
341 ]
342 }
343
344 return ActorModel.scope(ScopeNames.FULL).findOne(query)
345 }
346
347 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
348 const query = {
349 where: {
350 url
351 },
352 transaction,
353 include: [
354 {
355 attributes: [ 'id' ],
356 model: AccountModel.unscoped(),
357 required: false
358 },
359 {
360 attributes: [ 'id' ],
361 model: VideoChannelModel.unscoped(),
362 required: false
363 }
364 ]
365 }
366
367 return ActorModel.unscoped().findOne(query)
368 }
369
370 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Sequelize.Transaction) {
371 const query = {
372 where: {
373 url
374 },
375 transaction
376 }
377
378 return ActorModel.scope(ScopeNames.FULL).findOne(query)
379 }
380
381 static incrementFollows (id: number, column: 'followersCount' | 'followingCount', by: number) {
382 return ActorModel.increment(column, {
383 by,
384 where: {
385 id
386 }
387 })
388 }
389
390 toFormattedJSON () {
391 let avatar: Avatar = null
392 if (this.Avatar) {
393 avatar = this.Avatar.toFormattedJSON()
394 }
395
396 return {
397 id: this.id,
398 url: this.url,
399 name: this.preferredUsername,
400 host: this.getHost(),
401 hostRedundancyAllowed: this.getRedundancyAllowed(),
402 followingCount: this.followingCount,
403 followersCount: this.followersCount,
404 avatar,
405 createdAt: this.createdAt,
406 updatedAt: this.updatedAt
407 }
408 }
409
410 toActivityPubObject (name: string, type: 'Account' | 'Application' | 'VideoChannel') {
411 let activityPubType
412 if (type === 'Account') {
413 activityPubType = 'Person' as 'Person'
414 } else if (type === 'Application') {
415 activityPubType = 'Application' as 'Application'
416 } else { // VideoChannel
417 activityPubType = 'Group' as 'Group'
418 }
419
420 let icon = undefined
421 if (this.avatarId) {
422 const extension = extname(this.Avatar.filename)
423 icon = {
424 type: 'Image',
425 mediaType: extension === '.png' ? 'image/png' : 'image/jpeg',
426 url: this.getAvatarUrl()
427 }
428 }
429
430 const json = {
431 type: activityPubType,
432 id: this.url,
433 following: this.getFollowingUrl(),
434 followers: this.getFollowersUrl(),
435 playlists: this.getPlaylistsUrl(),
436 inbox: this.inboxUrl,
437 outbox: this.outboxUrl,
438 preferredUsername: this.preferredUsername,
439 url: this.url,
440 name,
441 endpoints: {
442 sharedInbox: this.sharedInboxUrl
443 },
444 publicKey: {
445 id: this.getPublicKeyUrl(),
446 owner: this.url,
447 publicKeyPem: this.publicKey
448 },
449 icon
450 }
451
452 return activityPubContextify(json)
453 }
454
455 getFollowerSharedInboxUrls (t: Sequelize.Transaction) {
456 const query = {
457 attributes: [ 'sharedInboxUrl' ],
458 include: [
459 {
460 attribute: [],
461 model: ActorFollowModel.unscoped(),
462 required: true,
463 as: 'ActorFollowing',
464 where: {
465 state: 'accepted',
466 targetActorId: this.id
467 }
468 }
469 ],
470 transaction: t
471 }
472
473 return ActorModel.findAll(query)
474 .then(accounts => accounts.map(a => a.sharedInboxUrl))
475 }
476
477 getFollowingUrl () {
478 return this.url + '/following'
479 }
480
481 getFollowersUrl () {
482 return this.url + '/followers'
483 }
484
485 getPlaylistsUrl () {
486 return this.url + '/playlists'
487 }
488
489 getPublicKeyUrl () {
490 return this.url + '#main-key'
491 }
492
493 isOwned () {
494 return this.serverId === null
495 }
496
497 getWebfingerUrl () {
498 return 'acct:' + this.preferredUsername + '@' + this.getHost()
499 }
500
501 getIdentifier () {
502 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
503 }
504
505 getHost () {
506 return this.Server ? this.Server.host : WEBSERVER.HOST
507 }
508
509 getRedundancyAllowed () {
510 return this.Server ? this.Server.redundancyAllowed : false
511 }
512
513 getAvatarUrl () {
514 if (!this.avatarId) return undefined
515
516 return WEBSERVER.URL + this.Avatar.getWebserverPath()
517 }
518
519 isOutdated () {
520 if (this.isOwned()) return false
521
522 return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL)
523 }
524 }