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