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