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