Skip to content

Commit db57f75

Browse files
committed
feat(file-size-limit): limit file size on POST and Replace
1 parent 4c4b09b commit db57f75

11 files changed

Lines changed: 988 additions & 139 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use strict';
2+
3+
const { v4 } = require('uuid');
4+
5+
const LIMIT_LABEL = 'max-upload-file-size';
6+
7+
const MB = 1024 * 1024;
8+
const GB = 1024 * MB;
9+
10+
/** @type {import('sequelize-cli').Migration} */
11+
module.exports = {
12+
async up(queryInterface) {
13+
await queryInterface.bulkInsert('limits', [
14+
{
15+
id: v4(),
16+
label: LIMIT_LABEL,
17+
type: 'counter',
18+
value: String(100 * MB),
19+
created_at: new Date(),
20+
updated_at: new Date(),
21+
},
22+
{
23+
id: v4(),
24+
label: LIMIT_LABEL,
25+
type: 'counter',
26+
value: String(1 * GB),
27+
created_at: new Date(),
28+
updated_at: new Date(),
29+
},
30+
{
31+
id: v4(),
32+
label: LIMIT_LABEL,
33+
type: 'counter',
34+
value: String(10 * GB),
35+
created_at: new Date(),
36+
updated_at: new Date(),
37+
},
38+
{
39+
id: v4(),
40+
label: LIMIT_LABEL,
41+
type: 'counter',
42+
value: String(50 * GB),
43+
created_at: new Date(),
44+
updated_at: new Date(),
45+
},
46+
{
47+
id: v4(),
48+
label: LIMIT_LABEL,
49+
type: 'counter',
50+
value: String(100 * GB),
51+
created_at: new Date(),
52+
updated_at: new Date(),
53+
},
54+
]);
55+
},
56+
57+
async down(queryInterface) {
58+
await queryInterface.sequelize.query(
59+
`DELETE FROM limits WHERE label = :limitLabel`,
60+
{ replacements: { limitLabel: LIMIT_LABEL } },
61+
);
62+
},
63+
};

src/modules/cache-manager/cache-manager.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ export class CacheManagerService {
101101
return this.cacheManager.del(`${this.AVATAR_KEY_PREFIX}${userUuid}`);
102102
}
103103

104+
async getTierLimit(tierId: string, label: string) {
105+
return this.cacheManager.get<string>(`tier-limit:${tierId}:${label}`);
106+
}
107+
108+
async setTierLimit(tierId: string, label: string, value: string) {
109+
await this.cacheManager.set(
110+
`tier-limit:${tierId}:${label}`,
111+
value,
112+
this.TTL_10_MINUTES,
113+
);
114+
}
115+
104116
async checkHealth(): Promise<void> {
105117
const key = 'health_check';
106118
const value = Date.now().toString();

src/modules/feature-limit/exceptions/payment-required.exception.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { HttpException, HttpStatus } from '@nestjs/common';
22

3+
export enum PaymentRequiredErrorCode {
4+
FileUploadSizeExceeded = 'FILE_UPLOAD_SIZE_EXCEEDED',
5+
FeatureNotAvailable = 'FEATURE_NOT_AVAILABLE',
6+
}
7+
38
export class PaymentRequiredException extends HttpException {
4-
constructor(message?: string) {
9+
constructor(message?: string, code?: PaymentRequiredErrorCode) {
510
super(
6-
message ??
7-
'It seems you reached the limit or feature is not available for your current plan tier',
11+
{
12+
message:
13+
message ??
14+
'It seems you reached the limit or feature is not available for your current plan tier',
15+
...(code ? { error: code } : {}),
16+
},
817
HttpStatus.PAYMENT_REQUIRED,
918
);
1019
}

src/modules/feature-limit/feature-limit.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { PaidPlansModel } from './models/paid-plans.model';
1616
import { PaymentsService } from '../../externals/payments/payments.service';
1717
import { FeatureLimitService } from './feature-limit.service';
1818
import { WorkspacesModule } from '../workspaces/workspaces.module';
19+
import { CacheManagerModule } from '../cache-manager/cache-manager.module';
1920

2021
@Module({
2122
imports: [
@@ -30,6 +31,7 @@ import { WorkspacesModule } from '../workspaces/workspaces.module';
3031
forwardRef(() => SharingModule),
3132
forwardRef(() => UserModule),
3233
WorkspacesModule,
34+
CacheManagerModule,
3335
],
3436
providers: [
3537
SequelizeFeatureLimitsRepository,

src/modules/feature-limit/feature-limit.service.spec.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ import {
1414
} from '../../../test/fixtures';
1515
import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository';
1616
import { SequelizeUserRepository } from '../user/user.repository';
17+
import { CacheManagerService } from '../cache-manager/cache-manager.service';
18+
import { PaymentRequiredException } from './exceptions/payment-required.exception';
1719

1820
describe('FeatureLimitService', () => {
1921
let service: FeatureLimitService;
2022
let limitsRepository: DeepMocked<SequelizeFeatureLimitsRepository>;
2123
let workspaceRepository: DeepMocked<SequelizeWorkspaceRepository>;
2224
let userRepository: DeepMocked<SequelizeUserRepository>;
25+
let cacheManagerService: DeepMocked<CacheManagerService>;
2326

2427
beforeEach(async () => {
2528
const moduleRef = await Test.createTestingModule({
@@ -33,6 +36,7 @@ describe('FeatureLimitService', () => {
3336
limitsRepository = moduleRef.get(SequelizeFeatureLimitsRepository);
3437
workspaceRepository = moduleRef.get(SequelizeWorkspaceRepository);
3538
userRepository = moduleRef.get(SequelizeUserRepository);
39+
cacheManagerService = moduleRef.get(CacheManagerService);
3640
});
3741

3842
describe('canUserAccessPlatform', () => {
@@ -370,6 +374,171 @@ describe('FeatureLimitService', () => {
370374
});
371375
});
372376

377+
describe('enforceMaxUploadFileSize', () => {
378+
const MB = 1024 * 1024;
379+
const GB = 1024 * MB;
380+
381+
it('When user has no limit configured, then it should allow the upload', async () => {
382+
const user = newUser({ attributes: { tierId: v4() } });
383+
384+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
385+
cacheManagerService.getTierLimit.mockResolvedValueOnce(null);
386+
limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(null);
387+
388+
await expect(
389+
service.enforceMaxUploadFileSize(user, BigInt(500 * MB)),
390+
).resolves.not.toThrow();
391+
});
392+
393+
it('When file size is under tier limit, then it should allow the upload', async () => {
394+
const user = newUser({ attributes: { tierId: v4() } });
395+
const limit = newFeatureLimit({
396+
type: LimitTypes.Counter,
397+
label: LimitLabels.MaxUploadFileSize,
398+
value: String(100 * MB),
399+
});
400+
401+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
402+
cacheManagerService.getTierLimit.mockResolvedValueOnce(null);
403+
limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit);
404+
405+
await expect(
406+
service.enforceMaxUploadFileSize(user, BigInt(50 * MB)),
407+
).resolves.not.toThrow();
408+
});
409+
410+
it('When file size equals tier limit, then it should throw PaymentRequiredException', async () => {
411+
const user = newUser({ attributes: { tierId: v4() } });
412+
const limit = newFeatureLimit({
413+
type: LimitTypes.Counter,
414+
label: LimitLabels.MaxUploadFileSize,
415+
value: String(100 * MB),
416+
});
417+
418+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
419+
cacheManagerService.getTierLimit.mockResolvedValueOnce(null);
420+
limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit);
421+
422+
await expect(
423+
service.enforceMaxUploadFileSize(user, BigInt(100 * MB)),
424+
).rejects.toThrow(PaymentRequiredException);
425+
});
426+
427+
it('When file size exceeds tier limit, then it should throw PaymentRequiredException', async () => {
428+
const user = newUser({ attributes: { tierId: v4() } });
429+
const limit = newFeatureLimit({
430+
type: LimitTypes.Counter,
431+
label: LimitLabels.MaxUploadFileSize,
432+
value: String(100 * MB),
433+
});
434+
435+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
436+
cacheManagerService.getTierLimit.mockResolvedValueOnce(null);
437+
limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit);
438+
439+
await expect(
440+
service.enforceMaxUploadFileSize(user, BigInt(200 * MB)),
441+
).rejects.toThrow(PaymentRequiredException);
442+
});
443+
444+
it('When user has an overridden limit, then it should use it instead of tier limit', async () => {
445+
const user = newUser({ attributes: { tierId: v4() } });
446+
const override = newFeatureLimit({
447+
type: LimitTypes.Counter,
448+
label: LimitLabels.MaxUploadFileSize,
449+
value: String(10 * GB),
450+
});
451+
452+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(override);
453+
454+
await expect(
455+
service.enforceMaxUploadFileSize(user, BigInt(5 * GB)),
456+
).resolves.not.toThrow();
457+
expect(cacheManagerService.getTierLimit).not.toHaveBeenCalled();
458+
expect(limitsRepository.findLimitByLabelAndTier).not.toHaveBeenCalled();
459+
});
460+
461+
it('When user overridden limit is exceeded, then it should throw', async () => {
462+
const user = newUser({ attributes: { tierId: v4() } });
463+
const override = newFeatureLimit({
464+
type: LimitTypes.Counter,
465+
label: LimitLabels.MaxUploadFileSize,
466+
value: String(100 * MB),
467+
});
468+
469+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(override);
470+
471+
await expect(
472+
service.enforceMaxUploadFileSize(user, BigInt(200 * MB)),
473+
).rejects.toThrow(PaymentRequiredException);
474+
});
475+
476+
it('When tier limit is cached, then it should not hit the DB', async () => {
477+
const user = newUser({ attributes: { tierId: v4() } });
478+
479+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
480+
cacheManagerService.getTierLimit.mockResolvedValueOnce(String(1 * GB));
481+
482+
await expect(
483+
service.enforceMaxUploadFileSize(user, BigInt(500 * MB)),
484+
).resolves.not.toThrow();
485+
expect(limitsRepository.findLimitByLabelAndTier).not.toHaveBeenCalled();
486+
});
487+
488+
it('When tier limit is cached and exceeded, then it should throw without hitting DB', async () => {
489+
const user = newUser({ attributes: { tierId: v4() } });
490+
491+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
492+
cacheManagerService.getTierLimit.mockResolvedValueOnce(String(100 * MB));
493+
494+
await expect(
495+
service.enforceMaxUploadFileSize(user, BigInt(200 * MB)),
496+
).rejects.toThrow(PaymentRequiredException);
497+
expect(limitsRepository.findLimitByLabelAndTier).not.toHaveBeenCalled();
498+
});
499+
500+
it('When cache miss occurs, then it should populate the cache from DB', async () => {
501+
const user = newUser({ attributes: { tierId: v4() } });
502+
const limit = newFeatureLimit({
503+
type: LimitTypes.Counter,
504+
label: LimitLabels.MaxUploadFileSize,
505+
value: String(1 * GB),
506+
});
507+
508+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
509+
cacheManagerService.getTierLimit.mockResolvedValueOnce(null);
510+
limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit);
511+
512+
await service.enforceMaxUploadFileSize(user, BigInt(500 * MB));
513+
514+
expect(cacheManagerService.setTierLimit).toHaveBeenCalledWith(
515+
user.tierId,
516+
LimitLabels.MaxUploadFileSize,
517+
limit.value,
518+
);
519+
});
520+
521+
it('When cache write fails, then it should still allow the upload', async () => {
522+
const user = newUser({ attributes: { tierId: v4() } });
523+
const limit = newFeatureLimit({
524+
type: LimitTypes.Counter,
525+
label: LimitLabels.MaxUploadFileSize,
526+
value: String(1 * GB),
527+
});
528+
529+
limitsRepository.findUserOverriddenLimit.mockResolvedValueOnce(null);
530+
cacheManagerService.getTierLimit.mockResolvedValueOnce(null);
531+
limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit);
532+
cacheManagerService.setTierLimit.mockRejectedValueOnce(
533+
new Error('Redis unavailable'),
534+
);
535+
536+
await expect(
537+
service.enforceMaxUploadFileSize(user, BigInt(500 * MB)),
538+
).resolves.not.toThrow();
539+
});
540+
});
541+
373542
describe('getTier', () => {
374543
it('When tier exists, then it should return the tier', async () => {
375544
const tierId = v4();

src/modules/feature-limit/feature-limit.service.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
2+
import {
3+
PaymentRequiredException,
4+
PaymentRequiredErrorCode,
5+
} from './exceptions/payment-required.exception';
26
import { SequelizeFeatureLimitsRepository } from './feature-limit.repository';
37
import { LimitLabels } from './limits.enum';
48
import { PlatformName } from '../../common/constants';
59
import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository';
610
import { SequelizeUserRepository } from '../user/user.repository';
711
import { type Limit } from './domain/limit.domain';
812
import { type User } from '../user/user.domain';
13+
import { CacheManagerService } from '../cache-manager/cache-manager.service';
914

1015
@Injectable()
1116
export class FeatureLimitService {
@@ -15,6 +20,7 @@ export class FeatureLimitService {
1520
private readonly limitsRepository: SequelizeFeatureLimitsRepository,
1621
private readonly workspaceRepository: SequelizeWorkspaceRepository,
1722
private readonly userRepository: SequelizeUserRepository,
23+
private readonly cacheManagerService: CacheManagerService,
1824
) {}
1925

2026
async canUserAccessPlatform(
@@ -170,4 +176,52 @@ export class FeatureLimitService {
170176

171177
return userOverriddenLimits ?? tierLimits;
172178
}
179+
180+
async enforceMaxUploadFileSize(user: User, fileSize: bigint): Promise<void> {
181+
const userOverriddenLimit =
182+
await this.limitsRepository.findUserOverriddenLimit(
183+
user.uuid,
184+
LimitLabels.MaxUploadFileSize,
185+
);
186+
187+
let limitValue: string | null = null;
188+
189+
if (userOverriddenLimit) {
190+
limitValue = userOverriddenLimit.value;
191+
} else {
192+
const cached = await this.cacheManagerService.getTierLimit(
193+
user.tierId,
194+
LimitLabels.MaxUploadFileSize,
195+
);
196+
197+
if (cached !== null && cached !== undefined) {
198+
limitValue = cached;
199+
} else {
200+
const tierLimit = await this.limitsRepository.findLimitByLabelAndTier(
201+
user.tierId,
202+
LimitLabels.MaxUploadFileSize,
203+
);
204+
205+
if (!tierLimit) return;
206+
207+
limitValue = tierLimit.value;
208+
await this.cacheManagerService
209+
.setTierLimit(user.tierId, LimitLabels.MaxUploadFileSize, limitValue)
210+
.catch((err) => {
211+
this.logger.error(
212+
`Failed to cache tier limit for tierId ${user.tierId}: ${err.message}`,
213+
);
214+
});
215+
}
216+
}
217+
218+
if (limitValue === null) return;
219+
220+
if (Number(fileSize) >= Number(limitValue)) {
221+
throw new PaymentRequiredException(
222+
'File size exceeds the maximum allowed by your plan',
223+
PaymentRequiredErrorCode.FileUploadSizeExceeded,
224+
);
225+
}
226+
}
173227
}

0 commit comments

Comments
 (0)