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