]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/account/user.ts
Type toFormattedJSON
[github/Chocobozzz/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 }