Fix blacklist sort
[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: [ 'uuid', '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 (accountId: number) {
338     const query = {
339       order: getSort('createdAt'),
340       include: [
341         {
342           model: AccountModel,
343           where: {
344             id: accountId
345           },
346           required: true
347         }
348       ]
349     }
350
351     return VideoChannelModel
352       .findAndCountAll(query)
353       .then(({ rows, count }) => {
354         return { total: count, data: rows }
355       })
356   }
357
358   static loadByIdAndPopulateAccount (id: number) {
359     return VideoChannelModel.unscoped()
360       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
361       .findByPk(id)
362   }
363
364   static loadByIdAndAccount (id: number, accountId: number) {
365     const query = {
366       where: {
367         id,
368         accountId
369       }
370     }
371
372     return VideoChannelModel.unscoped()
373       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
374       .findOne(query)
375   }
376
377   static loadAndPopulateAccount (id: number) {
378     return VideoChannelModel.unscoped()
379       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
380       .findByPk(id)
381   }
382
383   static loadByUUIDAndPopulateAccount (uuid: string) {
384     const query = {
385       include: [
386         {
387           model: ActorModel,
388           required: true,
389           where: {
390             uuid
391           }
392         }
393       ]
394     }
395
396     return VideoChannelModel
397       .scope([ ScopeNames.WITH_ACCOUNT ])
398       .findOne(query)
399   }
400
401   static loadByUrlAndPopulateAccount (url: string) {
402     const query = {
403       include: [
404         {
405           model: ActorModel,
406           required: true,
407           where: {
408             url
409           }
410         }
411       ]
412     }
413
414     return VideoChannelModel
415       .scope([ ScopeNames.WITH_ACCOUNT ])
416       .findOne(query)
417   }
418
419   static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
420     const [ name, host ] = nameWithHost.split('@')
421
422     if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
423
424     return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
425   }
426
427   static loadLocalByNameAndPopulateAccount (name: string) {
428     const query = {
429       include: [
430         {
431           model: ActorModel,
432           required: true,
433           where: {
434             preferredUsername: name,
435             serverId: null
436           }
437         }
438       ]
439     }
440
441     return VideoChannelModel.unscoped()
442       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
443       .findOne(query)
444   }
445
446   static loadByNameAndHostAndPopulateAccount (name: string, host: string) {
447     const query = {
448       include: [
449         {
450           model: ActorModel,
451           required: true,
452           where: {
453             preferredUsername: name
454           },
455           include: [
456             {
457               model: ServerModel,
458               required: true,
459               where: { host }
460             }
461           ]
462         }
463       ]
464     }
465
466     return VideoChannelModel.unscoped()
467       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
468       .findOne(query)
469   }
470
471   static loadAndPopulateAccountAndVideos (id: number) {
472     const options = {
473       include: [
474         VideoModel
475       ]
476     }
477
478     return VideoChannelModel.unscoped()
479       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
480       .findByPk(id, options)
481   }
482
483   toFormattedJSON (): VideoChannel {
484     const actor = this.Actor.toFormattedJSON()
485     const videoChannel = {
486       id: this.id,
487       displayName: this.getDisplayName(),
488       description: this.description,
489       support: this.support,
490       isLocal: this.Actor.isOwned(),
491       createdAt: this.createdAt,
492       updatedAt: this.updatedAt,
493       ownerAccount: undefined
494     }
495
496     if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
497
498     return Object.assign(actor, videoChannel)
499   }
500
501   toFormattedSummaryJSON (): VideoChannelSummary {
502     const actor = this.Actor.toFormattedJSON()
503
504     return {
505       id: this.id,
506       uuid: actor.uuid,
507       name: actor.name,
508       displayName: this.getDisplayName(),
509       url: actor.url,
510       host: actor.host,
511       avatar: actor.avatar
512     }
513   }
514
515   toActivityPubObject (): ActivityPubActor {
516     const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
517
518     return Object.assign(obj, {
519       summary: this.description,
520       support: this.support,
521       attributedTo: [
522         {
523           type: 'Person' as 'Person',
524           id: this.Account.Actor.url
525         }
526       ]
527     })
528   }
529
530   getDisplayName () {
531     return this.name
532   }
533
534   isOutdated () {
535     return this.Actor.isOutdated()
536   }
537 }