]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/account/user.ts
Merge branch 'feature/strong-model-types' into develop
[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,
3caf77d3 34 isUserVideoLanguages,
5cf84858 35 isUserVideoQuotaDailyValid,
64cc5e85 36 isUserVideoQuotaValid,
cef534ed
C
37 isUserVideosHistoryEnabledValid,
38 isUserWebTorrentEnabledValid
3fd3ab2d 39} from '../../helpers/custom-validators/users'
da854ddd 40import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
3fd3ab2d
C
41import { OAuthTokenModel } from '../oauth/oauth-token'
42import { getSort, throwIfNotValid } from '../utils'
43import { VideoChannelModel } from '../video/video-channel'
44import { AccountModel } from './account'
0883b324
C
45import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
46import { values } from 'lodash'
ffb321be 47import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
f201a749 48import { clearCacheByUserId } from '../../lib/oauth-model'
cef534ed
C
49import { UserNotificationSettingModel } from './user-notification-setting'
50import { VideoModel } from '../video/video'
51import { ActorModel } from '../activitypub/actor'
52import { ActorFollowModel } from '../activitypub/actor-follow'
dc133480 53import { VideoImportModel } from '../video/video-import'
1eddc9a7 54import { UserAdminFlag } from '../../../shared/models/users/user-flag.model'
503c6f44 55import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
7cd4d2ba 56import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
453e83ea 57import * as Bluebird from 'bluebird'
1ca9f7c3
C
58import {
59 MUserDefault,
60 MUserFormattable,
61 MUserId,
62 MUserNotifSettingChannelDefault,
63 MUserWithNotificationSetting
64} from '@server/typings/models'
3fd3ab2d 65
9c2e0dbf
C
66enum ScopeNames {
67 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
68}
69
3acc5084 70@DefaultScope(() => ({
d48ff09d
C
71 include: [
72 {
3acc5084 73 model: AccountModel,
d48ff09d 74 required: true
cef534ed
C
75 },
76 {
3acc5084 77 model: UserNotificationSettingModel,
cef534ed 78 required: true
d48ff09d
C
79 }
80 ]
3acc5084
C
81}))
82@Scopes(() => ({
9c2e0dbf 83 [ScopeNames.WITH_VIDEO_CHANNEL]: {
d48ff09d
C
84 include: [
85 {
3acc5084 86 model: AccountModel,
d48ff09d 87 required: true,
3acc5084 88 include: [ VideoChannelModel ]
cef534ed
C
89 },
90 {
3acc5084 91 model: UserNotificationSettingModel,
cef534ed 92 required: true
d48ff09d 93 }
3acc5084 94 ]
d48ff09d 95 }
3acc5084 96}))
3fd3ab2d
C
97@Table({
98 tableName: 'user',
99 indexes: [
feb4bdfd 100 {
3fd3ab2d
C
101 fields: [ 'username' ],
102 unique: true
feb4bdfd
C
103 },
104 {
3fd3ab2d
C
105 fields: [ 'email' ],
106 unique: true
feb4bdfd 107 }
e02643f3 108 ]
3fd3ab2d
C
109})
110export 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
d1ab89de
C
127 @AllowNull(true)
128 @IsEmail
129 @Column(DataType.STRING(400))
130 pendingEmail: string
131
d9eaee39
JM
132 @AllowNull(true)
133 @Default(null)
1735c825 134 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
d9eaee39
JM
135 @Column
136 emailVerified: boolean
137
3fd3ab2d 138 @AllowNull(false)
0883b324 139 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
1735c825 140 @Column(DataType.ENUM(...values(NSFW_POLICY_TYPES)))
0883b324 141 nsfwPolicy: NSFWPolicyType
3fd3ab2d 142
64cc5e85 143 @AllowNull(false)
0229b014 144 @Default(true)
ed638e53
RK
145 @Is('UserWebTorrentEnabled', value => throwIfNotValid(value, isUserWebTorrentEnabledValid, 'WebTorrent enabled'))
146 @Column
147 webTorrentEnabled: boolean
64cc5e85 148
8b9a525a
C
149 @AllowNull(false)
150 @Default(true)
151 @Is('UserVideosHistoryEnabled', value => throwIfNotValid(value, isUserVideosHistoryEnabledValid, 'Videos history enabled'))
152 @Column
153 videosHistoryEnabled: boolean
154
7efe153b
AL
155 @AllowNull(false)
156 @Default(true)
157 @Is('UserAutoPlayVideo', value => throwIfNotValid(value, isUserAutoPlayVideoValid, 'auto play video boolean'))
158 @Column
159 autoPlayVideo: boolean
160
3caf77d3
C
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
1eddc9a7
C
167 @AllowNull(false)
168 @Default(UserAdminFlag.NONE)
169 @Is('UserAdminFlags', value => throwIfNotValid(value, isUserAdminFlagsValid, 'user admin flags'))
170 @Column
171 adminFlags?: UserAdminFlag
172
e6921918
C
173 @AllowNull(false)
174 @Default(false)
175 @Is('UserBlocked', value => throwIfNotValid(value, isUserBlockedValid, 'blocked boolean'))
176 @Column
177 blocked: boolean
178
eacb25c4
C
179 @AllowNull(true)
180 @Default(null)
1735c825 181 @Is('UserBlockedReason', value => throwIfNotValid(value, isUserBlockedReasonValid, 'blocked reason', true))
eacb25c4
C
182 @Column
183 blockedReason: string
184
3fd3ab2d
C
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
bee0abff
FA
195 @AllowNull(false)
196 @Is('UserVideoQuotaDaily', value => throwIfNotValid(value, isUserVideoQuotaDailyValid, 'video quota daily'))
197 @Column(DataType.BIGINT)
198 videoQuotaDaily: number
199
7cd4d2ba 200 @AllowNull(false)
ffb321be 201 @Default(DEFAULT_THEME_NAME)
503c6f44 202 @Is('UserTheme', value => throwIfNotValid(value, isThemeNameValid, 'theme'))
7cd4d2ba
C
203 @Column
204 theme: string
205
3fd3ab2d
C
206 @CreatedAt
207 createdAt: Date
208
209 @UpdatedAt
210 updatedAt: Date
211
212 @HasOne(() => AccountModel, {
213 foreignKey: 'userId',
f05a1c30
C
214 onDelete: 'cascade',
215 hooks: true
3fd3ab2d
C
216 })
217 Account: AccountModel
69b0a27c 218
cef534ed
C
219 @HasOne(() => UserNotificationSettingModel, {
220 foreignKey: 'userId',
221 onDelete: 'cascade',
222 hooks: true
223 })
224 NotificationSetting: UserNotificationSettingModel
225
dc133480
C
226 @HasMany(() => VideoImportModel, {
227 foreignKey: 'userId',
228 onDelete: 'cascade'
229 })
230 VideoImports: VideoImportModel[]
231
3fd3ab2d
C
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 }
59557c46 248 }
26d7d31b 249
f201a749 250 @AfterUpdate
d175a6f7 251 @AfterDestroy
f201a749
C
252 static removeTokenCache (instance: UserModel) {
253 return clearCacheByUserId(instance.id)
254 }
255
3fd3ab2d
C
256 static countTotal () {
257 return this.count()
258 }
954605a8 259
24b9417c
C
260 static listForApi (start: number, count: number, sort: string, search?: string) {
261 let where = undefined
262 if (search) {
263 where = {
3acc5084 264 [Op.or]: [
24b9417c
C
265 {
266 email: {
3acc5084 267 [Op.iLike]: '%' + search + '%'
24b9417c
C
268 }
269 },
270 {
271 username: {
3acc5084 272 [ Op.iLike ]: '%' + search + '%'
24b9417c
C
273 }
274 }
275 ]
276 }
277 }
278
3acc5084 279 const query: FindOptions = {
a76138ff
C
280 attributes: {
281 include: [
282 [
3acc5084 283 literal(
a76138ff 284 '(' +
8b604880
C
285 'SELECT COALESCE(SUM("size"), 0) ' +
286 'FROM (' +
a76138ff
C
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'
3acc5084 296 ]
a76138ff
C
297 ]
298 },
3fd3ab2d
C
299 offset: start,
300 limit: count,
24b9417c
C
301 order: getSort(sort),
302 where
3fd3ab2d 303 }
72c7248b 304
3fd3ab2d
C
305 return UserModel.findAndCountAll(query)
306 .then(({ rows, count }) => {
307 return {
308 data: rows,
309 total: count
310 }
72c7248b 311 })
72c7248b
C
312 }
313
453e83ea 314 static listWithRight (right: UserRight): Bluebird<MUserDefault[]> {
ba75d268
C
315 const roles = Object.keys(USER_ROLE_LABELS)
316 .map(k => parseInt(k, 10) as UserRole)
317 .filter(role => hasUserRight(role, right))
318
ba75d268 319 const query = {
ba75d268
C
320 where: {
321 role: {
3acc5084 322 [Op.in]: roles
ba75d268
C
323 }
324 }
325 }
326
cef534ed
C
327 return UserModel.findAll(query)
328 }
329
453e83ea 330 static listUserSubscribersOf (actorId: number): Bluebird<MUserWithNotificationSetting[]> {
cef534ed
C
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)
ba75d268
C
367 }
368
453e83ea 369 static listByUsernames (usernames: string[]): Bluebird<MUserDefault[]> {
f7cc67b4
C
370 const query = {
371 where: {
372 username: usernames
373 }
374 }
375
376 return UserModel.findAll(query)
377 }
378
453e83ea 379 static loadById (id: number): Bluebird<MUserDefault> {
9b39106d 380 return UserModel.findByPk(id)
3fd3ab2d 381 }
feb4bdfd 382
453e83ea 383 static loadByUsername (username: string): Bluebird<MUserDefault> {
3fd3ab2d
C
384 const query = {
385 where: {
50b4dcce 386 username: { [ Op.iLike ]: username }
d48ff09d 387 }
3fd3ab2d 388 }
089ff2f2 389
3fd3ab2d 390 return UserModel.findOne(query)
feb4bdfd
C
391 }
392
0283eaac 393 static loadByUsernameAndPopulateChannels (username: string): Bluebird<MUserNotifSettingChannelDefault> {
3fd3ab2d
C
394 const query = {
395 where: {
50b4dcce 396 username: { [ Op.iLike ]: username }
d48ff09d 397 }
3fd3ab2d 398 }
9bd26629 399
9c2e0dbf 400 return UserModel.scope(ScopeNames.WITH_VIDEO_CHANNEL).findOne(query)
feb4bdfd
C
401 }
402
453e83ea 403 static loadByEmail (email: string): Bluebird<MUserDefault> {
ecb4e35f
C
404 const query = {
405 where: {
406 email
407 }
408 }
409
410 return UserModel.findOne(query)
411 }
412
453e83ea 413 static loadByUsernameOrEmail (username: string, email?: string): Bluebird<MUserDefault> {
ba12e8b3
C
414 if (!email) email = username
415
3fd3ab2d 416 const query = {
3fd3ab2d 417 where: {
50b4dcce 418 [ Op.or ]: [ { username: { [ Op.iLike ]: username } }, { email } ]
3fd3ab2d 419 }
6fcd19ba 420 }
69b0a27c 421
d48ff09d 422 return UserModel.findOne(query)
72c7248b
C
423 }
424
453e83ea 425 static loadByVideoId (videoId: number): Bluebird<MUserDefault> {
cef534ed
C
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
453e83ea 456 static loadByVideoImportId (videoImportId: number): Bluebird<MUserDefault> {
dc133480
C
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
453e83ea 473 static loadByChannelActorId (videoChannelActorId: number): Bluebird<MUserDefault> {
f7cc67b4
C
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
453e83ea 497 static loadByAccountActorId (accountActorId: number): Bluebird<MUserDefault> {
f7cc67b4
C
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
453e83ea 514 static getOriginalVideoFileTotalFromUser (user: MUserId) {
3fd3ab2d 515 // Don't use sequelize because we need to use a sub query
8b604880 516 const query = UserModel.generateUserQuotaBaseSQL()
bee0abff 517
8b604880 518 return UserModel.getTotalRawQuery(query, user.id)
bee0abff
FA
519 }
520
8b604880 521 // Returns cumulative size of all video files uploaded in the last 24 hours.
453e83ea 522 static getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
bee0abff 523 // Don't use sequelize because we need to use a sub query
8b604880 524 const query = UserModel.generateUserQuotaBaseSQL('"video"."createdAt" > now() - interval \'24 hours\'')
68a3b9f2 525
8b604880 526 return UserModel.getTotalRawQuery(query, user.id)
72c7248b
C
527 }
528
09cababd
C
529 static async getStats () {
530 const totalUsers = await UserModel.count()
531
532 return {
533 totalUsers
534 }
535 }
536
5cf84858
C
537 static autoComplete (search: string) {
538 const query = {
539 where: {
540 username: {
3acc5084 541 [ Op.like ]: `%${search}%`
5cf84858
C
542 }
543 },
544 limit: 10
545 }
546
547 return UserModel.findAll(query)
548 .then(u => u.map(u => u.username))
549 }
550
3fd3ab2d
C
551 hasRight (right: UserRight) {
552 return hasUserRight(this.role, right)
553 }
72c7248b 554
1eddc9a7
C
555 hasAdminFlag (flag: UserAdminFlag) {
556 return this.adminFlags & flag
557 }
558
3fd3ab2d
C
559 isPasswordMatch (password: string) {
560 return comparePassword(password, this.password)
feb4bdfd
C
561 }
562
1ca9f7c3
C
563 toSummaryJSON
564
565 toFormattedJSON (this: MUserFormattable, parameters: { withAdminFlags?: boolean } = {}): User {
a76138ff 566 const videoQuotaUsed = this.get('videoQuotaUsed')
bee0abff 567 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
a76138ff 568
3fd3ab2d
C
569 const json = {
570 id: this.id,
571 username: this.username,
572 email: this.email,
d1ab89de 573 pendingEmail: this.pendingEmail,
d9eaee39 574 emailVerified: this.emailVerified,
0883b324 575 nsfwPolicy: this.nsfwPolicy,
ed638e53 576 webTorrentEnabled: this.webTorrentEnabled,
276d9652 577 videosHistoryEnabled: this.videosHistoryEnabled,
7efe153b 578 autoPlayVideo: this.autoPlayVideo,
3caf77d3 579 videoLanguages: this.videoLanguages,
3fd3ab2d 580 role: this.role,
ffb321be 581 theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
3fd3ab2d
C
582 roleLabel: USER_ROLE_LABELS[ this.role ],
583 videoQuota: this.videoQuota,
bee0abff 584 videoQuotaDaily: this.videoQuotaDaily,
3fd3ab2d 585 createdAt: this.createdAt,
eacb25c4
C
586 blocked: this.blocked,
587 blockedReason: this.blockedReason,
c5911fd3 588 account: this.Account.toFormattedJSON(),
cef534ed 589 notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
a76138ff 590 videoChannels: [],
bee0abff 591 videoQuotaUsed: videoQuotaUsed !== undefined
1735c825 592 ? parseInt(videoQuotaUsed + '', 10)
bee0abff
FA
593 : undefined,
594 videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined
1735c825 595 ? parseInt(videoQuotaUsedDaily + '', 10)
bee0abff 596 : undefined
3fd3ab2d
C
597 }
598
1eddc9a7
C
599 if (parameters.withAdminFlags) {
600 Object.assign(json, { adminFlags: this.adminFlags })
601 }
602
3fd3ab2d 603 if (Array.isArray(this.Account.VideoChannels) === true) {
c5911fd3 604 json.videoChannels = this.Account.VideoChannels
3fd3ab2d
C
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
ad4a8a1c 609
3fd3ab2d
C
610 return 1
611 })
ad4a8a1c 612 }
3fd3ab2d
C
613
614 return json
ad4a8a1c
C
615 }
616
bee0abff
FA
617 async isAbleToUploadVideo (videoFile: { size: number }) {
618 if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
b0f9f39e 619
bee0abff
FA
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
bee0abff 627
3acc5084
C
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
b0f9f39e 632 }
8b604880
C
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 },
3acc5084 651 type: QueryTypes.SELECT as QueryTypes.SELECT
8b604880
C
652 }
653
3acc5084 654 return UserModel.sequelize.query<{ total: string }>(query, options)
8b604880
C
655 .then(([ { total } ]) => {
656 if (total === null) return 0
657
3acc5084 658 return parseInt(total, 10)
8b604880
C
659 })
660 }
b0f9f39e 661}