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