add user account email verificiation (#977)
[oweals/peertube.git] / server / models / account / user.ts
1 import * as Sequelize from 'sequelize'
2 import {
3   AllowNull,
4   BeforeCreate,
5   BeforeUpdate,
6   Column,
7   CreatedAt,
8   DataType,
9   Default,
10   DefaultScope,
11   HasMany,
12   HasOne,
13   Is,
14   IsEmail,
15   Model,
16   Scopes,
17   Table,
18   UpdatedAt
19 } from 'sequelize-typescript'
20 import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
21 import { User, UserRole } from '../../../shared/models/users'
22 import {
23   isUserAutoPlayVideoValid,
24   isUserBlockedReasonValid,
25   isUserBlockedValid,
26   isUserNSFWPolicyValid,
27   isUserEmailVerifiedValid,
28   isUserPasswordValid,
29   isUserRoleValid,
30   isUserUsernameValid,
31   isUserVideoQuotaValid,
32   isUserVideoQuotaDailyValid
33 } from '../../helpers/custom-validators/users'
34 import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
35 import { OAuthTokenModel } from '../oauth/oauth-token'
36 import { getSort, throwIfNotValid } from '../utils'
37 import { VideoChannelModel } from '../video/video-channel'
38 import { AccountModel } from './account'
39 import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
40 import { values } from 'lodash'
41 import { NSFW_POLICY_TYPES } from '../../initializers'
42
43 enum ScopeNames {
44   WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
45 }
46
47 @DefaultScope({
48   include: [
49     {
50       model: () => AccountModel,
51       required: true
52     }
53   ]
54 })
55 @Scopes({
56   [ScopeNames.WITH_VIDEO_CHANNEL]: {
57     include: [
58       {
59         model: () => AccountModel,
60         required: true,
61         include: [ () => VideoChannelModel ]
62       }
63     ]
64   }
65 })
66 @Table({
67   tableName: 'user',
68   indexes: [
69     {
70       fields: [ 'username' ],
71       unique: true
72     },
73     {
74       fields: [ 'email' ],
75       unique: true
76     }
77   ]
78 })
79 export class UserModel extends Model<UserModel> {
80
81   @AllowNull(false)
82   @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password'))
83   @Column
84   password: string
85
86   @AllowNull(false)
87   @Is('UserPassword', value => throwIfNotValid(value, isUserUsernameValid, 'user name'))
88   @Column
89   username: string
90
91   @AllowNull(false)
92   @IsEmail
93   @Column(DataType.STRING(400))
94   email: string
95
96   @AllowNull(true)
97   @Default(null)
98   @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean'))
99   @Column
100   emailVerified: boolean
101
102   @AllowNull(false)
103   @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
104   @Column(DataType.ENUM(values(NSFW_POLICY_TYPES)))
105   nsfwPolicy: NSFWPolicyType
106
107   @AllowNull(false)
108   @Default(true)
109   @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))
110   @Column
111   autoPlayVideo: boolean
112
113   @AllowNull(false)
114   @Default(false)
115   @Is('UserBlocked', value => throwIfNotValid(value, isUserBlockedValid, 'blocked boolean'))
116   @Column
117   blocked: boolean
118
119   @AllowNull(true)
120   @Default(null)
121   @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason'))
122   @Column
123   blockedReason: string
124
125   @AllowNull(false)
126   @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role'))
127   @Column
128   role: number
129
130   @AllowNull(false)
131   @Is('UserVideoQuota', value => throwIfNotValid(value, isUserVideoQuotaValid, 'video quota'))
132   @Column(DataType.BIGINT)
133   videoQuota: number
134
135   @AllowNull(false)
136   @Is('UserVideoQuotaDaily', value => throwIfNotValid(value, isUserVideoQuotaDailyValid, 'video quota daily'))
137   @Column(DataType.BIGINT)
138   videoQuotaDaily: number
139
140   @CreatedAt
141   createdAt: Date
142
143   @UpdatedAt
144   updatedAt: Date
145
146   @HasOne(() => AccountModel, {
147     foreignKey: 'userId',
148     onDelete: 'cascade',
149     hooks: true
150   })
151   Account: AccountModel
152
153   @HasMany(() => OAuthTokenModel, {
154     foreignKey: 'userId',
155     onDelete: 'cascade'
156   })
157   OAuthTokens: OAuthTokenModel[]
158
159   @BeforeCreate
160   @BeforeUpdate
161   static cryptPasswordIfNeeded (instance: UserModel) {
162     if (instance.changed('password')) {
163       return cryptPassword(instance.password)
164         .then(hash => {
165           instance.password = hash
166           return undefined
167         })
168     }
169   }
170
171   static countTotal () {
172     return this.count()
173   }
174
175   static listForApi (start: number, count: number, sort: string) {
176     const query = {
177       attributes: {
178         include: [
179           [
180             Sequelize.literal(
181               '(' +
182                 'SELECT COALESCE(SUM("size"), 0) ' +
183                 'FROM (' +
184                   'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
185                   'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
186                   'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
187                   'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
188                   'WHERE "account"."userId" = "UserModel"."id" GROUP BY "video"."id"' +
189                 ') t' +
190               ')'
191             ),
192             'videoQuotaUsed'
193           ] as any // FIXME: typings
194         ]
195       },
196       offset: start,
197       limit: count,
198       order: getSort(sort)
199     }
200
201     return UserModel.findAndCountAll(query)
202       .then(({ rows, count }) => {
203         return {
204           data: rows,
205           total: count
206         }
207       })
208   }
209
210   static listEmailsWithRight (right: UserRight) {
211     const roles = Object.keys(USER_ROLE_LABELS)
212       .map(k => parseInt(k, 10) as UserRole)
213       .filter(role => hasUserRight(role, right))
214
215     const query = {
216       attribute: [ 'email' ],
217       where: {
218         role: {
219           [Sequelize.Op.in]: roles
220         }
221       }
222     }
223
224     return UserModel.unscoped()
225       .findAll(query)
226       .then(u => u.map(u => u.email))
227   }
228
229   static loadById (id: number) {
230     return UserModel.findById(id)
231   }
232
233   static loadByUsername (username: string) {
234     const query = {
235       where: {
236         username
237       }
238     }
239
240     return UserModel.findOne(query)
241   }
242
243   static loadByUsernameAndPopulateChannels (username: string) {
244     const query = {
245       where: {
246         username
247       }
248     }
249
250     return UserModel.scope(ScopeNames.WITH_VIDEO_CHANNEL).findOne(query)
251   }
252
253   static loadByEmail (email: string) {
254     const query = {
255       where: {
256         email
257       }
258     }
259
260     return UserModel.findOne(query)
261   }
262
263   static loadByUsernameOrEmail (username: string, email?: string) {
264     if (!email) email = username
265
266     const query = {
267       where: {
268         [ Sequelize.Op.or ]: [ { username }, { email } ]
269       }
270     }
271
272     return UserModel.findOne(query)
273   }
274
275   static getOriginalVideoFileTotalFromUser (user: UserModel) {
276     // Don't use sequelize because we need to use a sub query
277     const query = UserModel.generateUserQuotaBaseSQL()
278
279     return UserModel.getTotalRawQuery(query, user.id)
280   }
281
282   // Returns cumulative size of all video files uploaded in the last 24 hours.
283   static getOriginalVideoFileTotalDailyFromUser (user: UserModel) {
284     // Don't use sequelize because we need to use a sub query
285     const query = UserModel.generateUserQuotaBaseSQL('"video"."createdAt" > now() - interval \'24 hours\'')
286
287     return UserModel.getTotalRawQuery(query, user.id)
288   }
289
290   static async getStats () {
291     const totalUsers = await UserModel.count()
292
293     return {
294       totalUsers
295     }
296   }
297
298   hasRight (right: UserRight) {
299     return hasUserRight(this.role, right)
300   }
301
302   isPasswordMatch (password: string) {
303     return comparePassword(password, this.password)
304   }
305
306   toFormattedJSON (): User {
307     const videoQuotaUsed = this.get('videoQuotaUsed')
308     const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
309
310     const json = {
311       id: this.id,
312       username: this.username,
313       email: this.email,
314       emailVerified: this.emailVerified,
315       nsfwPolicy: this.nsfwPolicy,
316       autoPlayVideo: this.autoPlayVideo,
317       role: this.role,
318       roleLabel: USER_ROLE_LABELS[ this.role ],
319       videoQuota: this.videoQuota,
320       videoQuotaDaily: this.videoQuotaDaily,
321       createdAt: this.createdAt,
322       blocked: this.blocked,
323       blockedReason: this.blockedReason,
324       account: this.Account.toFormattedJSON(),
325       videoChannels: [],
326       videoQuotaUsed: videoQuotaUsed !== undefined
327             ? parseInt(videoQuotaUsed, 10)
328             : undefined,
329       videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
330             ? parseInt(videoQuotaUsedDaily, 10)
331             : undefined
332     }
333
334     if (Array.isArray(this.Account.VideoChannels) === true) {
335       json.videoChannels = this.Account.VideoChannels
336         .map(c => c.toFormattedJSON())
337         .sort((v1, v2) => {
338           if (v1.createdAt < v2.createdAt) return -1
339           if (v1.createdAt === v2.createdAt) return 0
340
341           return 1
342         })
343     }
344
345     return json
346   }
347
348   async isAbleToUploadVideo (videoFile: { size: number }) {
349     if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
350
351     const [ totalBytes, totalBytesDaily ] = await Promise.all([
352       UserModel.getOriginalVideoFileTotalFromUser(this),
353       UserModel.getOriginalVideoFileTotalDailyFromUser(this)
354     ])
355
356     const uploadedTotal = videoFile.size + totalBytes
357     const uploadedDaily = videoFile.size + totalBytesDaily
358     if (this.videoQuotaDaily === -1) {
359       return uploadedTotal < this.videoQuota
360     }
361     if (this.videoQuota === -1) {
362       return uploadedDaily < this.videoQuotaDaily
363     }
364
365     return (uploadedTotal < this.videoQuota) &&
366         (uploadedDaily < this.videoQuotaDaily)
367   }
368
369   private static generateUserQuotaBaseSQL (where?: string) {
370     const andWhere = where ? 'AND ' + where : ''
371
372     return 'SELECT SUM("size") AS "total" ' +
373       'FROM (' +
374         'SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
375         'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
376         'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
377         'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
378         'WHERE "account"."userId" = $userId ' + andWhere +
379         'GROUP BY "video"."id"' +
380       ') t'
381   }
382
383   private static getTotalRawQuery (query: string, userId: number) {
384     const options = {
385       bind: { userId },
386       type: Sequelize.QueryTypes.SELECT
387     }
388
389     return UserModel.sequelize.query(query, options)
390                     .then(([ { total } ]) => {
391                       if (total === null) return 0
392
393                       return parseInt(total, 10)
394                     })
395   }
396 }