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