From bee0abffff73804d816b90c7fd599e0a51c09d61 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Tue, 28 Aug 2018 02:01:35 -0500 Subject: [PATCH] Implement daily upload limit (#956) * Implement daily upload limit (ref #652) * remove duplicate code * review fixes * fix tests? * whitespace fixes, finish leftover todo * fix tests * added some new tests * use different config value for tests * remove todo --- .../edit-custom-config.component.html | 14 ++++ .../edit-custom-config.component.ts | 24 +++++-- .../users/user-edit/user-edit.component.html | 9 +++ .../app/+admin/users/user-edit/user-edit.ts | 17 ++--- .../users/user-edit/user-update.component.ts | 9 ++- client/src/app/core/server/server.service.ts | 3 +- .../user-validators.service.ts | 8 +++ client/src/app/shared/users/user.model.ts | 3 + .../video-upload.component.ts | 22 +++++- config/default.yaml | 1 + config/production.yaml.example | 1 + server/controllers/api/config.ts | 7 +- server/controllers/api/users/index.ts | 7 +- server/controllers/api/users/me.ts | 4 +- server/helpers/custom-validators/users.ts | 5 ++ server/initializers/checker.ts | 2 +- server/initializers/constants.ts | 4 +- server/initializers/installer.ts | 3 +- .../migrations/0260-upload_quota_daily.ts | 23 +++++++ server/middlewares/validators/users.ts | 5 +- server/models/account/user.ts | 68 ++++++++++++++++--- server/tests/api/check-params/config.ts | 3 +- server/tests/api/check-params/users.ts | 56 ++++++++++++++- server/tests/api/server/config.ts | 5 +- server/tests/utils/server/config.ts | 3 +- server/tests/utils/users/users.ts | 6 +- shared/models/server/custom-config.model.ts | 1 + shared/models/server/server-config.model.ts | 1 + shared/models/users/user-create.model.ts | 1 + shared/models/users/user-update.model.ts | 1 + shared/models/users/user-video-quota.model.ts | 1 + shared/models/users/user.model.ts | 1 + 32 files changed, 273 insertions(+), 45 deletions(-) create mode 100644 server/initializers/migrations/0260-upload_quota_daily.ts diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 49b89cef4..ca7890d84 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -142,6 +142,20 @@ {{ formErrors.userVideoQuota }} + +
+ +
+ +
+
+ {{ formErrors.userVideoQuotaDaily }} +
+
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index fd6784415..3b6dabcb9 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -15,10 +15,7 @@ import { BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/ styleUrls: [ './edit-custom-config.component.scss' ] }) export class EditCustomConfigComponent extends FormReactive implements OnInit { - customConfig: CustomConfig - resolutions = [ '240p', '360p', '480p', '720p', '1080p' ] - - videoQuotaOptions = [ + static videoQuotaOptions = [ { value: -1, label: 'Unlimited' }, { value: 0, label: '0' }, { value: 100 * 1024 * 1024, label: '100MB' }, @@ -28,6 +25,20 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { { value: 20 * 1024 * 1024 * 1024, label: '20GB' }, { value: 50 * 1024 * 1024 * 1024, label: '50GB' } ] + static videoQuotaDailyOptions = [ + { value: -1, label: 'Unlimited' }, + { value: 0, label: '0' }, + { value: 10 * 1024 * 1024, label: '10MB' }, + { value: 50 * 1024 * 1024, label: '50MB' }, + { value: 100 * 1024 * 1024, label: '100MB' }, + { value: 500 * 1024 * 1024, label: '500MB' }, + { value: 2 * 1024 * 1024 * 1024, label: '2GB' }, + { value: 5 * 1024 * 1024 * 1024, label: '5GB' } + ] + + customConfig: CustomConfig + resolutions = [ '240p', '360p', '480p', '720p', '1080p' ] + transcodingThreadOptions = [ { value: 0, label: 'Auto (via ffmpeg)' }, { value: 1, label: '1' }, @@ -75,6 +86,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { importVideosTorrentEnabled: null, adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL, userVideoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, + userVideoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, transcodingThreads: this.customConfigValidatorsService.TRANSCODING_THREADS, transcodingEnabled: null, customizationJavascript: null, @@ -173,7 +185,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { email: this.form.value['adminEmail'] }, user: { - videoQuota: this.form.value['userVideoQuota'] + videoQuota: this.form.value['userVideoQuota'], + videoQuotaDaily: this.form.value['userVideoQuotaDaily'] }, transcoding: { enabled: this.form.value['transcodingEnabled'], @@ -231,6 +244,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { signupLimit: this.customConfig.signup.limit, adminEmail: this.customConfig.admin.email, userVideoQuota: this.customConfig.user.videoQuota, + userVideoQuotaDaily: this.customConfig.user.videoQuotaDaily, transcodingThreads: this.customConfig.transcoding.threads, transcodingEnabled: this.customConfig.transcoding.enabled, customizationJavascript: this.customConfig.instance.customizations.javascript, diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html index 4626a40c9..bb745d6aa 100644 --- a/client/src/app/+admin/users/user-edit/user-edit.component.html +++ b/client/src/app/+admin/users/user-edit/user-edit.component.html @@ -61,6 +61,15 @@ + + +
+ +
Transcoding is enabled on server. The video quota only take in account original video.
diff --git a/client/src/app/+admin/users/user-edit/user-edit.ts b/client/src/app/+admin/users/user-edit/user-edit.ts index ea8c733c3..4e7ca8a1b 100644 --- a/client/src/app/+admin/users/user-edit/user-edit.ts +++ b/client/src/app/+admin/users/user-edit/user-edit.ts @@ -1,18 +1,15 @@ import { ServerService } from '../../../core' import { FormReactive } from '../../../shared' import { USER_ROLE_LABELS, VideoResolution } from '../../../../../../shared' +import { EditCustomConfigComponent } from '../../../+admin/config/edit-custom-config/' export abstract class UserEdit extends FormReactive { - videoQuotaOptions = [ - { value: -1, label: 'Unlimited' }, - { value: 0, label: '0' }, - { value: 100 * 1024 * 1024, label: '100MB' }, - { value: 500 * 1024 * 1024, label: '500MB' }, - { value: 1024 * 1024 * 1024, label: '1GB' }, - { value: 5 * 1024 * 1024 * 1024, label: '5GB' }, - { value: 20 * 1024 * 1024 * 1024, label: '20GB' }, - { value: 50 * 1024 * 1024 * 1024, label: '50GB' } - ].map(q => ({ value: q.value.toString(), label: q.label })) // Used by a HTML select, so convert key into strings + + // These are used by a HTML select, so convert key into strings + videoQuotaOptions = EditCustomConfigComponent.videoQuotaOptions + .map(q => ({ value: q.value.toString(), label: q.label })) + videoQuotaDailyOptions = EditCustomConfigComponent.videoQuotaDailyOptions + .map(q => ({ value: q.value.toString(), label: q.label })) roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts index 06bde582e..5821229b3 100644 --- a/client/src/app/+admin/users/user-edit/user-update.component.ts +++ b/client/src/app/+admin/users/user-edit/user-update.component.ts @@ -36,11 +36,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { } ngOnInit () { - const defaultValues = { videoQuota: '-1' } + const defaultValues = { videoQuota: '-1', videoQuotaDaily: '-1' } this.buildForm({ email: this.userValidatorsService.USER_EMAIL, role: this.userValidatorsService.USER_ROLE, - videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA + videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, + videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY }, defaultValues) this.paramsSub = this.route.params.subscribe(routeParams => { @@ -64,6 +65,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { // A select in HTML is always mapped as a string, we convert it to number userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) + userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) this.userService.updateUser(this.userId, userUpdate).subscribe( () => { @@ -93,7 +95,8 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { this.form.patchValue({ email: userJson.email, role: userJson.role, - videoQuota: userJson.videoQuota + videoQuota: userJson.videoQuota, + videoQuotaDaily: userJson.videoQuotaDaily }) } } diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 7823fa80e..a1ce12069 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts @@ -67,7 +67,8 @@ export class ServerService { } }, user: { - videoQuota: -1 + videoQuota: -1, + videoQuotaDaily: -1 }, import: { videos: { diff --git a/client/src/app/shared/forms/form-validators/user-validators.service.ts b/client/src/app/shared/forms/form-validators/user-validators.service.ts index ec9566ef3..424553d74 100644 --- a/client/src/app/shared/forms/form-validators/user-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/user-validators.service.ts @@ -9,6 +9,7 @@ export class UserValidatorsService { readonly USER_EMAIL: BuildFormValidator readonly USER_PASSWORD: BuildFormValidator readonly USER_VIDEO_QUOTA: BuildFormValidator + readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator readonly USER_ROLE: BuildFormValidator readonly USER_DISPLAY_NAME: BuildFormValidator readonly USER_DESCRIPTION: BuildFormValidator @@ -61,6 +62,13 @@ export class UserValidatorsService { 'min': this.i18n('Quota must be greater than -1.') } } + this.USER_VIDEO_QUOTA_DAILY = { + VALIDATORS: [ Validators.required, Validators.min(-1) ], + MESSAGES: { + 'required': this.i18n('Daily upload limit is required.'), + 'min': this.i18n('Daily upload limit must be greater than -1.') + } + } this.USER_ROLE = { VALIDATORS: [ Validators.required ], diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index 2748001d0..877f1bf3a 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts @@ -16,6 +16,7 @@ export type UserConstructorHash = { email: string, role: UserRole, videoQuota?: number, + videoQuotaDaily?: number, nsfwPolicy?: NSFWPolicyType, autoPlayVideo?: boolean, createdAt?: Date, @@ -33,6 +34,7 @@ export class User implements UserServerModel { nsfwPolicy: NSFWPolicyType autoPlayVideo: boolean videoQuota: number + videoQuotaDaily: number account: Account videoChannels: VideoChannel[] createdAt: Date @@ -48,6 +50,7 @@ export class User implements UserServerModel { this.videoChannels = hash.videoChannels this.videoQuota = hash.videoQuota + this.videoQuotaDaily = hash.videoQuotaDaily this.nsfwPolicy = hash.nsfwPolicy this.autoPlayVideo = hash.autoPlayVideo this.createdAt = hash.createdAt diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts index 3ec89ff62..c9ab35b1d 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts @@ -31,6 +31,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY userVideoQuotaUsed = 0 + userVideoQuotaUsedDaily = 0 isUploadingVideo = false isUpdatingVideo = false @@ -68,6 +69,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy this.userService.getMyVideoQuotaUsed() .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed) + + this.userService.getMyVideoQuotaUsed() + .subscribe(data => this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily) } ngOnDestroy () { @@ -115,10 +119,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy return } + const bytePipes = new BytesPipe() const videoQuota = this.authService.getUser().videoQuota if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { - const bytePipes = new BytesPipe() - const msg = this.i18n( 'Your video quota is exceeded with this video (video size: {{ videoSize }}, used: {{ videoQuotaUsed }}, quota: {{ videoQuota }})', { @@ -131,6 +134,21 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy return } + const videoQuotaDaily = this.authService.getUser().videoQuotaDaily + if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { + const msg = this.i18n( + 'Your daily video quota is exceeded with this video (video size: {{ videoSize }}, ' + + 'used: {{ videoQuotaUsedDaily }}, quota: {{ videoQuotaDaily }})', + { + videoSize: bytePipes.transform(videofile.size, 0), + videoQuotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0), + videoQuotaDaily: bytePipes.transform(videoQuotaDaily, 0) + } + ) + this.notificationsService.error(this.i18n('Error'), msg) + return + } + const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '') let name: string diff --git a/config/default.yaml b/config/default.yaml index 6a02f254d..7799ea927 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -83,6 +83,7 @@ user: # Default value of maximum video BYTES the user can upload (does not take into account transcoded files). # -1 == unlimited video_quota: -1 + video_quota_daily: -1 # If enabled, the video will be transcoded to mp4 (x264) with "faststart" flag # In addition, if some resolutions are enabled the mp4 video file will be transcoded to these new resolutions. diff --git a/config/production.yaml.example b/config/production.yaml.example index fc698ae96..33a26dec1 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -96,6 +96,7 @@ user: # Default value of maximum video BYTES the user can upload (does not take into account transcoded files). # -1 == unlimited video_quota: -1 + video_quota_daily: -1 # If enabled, the video will be transcoded to mp4 (x264) with "faststart" flag # In addition, if some resolutions are enabled the mp4 video file will be transcoded to these new resolutions. diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index b25f739bb..3fd355e6d 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -103,7 +103,8 @@ async function getConfig (req: express.Request, res: express.Response, next: exp } }, user: { - videoQuota: CONFIG.USER.VIDEO_QUOTA + videoQuota: CONFIG.USER.VIDEO_QUOTA, + videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY } } @@ -154,6 +155,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response, toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10) toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10) toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10) + toUpdate.user.videoQuotaDaily = parseInt('' + toUpdate.user.videoQuotaDaily, 10) toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) // camelCase to snake_case key @@ -223,7 +225,8 @@ function customConfig (): CustomConfig { email: CONFIG.ADMIN.EMAIL }, user: { - videoQuota: CONFIG.USER.VIDEO_QUOTA + videoQuota: CONFIG.USER.VIDEO_QUOTA, + videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY }, transcoding: { enabled: CONFIG.TRANSCODING.ENABLED, diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 608d439ac..25d51ae5e 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -134,7 +134,8 @@ async function createUser (req: express.Request, res: express.Response) { nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, autoPlayVideo: true, role: body.role, - videoQuota: body.videoQuota + videoQuota: body.videoQuota, + videoQuotaDaily: body.videoQuotaDaily }) const { user, account } = await createUserAccountAndChannel(userToCreate) @@ -163,7 +164,8 @@ async function registerUser (req: express.Request, res: express.Response) { nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, autoPlayVideo: true, role: UserRole.USER, - videoQuota: CONFIG.USER.VIDEO_QUOTA + videoQuota: CONFIG.USER.VIDEO_QUOTA, + videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY }) const { user } = await createUserAccountAndChannel(userToCreate) @@ -219,6 +221,7 @@ async function updateUser (req: express.Request, res: express.Response, next: ex if (body.email !== undefined) userToUpdate.email = body.email if (body.videoQuota !== undefined) userToUpdate.videoQuota = body.videoQuota + if (body.videoQuotaDaily !== undefined) userToUpdate.videoQuotaDaily = body.videoQuotaDaily if (body.role !== undefined) userToUpdate.role = body.role const user = await userToUpdate.save() diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 000c706b5..0f18b42f9 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -283,9 +283,11 @@ async function getUserVideoQuotaUsed (req: express.Request, res: express.Respons // We did not load channels in res.locals.user const user = await UserModel.loadByUsernameAndPopulateChannels(res.locals.oauth.token.user.username) const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user) + const videoQuotaUsedDaily = await UserModel.getOriginalVideoFileTotalDailyFromUser(user) const data: UserVideoQuota = { - videoQuotaUsed + videoQuotaUsed, + videoQuotaUsedDaily } return res.json(data) } diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts index c3cdefd4e..8d6247e41 100644 --- a/server/helpers/custom-validators/users.ts +++ b/server/helpers/custom-validators/users.ts @@ -15,6 +15,10 @@ function isUserVideoQuotaValid (value: string) { return exists(value) && validator.isInt(value + '', USERS_CONSTRAINTS_FIELDS.VIDEO_QUOTA) } +function isUserVideoQuotaDailyValid (value: string) { + return exists(value) && validator.isInt(value + '', USERS_CONSTRAINTS_FIELDS.VIDEO_QUOTA_DAILY) +} + function isUserUsernameValid (value: string) { const max = USERS_CONSTRAINTS_FIELDS.USERNAME.max const min = USERS_CONSTRAINTS_FIELDS.USERNAME.min @@ -66,6 +70,7 @@ export { isUserBlockedReasonValid, isUserRoleValid, isUserVideoQuotaValid, + isUserVideoQuotaDailyValid, isUserUsernameValid, isUserNSFWPolicyValid, isUserAutoPlayVideoValid, diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts index 608123607..916e9067e 100644 --- a/server/initializers/checker.ts +++ b/server/initializers/checker.ts @@ -47,7 +47,7 @@ function checkMissedConfig () { 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache', 'log.level', - 'user.video_quota', + 'user.video_quota', 'user.video_quota_daily', 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 'transcoding.enabled', 'transcoding.threads', diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index a0dd78f42..4111d04ec 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -202,7 +202,8 @@ const CONFIG = { } }, USER: { - get VIDEO_QUOTA () { return config.get('user.video_quota') } + get VIDEO_QUOTA () { return config.get('user.video_quota') }, + get VIDEO_QUOTA_DAILY () { return config.get('user.video_quota_daily') } }, TRANSCODING: { get ENABLED () { return config.get('transcoding.enabled') }, @@ -263,6 +264,7 @@ const CONSTRAINTS_FIELDS = { USERNAME: { min: 3, max: 20 }, // Length PASSWORD: { min: 6, max: 255 }, // Length VIDEO_QUOTA: { min: -1 }, + VIDEO_QUOTA_DAILY: { min: -1 }, BLOCKED_REASON: { min: 3, max: 250 } // Length }, VIDEO_ABUSES: { diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts index e319164e4..d4aaec8fe 100644 --- a/server/initializers/installer.ts +++ b/server/initializers/installer.ts @@ -123,7 +123,8 @@ async function createOAuthAdminIfNotExist () { password, role, nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, - videoQuota: -1 + videoQuota: -1, + videoQuotaDaily: -1 } const user = new UserModel(userData) diff --git a/server/initializers/migrations/0260-upload_quota_daily.ts b/server/initializers/migrations/0260-upload_quota_daily.ts new file mode 100644 index 000000000..d25154ba6 --- /dev/null +++ b/server/initializers/migrations/0260-upload_quota_daily.ts @@ -0,0 +1,23 @@ +import * as Sequelize from 'sequelize' +import { CONSTRAINTS_FIELDS } from '../constants' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + { + const data = { + type: Sequelize.BIGINT, + allowNull: false, + defaultValue: -1 + } + await utils.queryInterface.addColumn('user', 'videoQuotaDaily', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { up, down } diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index c8baf22e2..6c5e783e9 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -12,7 +12,8 @@ import { isUserPasswordValid, isUserRoleValid, isUserUsernameValid, - isUserVideoQuotaValid + isUserVideoQuotaValid, + isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users' import { isVideoExist } from '../../helpers/custom-validators/videos' import { logger } from '../../helpers/logger' @@ -27,6 +28,7 @@ const usersAddValidator = [ body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'), body('email').isEmail().withMessage('Should have a valid email'), body('videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'), + body('videoQuotaDaily').custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily user quota'), body('role').custom(isUserRoleValid).withMessage('Should have a valid role'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -112,6 +114,7 @@ const usersUpdateValidator = [ param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), body('email').optional().isEmail().withMessage('Should have a valid email attribute'), body('videoQuota').optional().custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'), + body('videoQuotaDaily').optional().custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily user quota'), body('role').optional().custom(isUserRoleValid).withMessage('Should have a valid role'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 0150df4ce..178012eae 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -27,7 +27,8 @@ import { isUserPasswordValid, isUserRoleValid, isUserUsernameValid, - isUserVideoQuotaValid + isUserVideoQuotaValid, + isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users' import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' import { OAuthTokenModel } from '../oauth/oauth-token' @@ -124,6 +125,11 @@ export class UserModel extends Model { @Column(DataType.BIGINT) videoQuota: number + @AllowNull(false) + @Is('UserVideoQuotaDaily', value => throwIfNotValid(value, isUserVideoQuotaDailyValid, 'video quota daily')) + @Column(DataType.BIGINT) + videoQuotaDaily: number + @CreatedAt createdAt: Date @@ -271,7 +277,32 @@ export class UserModel extends Model { 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + - 'WHERE "account"."userId" = $userId GROUP BY "video"."id") t' + 'WHERE "account"."userId" = $userId ' + + 'GROUP BY "video"."id") t' + + const options = { + bind: { userId: user.id }, + type: Sequelize.QueryTypes.SELECT + } + return UserModel.sequelize.query(query, options) + .then(([ { total } ]) => { + if (total === null) return 0 + + return parseInt(total, 10) + }) + } + + // Returns comulative size of all video files uploaded in the last 24 hours. + static getOriginalVideoFileTotalDailyFromUser (user: UserModel) { + // Don't use sequelize because we need to use a sub query + const query = 'SELECT SUM("size") AS "total" FROM ' + + '(SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' + + 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + + 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + + 'WHERE "account"."userId" = $userId ' + + 'AND "video"."createdAt" > now() - interval \'24 hours\'' + + 'GROUP BY "video"."id") t' const options = { bind: { userId: user.id }, @@ -303,6 +334,7 @@ export class UserModel extends Model { toFormattedJSON (): User { const videoQuotaUsed = this.get('videoQuotaUsed') + const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') const json = { id: this.id, @@ -313,12 +345,18 @@ export class UserModel extends Model { role: this.role, roleLabel: USER_ROLE_LABELS[ this.role ], videoQuota: this.videoQuota, + videoQuotaDaily: this.videoQuotaDaily, createdAt: this.createdAt, blocked: this.blocked, blockedReason: this.blockedReason, account: this.Account.toFormattedJSON(), videoChannels: [], - videoQuotaUsed: videoQuotaUsed !== undefined ? parseInt(videoQuotaUsed, 10) : undefined + videoQuotaUsed: videoQuotaUsed !== undefined + ? parseInt(videoQuotaUsed, 10) + : undefined, + videoQuotaUsedDaily: videoQuotaUsedDaily !== undefined + ? parseInt(videoQuotaUsedDaily, 10) + : undefined } if (Array.isArray(this.Account.VideoChannels) === true) { @@ -335,12 +373,24 @@ export class UserModel extends Model { return json } - isAbleToUploadVideo (videoFile: { size: number }) { - if (this.videoQuota === -1) return Promise.resolve(true) + async isAbleToUploadVideo (videoFile: { size: number }) { + if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true) - return UserModel.getOriginalVideoFileTotalFromUser(this) - .then(totalBytes => { - return (videoFile.size + totalBytes) < this.videoQuota - }) + const [ totalBytes, totalBytesDaily ] = await Promise.all([ + UserModel.getOriginalVideoFileTotalFromUser(this), + UserModel.getOriginalVideoFileTotalDailyFromUser(this) + ]) + + const uploadedTotal = videoFile.size + totalBytes + const uploadedDaily = videoFile.size + totalBytesDaily + if (this.videoQuotaDaily === -1) { + return uploadedTotal < this.videoQuota + } + if (this.videoQuota === -1) { + return uploadedDaily < this.videoQuotaDaily + } + + return (uploadedTotal < this.videoQuota) && + (uploadedDaily < this.videoQuotaDaily) } } diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index b26dfa252..ecfb76d47 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts @@ -48,7 +48,8 @@ describe('Test config API validators', function () { email: 'superadmin1@example.com' }, user: { - videoQuota: 5242881 + videoQuota: 5242881, + videoQuotaDaily: 318742 }, transcoding: { enabled: true, diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts index b3fb61f6c..8b2ed1b04 100644 --- a/server/tests/api/check-params/users.ts +++ b/server/tests/api/check-params/users.ts @@ -94,6 +94,7 @@ describe('Test users API validators', function () { email: 'test@example.com', password: 'my super password', videoQuota: -1, + videoQuotaDaily: -1, role: UserRole.USER } @@ -173,12 +174,24 @@ describe('Test users API validators', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) + it('Should fail without a videoQuotaDaily', async function () { + const fields = omit(baseCorrectParams, 'videoQuotaDaily') + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + it('Should fail with an invalid videoQuota', async function () { const fields = immutableAssign(baseCorrectParams, { videoQuota: -5 }) await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) + it('Should fail with an invalid videoQuotaDaily', async function () { + const fields = immutableAssign(baseCorrectParams, { videoQuotaDaily: -7 }) + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + it('Should fail without a user role', async function () { const fields = omit(baseCorrectParams, 'role') @@ -607,7 +620,7 @@ describe('Test users API validators', function () { }) describe('When having a video quota', function () { - it('Should fail with a user having too many video', async function () { + it('Should fail with a user having too many videos', async function () { await updateUser({ url: server.url, userId: rootId, @@ -618,7 +631,7 @@ describe('Test users API validators', function () { await uploadVideo(server.url, server.accessToken, {}, 403) }) - it('Should fail with a registered user having too many video', async function () { + it('Should fail with a registered user having too many videos', async function () { this.timeout(30000) const user = { @@ -663,6 +676,45 @@ describe('Test users API validators', function () { }) }) + describe('When having a daily video quota', function () { + it('Should fail with a user having too many videos', async function () { + await updateUser({ + url: server.url, + userId: rootId, + accessToken: server.accessToken, + videoQuotaDaily: 42 + }) + + await uploadVideo(server.url, server.accessToken, {}, 403) + }) + }) + + describe('When having an absolute and daily video quota', function () { + it('Should fail if exceeding total quota', async function () { + await updateUser({ + url: server.url, + userId: rootId, + accessToken: server.accessToken, + videoQuota: 42, + videoQuotaDaily: 1024 * 1024 * 1024 + }) + + await uploadVideo(server.url, server.accessToken, {}, 403) + }) + + it('Should fail if exceeding daily quota', async function () { + await updateUser({ + url: server.url, + userId: rootId, + accessToken: server.accessToken, + videoQuota: 1024 * 1024 * 1024, + videoQuotaDaily: 42 + }) + + await uploadVideo(server.url, server.accessToken, {}, 403) + }) + }) + describe('When asking a password reset', function () { const path = '/api/v1/users/ask-reset-password' diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index f9805b6ea..8a5f27c34 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -37,6 +37,7 @@ function checkInitialConfig (data: CustomConfig) { expect(data.signup.limit).to.equal(4) expect(data.admin.email).to.equal('admin1@example.com') expect(data.user.videoQuota).to.equal(5242880) + expect(data.user.videoQuotaDaily).to.equal(318742) expect(data.transcoding.enabled).to.be.false expect(data.transcoding.threads).to.equal(2) expect(data.transcoding.resolutions['240p']).to.be.true @@ -65,6 +66,7 @@ function checkUpdatedConfig (data: CustomConfig) { expect(data.signup.limit).to.equal(5) expect(data.admin.email).to.equal('superadmin1@example.com') expect(data.user.videoQuota).to.equal(5242881) + expect(data.user.videoQuotaDaily).to.equal(318742) expect(data.transcoding.enabled).to.be.true expect(data.transcoding.threads).to.equal(1) expect(data.transcoding.resolutions['240p']).to.be.false @@ -152,7 +154,8 @@ describe('Test config', function () { email: 'superadmin1@example.com' }, user: { - videoQuota: 5242881 + videoQuota: 5242881, + videoQuotaDaily: 318742 }, transcoding: { enabled: true, diff --git a/server/tests/utils/server/config.ts b/server/tests/utils/server/config.ts index d6ac3ef8a..799c31ae5 100644 --- a/server/tests/utils/server/config.ts +++ b/server/tests/utils/server/config.ts @@ -80,7 +80,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) { email: 'superadmin1@example.com' }, user: { - videoQuota: 5242881 + videoQuota: 5242881, + videoQuotaDaily: 318742 }, transcoding: { enabled: true, diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts index f786de6e3..5dba34b69 100644 --- a/server/tests/utils/users/users.ts +++ b/server/tests/utils/users/users.ts @@ -10,6 +10,7 @@ function createUser ( username: string, password: string, videoQuota = 1000000, + videoQuotaDaily = -1, role: UserRole = UserRole.USER, specialStatus = 200 ) { @@ -19,7 +20,8 @@ function createUser ( password, role, email: username + '@example.com', - videoQuota + videoQuota, + videoQuotaDaily } return request(url) @@ -202,6 +204,7 @@ function updateUser (options: { accessToken: string, email?: string, videoQuota?: number, + videoQuotaDaily?: number, role?: UserRole }) { const path = '/api/v1/users/' + options.userId @@ -209,6 +212,7 @@ function updateUser (options: { const toSend = {} if (options.email !== undefined && options.email !== null) toSend['email'] = options.email if (options.videoQuota !== undefined && options.videoQuota !== null) toSend['videoQuota'] = options.videoQuota + if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend['videoQuotaDaily'] = options.videoQuotaDaily if (options.role !== undefined && options.role !== null) toSend['role'] = options.role return makePutBodyRequest({ diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index d70c757b6..2f5cebf7f 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -42,6 +42,7 @@ export interface CustomConfig { user: { videoQuota: number + videoQuotaDaily: number } transcoding: { diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 8cb087234..9bbeb14d2 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -66,5 +66,6 @@ export interface ServerConfig { user: { videoQuota: number + videoQuotaDaily: number } } diff --git a/shared/models/users/user-create.model.ts b/shared/models/users/user-create.model.ts index 65830f55e..08be4db05 100644 --- a/shared/models/users/user-create.model.ts +++ b/shared/models/users/user-create.model.ts @@ -5,5 +5,6 @@ export interface UserCreate { password: string email: string videoQuota: number + videoQuotaDaily: number role: UserRole } diff --git a/shared/models/users/user-update.model.ts b/shared/models/users/user-update.model.ts index 96b454b7c..ce866fb18 100644 --- a/shared/models/users/user-update.model.ts +++ b/shared/models/users/user-update.model.ts @@ -3,5 +3,6 @@ import { UserRole } from './user-role' export interface UserUpdate { email?: string videoQuota?: number + videoQuotaDaily?: number role?: UserRole } diff --git a/shared/models/users/user-video-quota.model.ts b/shared/models/users/user-video-quota.model.ts index b856fd9fc..a24871d71 100644 --- a/shared/models/users/user-video-quota.model.ts +++ b/shared/models/users/user-video-quota.model.ts @@ -1,3 +1,4 @@ export interface UserVideoQuota { videoQuotaUsed: number + videoQuotaUsedDaily: number } diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts index 455211aa3..8147dc48e 100644 --- a/shared/models/users/user.model.ts +++ b/shared/models/users/user.model.ts @@ -11,6 +11,7 @@ export interface User { autoPlayVideo: boolean role: UserRole videoQuota: number + videoQuotaDaily: number createdAt: Date account: Account videoChannels?: VideoChannel[] -- 2.41.0