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