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