Fix lint & tests
[oweals/peertube.git] / server / models / account / user.ts
1 import { FindOptions, literal, Op, QueryTypes } from 'sequelize'
2 import {
3   AfterDestroy,
4   AfterUpdate,
5   AllowNull,
6   BeforeCreate,
7   BeforeUpdate,
8   Column,
9   CreatedAt,
10   DataType,
11   Default,
12   DefaultScope,
13   HasMany,
14   HasOne,
15   Is,
16   IsEmail,
17   Model,
18   Scopes,
19   Table,
20   UpdatedAt
21 } from 'sequelize-typescript'
22 import { hasUserRight, USER_ROLE_LABELS, UserRight, VideoPrivacy } from '../../../shared'
23 import { User, UserRole } from '../../../shared/models/users'
24 import {
25   isNoInstanceConfigWarningModal,
26   isUserAdminFlagsValid,
27   isUserAutoPlayVideoValid,
28   isUserAutoPlayNextVideoValid,
29   isUserAutoPlayNextVideoPlaylistValid,
30   isUserBlockedReasonValid,
31   isUserBlockedValid,
32   isUserEmailVerifiedValid,
33   isUserNSFWPolicyValid,
34   isUserPasswordValid,
35   isUserRoleValid,
36   isUserUsernameValid,
37   isUserVideoLanguages,
38   isUserVideoQuotaDailyValid,
39   isUserVideoQuotaValid,
40   isUserVideosHistoryEnabledValid,
41   isUserWebTorrentEnabledValid,
42   isNoWelcomeModal
43 } from '../../helpers/custom-validators/users'
44 import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
45 import { OAuthTokenModel } from '../oauth/oauth-token'
46 import { getSort, throwIfNotValid } from '../utils'
47 import { VideoChannelModel } from '../video/video-channel'
48 import { AccountModel } from './account'
49 import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
50 import { values } from 'lodash'
51 import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
52 import { clearCacheByUserId } from '../../lib/oauth-model'
53 import { UserNotificationSettingModel } from './user-notification-setting'
54 import { VideoModel } from '../video/video'
55 import { ActorModel } from '../activitypub/actor'
56 import { ActorFollowModel } from '../activitypub/actor-follow'
57 import { VideoImportModel } from '../video/video-import'
58 import { UserAdminFlag } from '../../../shared/models/users/user-flag.model'
59 import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
60 import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
61 import * as Bluebird from 'bluebird'
62 import {
63   MUserDefault,
64   MUserFormattable,
65   MUserId,
66   MUserNotifSettingChannelDefault,
67   MUserWithNotificationSetting, MVideoFullLight
68 } from '@server/typings/models'
69
70 enum ScopeNames {
71   WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
72 }
73
74 @DefaultScope(() => ({
75   include: [
76     {
77       model: AccountModel,
78       required: true
79     },
80     {
81       model: UserNotificationSettingModel,
82       required: true
83     }
84   ]
85 }))
86 @Scopes(() => ({
87   [ScopeNames.WITH_VIDEO_CHANNEL]: {
88     include: [
89       {
90         model: AccountModel,
91         required: true,
92         include: [ VideoChannelModel ]
93       },
94       {
95         model: UserNotificationSettingModel,
96         required: true
97       }
98     ]
99   }
100 }))
101 @Table({
102   tableName: 'user',
103   indexes: [
104     {
105       fields: [ 'username' ],
106       unique: true
107     },
108     {
109       fields: [ 'email' ],
110       unique: true
111     }
112   ]
113 })
114 export class UserModel extends Model<UserModel> {
115
116   @AllowNull(false)
117   @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password'))
118   @Column
119   password: string
120
121   @AllowNull(false)
122   @Is('UserPassword', value => throwIfNotValid(value, isUserUsernameValid, 'user name'))
123   @Column
124   username: string
125
126   @AllowNull(false)
127   @IsEmail
128   @Column(DataType.STRING(400))
129   email: string
130
131   @AllowNull(true)
132   @IsEmail
133   @Column(DataType.STRING(400))
134   pendingEmail: string
135
136   @AllowNull(true)
137   @Default(null)
138   @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
139   @Column
140   emailVerified: boolean
141
142   @AllowNull(false)
143   @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
144   @Column(DataType.ENUM(...values(NSFW_POLICY_TYPES)))
145   nsfwPolicy: NSFWPolicyType
146
147   @AllowNull(false)
148   @Default(true)
149   @Is('UserWebTorrentEnabled', value => throwIfNotValid(value, isUserWebTorrentEnabledValid, 'WebTorrent enabled'))
150   @Column
151   webTorrentEnabled: boolean
152
153   @AllowNull(false)
154   @Default(true)
155   @Is('UserVideosHistoryEnabled', value => throwIfNotValid(value, isUserVideosHistoryEnabledValid, 'Videos history enabled'))
156   @Column
157   videosHistoryEnabled: boolean
158
159   @AllowNull(false)
160   @Default(true)
161   @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))
162   @Column
163   autoPlayVideo: boolean
164
165   @AllowNull(false)
166   @Default(false)
167   @Is('UserAutoPlayNextVideo', value => throwIfNotValid(value, isUserAutoPlayNextVideoValid, 'auto play next video boolean'))
168   @Column
169   autoPlayNextVideo: boolean
170
171   @AllowNull(false)
172   @Default(true)
173   @Is('UserAutoPlayNextVideoPlaylist', value => throwIfNotValid(value, isUserAutoPlayNextVideoPlaylistValid, 'auto play next video for playlists boolean'))
174   @Column
175   autoPlayNextVideoPlaylist: boolean
176
177   @AllowNull(true)
178   @Default(null)
179   @Is('UserVideoLanguages', value => throwIfNotValid(value, isUserVideoLanguages, 'video languages'))
180   @Column(DataType.ARRAY(DataType.STRING))
181   videoLanguages: string[]
182
183   @AllowNull(false)
184   @Default(UserAdminFlag.NONE)
185   @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
186   @Column
187   adminFlags?: UserAdminFlag
188
189   @AllowNull(false)
190   @Default(false)
191   @Is('UserBlocked', value => throwIfNotValid(value, isUserBlockedValid, 'blocked boolean'))
192   @Column
193   blocked: boolean
194
195   @AllowNull(true)
196   @Default(null)
197   @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason', true))
198   @Column
199   blockedReason: string
200
201   @AllowNull(false)
202   @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role'))
203   @Column
204   role: number
205
206   @AllowNull(false)
207   @Is('UserVideoQuota', value => throwIfNotValid(value, isUserVideoQuotaValid, 'video quota'))
208   @Column(DataType.BIGINT)
209   videoQuota: number
210
211   @AllowNull(false)
212   @Is('UserVideoQuotaDaily', value => throwIfNotValid(value, isUserVideoQuotaDailyValid, 'video quota daily'))
213   @Column(DataType.BIGINT)
214   videoQuotaDaily: number
215
216   @AllowNull(false)
217   @Default(DEFAULT_THEME_NAME)
218   @Is('UserTheme', value => throwIfNotValid(value, isThemeNameValid, 'theme'))
219   @Column
220   theme: string
221
222   @AllowNull(false)
223   @Default(false)
224   @Is(
225     'UserNoInstanceConfigWarningModal',
226     value => throwIfNotValid(value, isNoInstanceConfigWarningModal, 'no instance config warning modal')
227   )
228   @Column
229   noInstanceConfigWarningModal: boolean
230
231   @AllowNull(false)
232   @Default(false)
233   @Is(
234     'UserNoInstanceConfigWarningModal',
235     value => throwIfNotValid(value, isNoWelcomeModal, 'no welcome modal')
236   )
237   @Column
238   noWelcomeModal: boolean
239
240   @CreatedAt
241   createdAt: Date
242
243   @UpdatedAt
244   updatedAt: Date
245
246   @HasOne(() => AccountModel, {
247     foreignKey: 'userId',
248     onDelete: 'cascade',
249     hooks: true
250   })
251   Account: AccountModel
252
253   @HasOne(() => UserNotificationSettingModel, {
254     foreignKey: 'userId',
255     onDelete: 'cascade',
256     hooks: true
257   })
258   NotificationSetting: UserNotificationSettingModel
259
260   @HasMany(() => VideoImportModel, {
261     foreignKey: 'userId',
262     onDelete: 'cascade'
263   })
264   VideoImports: VideoImportModel[]
265
266   @HasMany(() => OAuthTokenModel, {
267     foreignKey: 'userId',
268     onDelete: 'cascade'
269   })
270   OAuthTokens: OAuthTokenModel[]
271
272   @BeforeCreate
273   @BeforeUpdate
274   static cryptPasswordIfNeeded (instance: UserModel) {
275     if (instance.changed('password')) {
276       return cryptPassword(instance.password)
277         .then(hash => {
278           instance.password = hash
279           return undefined
280         })
281     }
282   }
283
284   @AfterUpdate
285   @AfterDestroy
286   static removeTokenCache (instance: UserModel) {
287     return clearCacheByUserId(instance.id)
288   }
289
290   static countTotal () {
291     return this.count()
292   }
293
294   static listForApi (start: number, count: number, sort: string, search?: string) {
295     let where = undefined
296     if (search) {
297       where = {
298         [Op.or]: [
299           {
300             email: {
301               [Op.iLike]: '%' + search + '%'
302             }
303           },
304           {
305             username: {
306               [ Op.iLike ]: '%' + search + '%'
307             }
308           }
309         ]
310       }
311     }
312
313     const query: FindOptions = {
314       attributes: {
315         include: [
316           [
317             literal(
318               '(' +
319                 'SELECT COALESCE(SUM("size"), 0) ' +
320                 'FROM (' +
321                   'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
322                   'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
323                   'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
324                   'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
325                   'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' +
326                 ') t' +
327               ')'
328             ),
329             'videoQuotaUsed'
330           ]
331         ]
332       },
333       offset: start,
334       limit: count,
335       order: getSort(sort),
336       where
337     }
338
339     return UserModel.findAndCountAll(query)
340       .then(({ rows, count }) => {
341         return {
342           data: rows,
343           total: count
344         }
345       })
346   }
347
348   static listWithRight (right: UserRight): Bluebird<MUserDefault[]> {
349     const roles = Object.keys(USER_ROLE_LABELS)
350       .map(k => parseInt(k, 10) as UserRole)
351       .filter(role => hasUserRight(role, right))
352
353     const query = {
354       where: {
355         role: {
356           [Op.in]: roles
357         }
358       }
359     }
360
361     return UserModel.findAll(query)
362   }
363
364   static listUserSubscribersOf (actorId: number): Bluebird<MUserWithNotificationSetting[]> {
365     const query = {
366       include: [
367         {
368           model: UserNotificationSettingModel.unscoped(),
369           required: true
370         },
371         {
372           attributes: [ 'userId' ],
373           model: AccountModel.unscoped(),
374           required: true,
375           include: [
376             {
377               attributes: [ ],
378               model: ActorModel.unscoped(),
379               required: true,
380               where: {
381                 serverId: null
382               },
383               include: [
384                 {
385                   attributes: [ ],
386                   as: 'ActorFollowings',
387                   model: ActorFollowModel.unscoped(),
388                   required: true,
389                   where: {
390                     targetActorId: actorId
391                   }
392                 }
393               ]
394             }
395           ]
396         }
397       ]
398     }
399
400     return UserModel.unscoped().findAll(query)
401   }
402
403   static listByUsernames (usernames: string[]): Bluebird<MUserDefault[]> {
404     const query = {
405       where: {
406         username: usernames
407       }
408     }
409
410     return UserModel.findAll(query)
411   }
412
413   static loadById (id: number): Bluebird<MUserDefault> {
414     return UserModel.findByPk(id)
415   }
416
417   static loadByUsername (username: string): Bluebird<MUserDefault> {
418     const query = {
419       where: {
420         username: { [ Op.iLike ]: username }
421       }
422     }
423
424     return UserModel.findOne(query)
425   }
426
427   static loadByUsernameAndPopulateChannels (username: string): Bluebird<MUserNotifSettingChannelDefault> {
428     const query = {
429       where: {
430         username: { [ Op.iLike ]: username }
431       }
432     }
433
434     return UserModel.scope(ScopeNames.WITH_VIDEO_CHANNEL).findOne(query)
435   }
436
437   static loadByEmail (email: string): Bluebird<MUserDefault> {
438     const query = {
439       where: {
440         email
441       }
442     }
443
444     return UserModel.findOne(query)
445   }
446
447   static loadByUsernameOrEmail (username: string, email?: string): Bluebird<MUserDefault> {
448     if (!email) email = username
449
450     const query = {
451       where: {
452         [ Op.or ]: [ { username: { [ Op.iLike ]: username } }, { email } ]
453       }
454     }
455
456     return UserModel.findOne(query)
457   }
458
459   static loadByVideoId (videoId: number): Bluebird<MUserDefault> {
460     const query = {
461       include: [
462         {
463           required: true,
464           attributes: [ 'id' ],
465           model: AccountModel.unscoped(),
466           include: [
467             {
468               required: true,
469               attributes: [ 'id' ],
470               model: VideoChannelModel.unscoped(),
471               include: [
472                 {
473                   required: true,
474                   attributes: [ 'id' ],
475                   model: VideoModel.unscoped(),
476                   where: {
477                     id: videoId
478                   }
479                 }
480               ]
481             }
482           ]
483         }
484       ]
485     }
486
487     return UserModel.findOne(query)
488   }
489
490   static loadByVideoImportId (videoImportId: number): Bluebird<MUserDefault> {
491     const query = {
492       include: [
493         {
494           required: true,
495           attributes: [ 'id' ],
496           model: VideoImportModel.unscoped(),
497           where: {
498             id: videoImportId
499           }
500         }
501       ]
502     }
503
504     return UserModel.findOne(query)
505   }
506
507   static loadByChannelActorId (videoChannelActorId: number): Bluebird<MUserDefault> {
508     const query = {
509       include: [
510         {
511           required: true,
512           attributes: [ 'id' ],
513           model: AccountModel.unscoped(),
514           include: [
515             {
516               required: true,
517               attributes: [ 'id' ],
518               model: VideoChannelModel.unscoped(),
519               where: {
520                 actorId: videoChannelActorId
521               }
522             }
523           ]
524         }
525       ]
526     }
527
528     return UserModel.findOne(query)
529   }
530
531   static loadByAccountActorId (accountActorId: number): Bluebird<MUserDefault> {
532     const query = {
533       include: [
534         {
535           required: true,
536           attributes: [ 'id' ],
537           model: AccountModel.unscoped(),
538           where: {
539             actorId: accountActorId
540           }
541         }
542       ]
543     }
544
545     return UserModel.findOne(query)
546   }
547
548   static getOriginalVideoFileTotalFromUser (user: MUserId) {
549     // Don't use sequelize because we need to use a sub query
550     const query = UserModel.generateUserQuotaBaseSQL()
551
552     return UserModel.getTotalRawQuery(query, user.id)
553   }
554
555   // Returns cumulative size of all video files uploaded in the last 24 hours.
556   static getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
557     // Don't use sequelize because we need to use a sub query
558     const query = UserModel.generateUserQuotaBaseSQL('"video"."createdAt" > now() - interval \'24 hours\'')
559
560     return UserModel.getTotalRawQuery(query, user.id)
561   }
562
563   static async getStats () {
564     const totalUsers = await UserModel.count()
565
566     return {
567       totalUsers
568     }
569   }
570
571   static autoComplete (search: string) {
572     const query = {
573       where: {
574         username: {
575           [ Op.like ]: `%${search}%`
576         }
577       },
578       limit: 10
579     }
580
581     return UserModel.findAll(query)
582                     .then(u => u.map(u => u.username))
583   }
584
585   canGetVideo (video: MVideoFullLight) {
586     const videoUserId = video.VideoChannel.Account.userId
587
588     if (video.isBlacklisted()) {
589       return videoUserId === this.id || this.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)
590     }
591
592     if (video.privacy === VideoPrivacy.PRIVATE) {
593       return video.VideoChannel && videoUserId === this.id || this.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)
594     }
595
596     if (video.privacy === VideoPrivacy.INTERNAL) return true
597
598     return false
599   }
600
601   hasRight (right: UserRight) {
602     return hasUserRight(this.role, right)
603   }
604
605   hasAdminFlag (flag: UserAdminFlag) {
606     return this.adminFlags & flag
607   }
608
609   isPasswordMatch (password: string) {
610     return comparePassword(password, this.password)
611   }
612
613   toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User {
614     const videoQuotaUsed = this.get('videoQuotaUsed')
615     const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
616
617     const json: User = {
618       id: this.id,
619       username: this.username,
620       email: this.email,
621       theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
622
623       pendingEmail: this.pendingEmail,
624       emailVerified: this.emailVerified,
625
626       nsfwPolicy: this.nsfwPolicy,
627       webTorrentEnabled: this.webTorrentEnabled,
628       videosHistoryEnabled: this.videosHistoryEnabled,
629       autoPlayVideo: this.autoPlayVideo,
630       autoPlayNextVideo: this.autoPlayNextVideo,
631       autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist,
632       videoLanguages: this.videoLanguages,
633
634       role: this.role,
635       roleLabel: USER_ROLE_LABELS[ this.role ],
636
637       videoQuota: this.videoQuota,
638       videoQuotaDaily: this.videoQuotaDaily,
639       videoQuotaUsed: videoQuotaUsed !== undefined
640         ? parseInt(videoQuotaUsed + '', 10)
641         : undefined,
642       videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
643         ? parseInt(videoQuotaUsedDaily + '', 10)
644         : undefined,
645
646       noInstanceConfigWarningModal: this.noInstanceConfigWarningModal,
647       noWelcomeModal: this.noWelcomeModal,
648
649       blocked: this.blocked,
650       blockedReason: this.blockedReason,
651
652       account: this.Account.toFormattedJSON(),
653
654       notificationSettings: this.NotificationSetting
655         ? this.NotificationSetting.toFormattedJSON()
656         : undefined,
657
658       videoChannels: [],
659
660       createdAt: this.createdAt
661     }
662
663     if (parameters.withAdminFlags) {
664       Object.assign(json, { adminFlags: this.adminFlags })
665     }
666
667     if (Array.isArray(this.Account.VideoChannels) === true) {
668       json.videoChannels = this.Account.VideoChannels
669         .map(c => c.toFormattedJSON())
670         .sort((v1, v2) => {
671           if (v1.createdAt < v2.createdAt) return -1
672           if (v1.createdAt === v2.createdAt) return 0
673
674           return 1
675         })
676     }
677
678     return json
679   }
680
681   async isAbleToUploadVideo (videoFile: { size: number }) {
682     if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
683
684     const [ totalBytes, totalBytesDaily ] = await Promise.all([
685       UserModel.getOriginalVideoFileTotalFromUser(this),
686       UserModel.getOriginalVideoFileTotalDailyFromUser(this)
687     ])
688
689     const uploadedTotal = videoFile.size + totalBytes
690     const uploadedDaily = videoFile.size + totalBytesDaily
691
692     if (this.videoQuotaDaily === -1) return uploadedTotal < this.videoQuota
693     if (this.videoQuota === -1) return uploadedDaily < this.videoQuotaDaily
694
695     return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily
696   }
697
698   private static generateUserQuotaBaseSQL (where?: string) {
699     const andWhere = where ? 'AND ' + where : ''
700
701     return 'SELECT SUM("size") AS "total" ' +
702       'FROM (' +
703         'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
704         'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
705         'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
706         'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
707         'WHERE "account"."userId" = $userId ' + andWhere +
708         'GROUP BY "video"."id"' +
709       ') t'
710   }
711
712   private static getTotalRawQuery (query: string, userId: number) {
713     const options = {
714       bind: { userId },
715       type: QueryTypes.SELECT as QueryTypes.SELECT
716     }
717
718     return UserModel.sequelize.query<{ total: string }>(query, options)
719                     .then(([ { total } ]) => {
720                       if (total === null) return 0
721
722                       return parseInt(total, 10)
723                     })
724   }
725 }