Remove unused actor uuid field
[oweals/peertube.git] / server / models / video / video-channel.ts
1 import {
2   AllowNull,
3   BeforeDestroy,
4   BelongsTo,
5   Column,
6   CreatedAt,
7   DataType,
8   Default,
9   DefaultScope,
10   ForeignKey,
11   HasMany,
12   Is,
13   Model,
14   Scopes,
15   Sequelize,
16   Table,
17   UpdatedAt
18 } from 'sequelize-typescript'
19 import { ActivityPubActor } from '../../../shared/models/activitypub'
20 import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
21 import {
22   isVideoChannelDescriptionValid,
23   isVideoChannelNameValid,
24   isVideoChannelSupportValid
25 } from '../../helpers/custom-validators/video-channels'
26 import { sendDeleteActor } from '../../lib/activitypub/send'
27 import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
28 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29 import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30 import { VideoModel } from './video'
31 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32 import { ServerModel } from '../server/server'
33 import { FindOptions, ModelIndexesOptions, Op } from 'sequelize'
34 import { AvatarModel } from '../avatar/avatar'
35 import { VideoPlaylistModel } from './video-playlist'
36
37 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
38 const indexes: ModelIndexesOptions[] = [
39   buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
40
41   {
42     fields: [ 'accountId' ]
43   },
44   {
45     fields: [ 'actorId' ]
46   }
47 ]
48
49 export enum ScopeNames {
50   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
51   WITH_ACCOUNT = 'WITH_ACCOUNT',
52   WITH_ACTOR = 'WITH_ACTOR',
53   WITH_VIDEOS = 'WITH_VIDEOS',
54   SUMMARY = 'SUMMARY'
55 }
56
57 type AvailableForListOptions = {
58   actorId: number
59 }
60
61 @DefaultScope(() => ({
62   include: [
63     {
64       model: ActorModel,
65       required: true
66     }
67   ]
68 }))
69 @Scopes(() => ({
70   [ScopeNames.SUMMARY]: (withAccount = false) => {
71     const base: FindOptions = {
72       attributes: [ 'name', 'description', 'id', 'actorId' ],
73       include: [
74         {
75           attributes: [ 'preferredUsername', 'url', 'serverId', 'avatarId' ],
76           model: ActorModel.unscoped(),
77           required: true,
78           include: [
79             {
80               attributes: [ 'host' ],
81               model: ServerModel.unscoped(),
82               required: false
83             },
84             {
85               model: AvatarModel.unscoped(),
86               required: false
87             }
88           ]
89         }
90       ]
91     }
92
93     if (withAccount === true) {
94       base.include.push({
95         model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
96         required: true
97       })
98     }
99
100     return base
101   },
102   [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
103     // Only list local channels OR channels that are on an instance followed by actorId
104     const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
105
106     return {
107       include: [
108         {
109           attributes: {
110             exclude: unusedActorAttributesForAPI
111           },
112           model: ActorModel,
113           where: {
114             [Op.or]: [
115               {
116                 serverId: null
117               },
118               {
119                 serverId: {
120                   [ Op.in ]: Sequelize.literal(inQueryInstanceFollow)
121                 }
122               }
123             ]
124           }
125         },
126         {
127           model: AccountModel,
128           required: true,
129           include: [
130             {
131               attributes: {
132                 exclude: unusedActorAttributesForAPI
133               },
134               model: ActorModel, // Default scope includes avatar and server
135               required: true
136             }
137           ]
138         }
139       ]
140     }
141   },
142   [ScopeNames.WITH_ACCOUNT]: {
143     include: [
144       {
145         model: AccountModel,
146         required: true
147       }
148     ]
149   },
150   [ScopeNames.WITH_VIDEOS]: {
151     include: [
152       VideoModel
153     ]
154   },
155   [ScopeNames.WITH_ACTOR]: {
156     include: [
157       ActorModel
158     ]
159   }
160 }))
161 @Table({
162   tableName: 'videoChannel',
163   indexes
164 })
165 export class VideoChannelModel extends Model<VideoChannelModel> {
166
167   @AllowNull(false)
168   @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
169   @Column
170   name: string
171
172   @AllowNull(true)
173   @Default(null)
174   @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
175   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
176   description: string
177
178   @AllowNull(true)
179   @Default(null)
180   @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
181   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
182   support: string
183
184   @CreatedAt
185   createdAt: Date
186
187   @UpdatedAt
188   updatedAt: Date
189
190   @ForeignKey(() => ActorModel)
191   @Column
192   actorId: number
193
194   @BelongsTo(() => ActorModel, {
195     foreignKey: {
196       allowNull: false
197     },
198     onDelete: 'cascade'
199   })
200   Actor: ActorModel
201
202   @ForeignKey(() => AccountModel)
203   @Column
204   accountId: number
205
206   @BelongsTo(() => AccountModel, {
207     foreignKey: {
208       allowNull: false
209     },
210     hooks: true
211   })
212   Account: AccountModel
213
214   @HasMany(() => VideoModel, {
215     foreignKey: {
216       name: 'channelId',
217       allowNull: false
218     },
219     onDelete: 'CASCADE',
220     hooks: true
221   })
222   Videos: VideoModel[]
223
224   @HasMany(() => VideoPlaylistModel, {
225     foreignKey: {
226       allowNull: true
227     },
228     onDelete: 'CASCADE',
229     hooks: true
230   })
231   VideoPlaylists: VideoPlaylistModel[]
232
233   @BeforeDestroy
234   static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
235     if (!instance.Actor) {
236       instance.Actor = await instance.$get('Actor', { transaction: options.transaction }) as ActorModel
237     }
238
239     if (instance.Actor.isOwned()) {
240       return sendDeleteActor(instance.Actor, options.transaction)
241     }
242
243     return undefined
244   }
245
246   static countByAccount (accountId: number) {
247     const query = {
248       where: {
249         accountId
250       }
251     }
252
253     return VideoChannelModel.count(query)
254   }
255
256   static listForApi (actorId: number, start: number, count: number, sort: string) {
257     const query = {
258       offset: start,
259       limit: count,
260       order: getSort(sort)
261     }
262
263     const scopes = {
264       method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ]
265     }
266     return VideoChannelModel
267       .scope(scopes)
268       .findAndCountAll(query)
269       .then(({ rows, count }) => {
270         return { total: count, data: rows }
271       })
272   }
273
274   static listLocalsForSitemap (sort: string) {
275     const query = {
276       attributes: [ ],
277       offset: 0,
278       order: getSort(sort),
279       include: [
280         {
281           attributes: [ 'preferredUsername', 'serverId' ],
282           model: ActorModel.unscoped(),
283           where: {
284             serverId: null
285           }
286         }
287       ]
288     }
289
290     return VideoChannelModel
291       .unscoped()
292       .findAll(query)
293   }
294
295   static searchForApi (options: {
296     actorId: number
297     search: string
298     start: number
299     count: number
300     sort: string
301   }) {
302     const attributesInclude = []
303     const escapedSearch = VideoModel.sequelize.escape(options.search)
304     const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
305     attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
306
307     const query = {
308       attributes: {
309         include: attributesInclude
310       },
311       offset: options.start,
312       limit: options.count,
313       order: getSort(options.sort),
314       where: {
315         [Op.or]: [
316           Sequelize.literal(
317             'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
318           ),
319           Sequelize.literal(
320             'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
321           )
322         ]
323       }
324     }
325
326     const scopes = {
327       method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: options.actorId } as AvailableForListOptions ]
328     }
329     return VideoChannelModel
330       .scope(scopes)
331       .findAndCountAll(query)
332       .then(({ rows, count }) => {
333         return { total: count, data: rows }
334       })
335   }
336
337   static listByAccount (options: {
338     accountId: number,
339     start: number,
340     count: number,
341     sort: string
342   }) {
343     const query = {
344       offset: options.start,
345       limit: options.count,
346       order: getSort(options.sort),
347       include: [
348         {
349           model: AccountModel,
350           where: {
351             id: options.accountId
352           },
353           required: true
354         }
355       ]
356     }
357
358     return VideoChannelModel
359       .findAndCountAll(query)
360       .then(({ rows, count }) => {
361         return { total: count, data: rows }
362       })
363   }
364
365   static loadByIdAndPopulateAccount (id: number) {
366     return VideoChannelModel.unscoped()
367       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
368       .findByPk(id)
369   }
370
371   static loadByIdAndAccount (id: number, accountId: number) {
372     const query = {
373       where: {
374         id,
375         accountId
376       }
377     }
378
379     return VideoChannelModel.unscoped()
380       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
381       .findOne(query)
382   }
383
384   static loadAndPopulateAccount (id: number) {
385     return VideoChannelModel.unscoped()
386       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
387       .findByPk(id)
388   }
389
390   static loadByUrlAndPopulateAccount (url: string) {
391     const query = {
392       include: [
393         {
394           model: ActorModel,
395           required: true,
396           where: {
397             url
398           }
399         }
400       ]
401     }
402
403     return VideoChannelModel
404       .scope([ ScopeNames.WITH_ACCOUNT ])
405       .findOne(query)
406   }
407
408   static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
409     const [ name, host ] = nameWithHost.split('@')
410
411     if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
412
413     return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
414   }
415
416   static loadLocalByNameAndPopulateAccount (name: string) {
417     const query = {
418       include: [
419         {
420           model: ActorModel,
421           required: true,
422           where: {
423             preferredUsername: name,
424             serverId: null
425           }
426         }
427       ]
428     }
429
430     return VideoChannelModel.unscoped()
431       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
432       .findOne(query)
433   }
434
435   static loadByNameAndHostAndPopulateAccount (name: string, host: string) {
436     const query = {
437       include: [
438         {
439           model: ActorModel,
440           required: true,
441           where: {
442             preferredUsername: name
443           },
444           include: [
445             {
446               model: ServerModel,
447               required: true,
448               where: { host }
449             }
450           ]
451         }
452       ]
453     }
454
455     return VideoChannelModel.unscoped()
456       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
457       .findOne(query)
458   }
459
460   static loadAndPopulateAccountAndVideos (id: number) {
461     const options = {
462       include: [
463         VideoModel
464       ]
465     }
466
467     return VideoChannelModel.unscoped()
468       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
469       .findByPk(id, options)
470   }
471
472   toFormattedJSON (): VideoChannel {
473     const actor = this.Actor.toFormattedJSON()
474     const videoChannel = {
475       id: this.id,
476       displayName: this.getDisplayName(),
477       description: this.description,
478       support: this.support,
479       isLocal: this.Actor.isOwned(),
480       createdAt: this.createdAt,
481       updatedAt: this.updatedAt,
482       ownerAccount: undefined
483     }
484
485     if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
486
487     return Object.assign(actor, videoChannel)
488   }
489
490   toFormattedSummaryJSON (): VideoChannelSummary {
491     const actor = this.Actor.toFormattedJSON()
492
493     return {
494       id: this.id,
495       name: actor.name,
496       displayName: this.getDisplayName(),
497       url: actor.url,
498       host: actor.host,
499       avatar: actor.avatar
500     }
501   }
502
503   toActivityPubObject (): ActivityPubActor {
504     const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
505
506     return Object.assign(obj, {
507       summary: this.description,
508       support: this.support,
509       attributedTo: [
510         {
511           type: 'Person' as 'Person',
512           id: this.Account.Actor.url
513         }
514       ]
515     })
516   }
517
518   getDisplayName () {
519     return this.name
520   }
521
522   isOutdated () {
523     return this.Actor.isOutdated()
524   }
525 }