Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b1e60bc
feat(cache): add getRecord and increment functions for the rate limit…
sg-gs Jan 16, 2026
4def7db
refactor(guards): simplify CustomThrottlerGuard significatively
sg-gs Jan 16, 2026
12ae20b
feat(guards): add custom throttler
sg-gs Jan 16, 2026
1ec15e0
feat(guards): create custom interceptor for global throttling
sg-gs Jan 16, 2026
594b901
feat(guards): wire the throttler guard and interceptor with the app
sg-gs Jan 16, 2026
45c685b
fix(cache): dont keep the expiration time when the record is already …
sg-gs Jan 16, 2026
e1bff4f
fix(folders): rate limit folder meta to 30 x min
sg-gs Jan 16, 2026
fecb1e7
fix(files): rate limit file meta requests x min
sg-gs Jan 16, 2026
025e249
refactor: remove deprecated prometheus integration
sg-gs Jan 16, 2026
fe94184
fix(users): custom rate limit for usage requests
sg-gs Jan 16, 2026
5e46eb3
fix(files): throttle listing requests x min
sg-gs Jan 16, 2026
cf1e476
fix(folders): add custom throttle guard for folder listing requests
jzunigax2 Jan 16, 2026
35dfcce
fix(users, folders): set custom throttle to refresh and folder creation
sg-gs Jan 19, 2026
cad6597
fix(folder): increase create folder throttler to 30k/h
sg-gs Jan 19, 2026
d80d807
feat(migration): add user_id column to file_versions table
douglas-xt Dec 16, 2025
1bc3e74
fix(migration): use user uuid instead of id for file_versions
douglas-xt Dec 30, 2025
0118ada
chore(migration): update timestamp for add-user-id-to-file-versions m…
douglas-xt Jan 16, 2026
bf96510
fix(migration): make user_id NOT NULL in file_versions
douglas-xt Jan 16, 2026
4f4825b
fix(migration): create file_versions user_id status index concurrently
douglas-xt Jan 19, 2026
c7f7e15
perf(migration): use partial index for file_versions user_id lookup
douglas-xt Jan 19, 2026
daa199c
perf(migration): use partial index for file_versions user_id lookup
douglas-xt Jan 19, 2026
de4d388
fix(migration): remove duplicate index creation from user_id migration
douglas-xt Jan 19, 2026
27175cc
feat(usage): include file versions in storage calculation
douglas-xt Jan 19, 2026
d6dfa6e
feat(migration): drop legacy unique index on folders table
jzunigax2 Jan 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions migrations/20260116150327-add-user-id-to-file-versions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

const tableName = 'file_versions';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(tableName, 'user_id', {
type: Sequelize.STRING(36),
allowNull: false,
references: {
model: 'users',
key: 'uuid',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
});
},

async down(queryInterface) {
await queryInterface.removeColumn(tableName, 'user_id');
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx
ON file_versions (user_id)
WHERE status = 'EXISTS';
`);
},

async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx;
`);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
up: async (queryInterface) => {
await queryInterface.sequelize.query(`
DROP INDEX CONCURRENTLY IF EXISTS folders_plainname_parentid_key;
`);
},

down: async (queryInterface) => {
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX CONCURRENTLY folders_plainname_parentid_key
ON folders (plain_name, parent_id)
WHERE deleted = false;
`);
},
};
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"prettysize": "^2.0.0",
"prom-client": "^15.0.0",
"prometheus-api-metrics": "^4.0.0",
"qrcode": "^1.4.4",
"redis": "^5.8.2",
"reflect-metadata": "^0.2.2",
Expand Down
18 changes: 2 additions & 16 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import { HttpGlobalExceptionFilter } from './common/http-global-exception-filter
import { JobsModule } from './modules/jobs/jobs.module';
import { v4 } from 'uuid';
import { getClientIdFromHeaders } from './common/decorators/client.decorator';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { CustomThrottlerGuard } from './guards/throttler.guard';
import { AuthGuard } from './modules/auth/auth.guard';
import { CustomThrottlerModule } from './guards/throttler.module';

@Module({
imports: [
Expand Down Expand Up @@ -125,21 +125,7 @@ import { AuthGuard } from './modules/auth/auth.guard';
}),
}),
EventEmitterModule.forRoot({ wildcard: true, delimiter: '.' }),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
return ({
throttlers: [
{
ttl: seconds(config.get('users.rateLimit.default.ttl')),
limit: config.get('users.rateLimit.default.limit')
}
],
storage: new ThrottlerStorageRedisService(config.get('cache.redisConnectionString'))
})
},
}),
CustomThrottlerModule,
JobsModule,
NotificationModule,
NotificationsModule,
Expand Down
20 changes: 20 additions & 0 deletions src/guards/custom-endpoint-throttle.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SetMetadata } from '@nestjs/common';

export const CUSTOM_ENDPOINT_THROTTLE_KEY = 'customEndpointThrottle';

export interface CustomThrottleOptions {
ttl: number; // seconds
limit: number;
}

/**
* You can use two different shapes:
* - single policy: { ttl, limit }
* - named policies: { short: { ttl, limit }, long: { ttl, limit } }
*/
export type CustomThrottleArg =
| CustomThrottleOptions
| Record<string, CustomThrottleOptions>;

export const CustomThrottle = (opts: CustomThrottleArg) =>
SetMetadata(CUSTOM_ENDPOINT_THROTTLE_KEY, opts);
103 changes: 103 additions & 0 deletions src/guards/custom-endpoint-throttle.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as tsjest from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CustomEndpointThrottleGuard } from './custom-endpoint-throttle.guard';
import { CacheManagerService } from '../modules/cache-manager/cache-manager.service';
import { ThrottlerException } from '@nestjs/throttler';

describe('CustomThrottleGuard', () => {
let guard: CustomEndpointThrottleGuard;
let reflector: Reflector;
let cacheService: jest.Mocked<CacheManagerService>;

beforeEach(() => {
reflector = tsjest.createMock<Reflector>();
cacheService = tsjest.createMock<CacheManagerService>();
cacheService.increment = jest.fn();
guard = new CustomEndpointThrottleGuard(reflector, cacheService as any);
});

describe('canActivate', () => {
it('When reflector returns no metadata then the guard checks are skipped', async () => {
(reflector.get as jest.Mock).mockReturnValue(undefined);
const context = tsjest.createMock<ExecutionContext>();

const result = await guard.canActivate(context);

expect(result).toBe(true);
expect(cacheService.increment).not.toHaveBeenCalled();
});

describe('Applying a single policy', () => {
const route = '/login';

it('When under limit then it allows the request to pass', async () => {
const policy = { ttl: 60, limit: 5 };
(reflector.get as jest.Mock).mockReturnValue(policy);

const request: any = { route: { path: route }, user: { uuid: 'user-1' }, ip: '1.2.3.4' };
(cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 1, timeToExpire: 5000 });
const context = tsjest.createMock<ExecutionContext>();
(context as any).switchToHttp = () => ({ getRequest: () => request });

const result = await guard.canActivate(context);

expect(result).toBe(true);
expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:policy0:cet:uid:${request.user.uuid}`, 60);
});

it('When over the limit then the request is throttled', async () => {
const policy = { ttl: 60, limit: 1 };
(reflector.get as jest.Mock).mockReturnValue(policy);

const request: any = { route: { path: route }, user: { uuid: 'user-2' }, ip: '2.2.2.2' };
(cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 2, timeToExpire: 1000 });
const context = tsjest.createMock<ExecutionContext>();
(context as any).switchToHttp = () => ({ getRequest: () => request });

await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ThrottlerException);
expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:policy0:cet:uid:${request.user.uuid}`, 60);
});
});

describe('Applying multiple policies', () => {
const route = '/login';

it('When under limits then it allows the request to pass', async () => {
const named = { short: { ttl: 60, limit: 5 }, long: { ttl: 3600, limit: 30 } };
(reflector.get as jest.Mock).mockReturnValue(named);
const request: any = { route: { path: route }, user: null, ip: '9.9.9.9' };

(cacheService.increment as jest.Mock)
.mockResolvedValueOnce({ totalHits: named.short.limit - 1, timeToExpire: 100 })
.mockResolvedValueOnce({ totalHits: named.long.limit - 1, timeToExpire: 1000 });

const context = tsjest.createMock<ExecutionContext>();
(context as any).switchToHttp = () => ({ getRequest: () => request });

const result = await guard.canActivate(context);

expect(result).toBe(true);
expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:short:cet:ip:${request.ip}`, named.short.ttl);
expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:long:cet:ip:${request.ip}`, named.long.ttl);
});

it('when over the limit then the request is throttled', async () => {
const named = { short: { ttl: 60, limit: 1 }, long: { ttl: 3600, limit: 30 } };
(reflector.get as jest.Mock).mockReturnValue(named);
const request: any = { route: { path: route }, user: null, ip: '11.11.11.11' };

const shortOverTheLimit = named.short.limit + 1;
(cacheService.increment as jest.Mock)
.mockResolvedValueOnce({ totalHits: shortOverTheLimit, timeToExpire: 10 })
.mockResolvedValueOnce({ totalHits: named.long.limit - 1, timeToExpire: 1000 });

const context = tsjest.createMock<ExecutionContext>();
(context as any).switchToHttp = () => ({ getRequest: () => request });

await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ThrottlerException);
expect(cacheService.increment).toHaveBeenCalledWith(`${request.route.path}:short:cet:ip:${request.ip}`, 60);
});
});
});
});
65 changes: 65 additions & 0 deletions src/guards/custom-endpoint-throttle.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ThrottlerException } from '@nestjs/throttler';
import { CacheManagerService } from '../modules/cache-manager/cache-manager.service';
import {
CUSTOM_ENDPOINT_THROTTLE_KEY,
CustomThrottleOptions,
} from './custom-endpoint-throttle.decorator';

@Injectable()
export class CustomEndpointThrottleGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly cacheService: CacheManagerService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const raw = this.reflector.get<any>(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler());

// If no custom throttle metadata, do not block (this guard should be applied
// only where needed). Returning true lets other guards run.
if (!raw) return true;

const policies: Array<CustomThrottleOptions & { key?: string }> = [];

if (typeof raw === 'object' && (raw as any).ttl === undefined && (raw as any).limit === undefined) {
// named policies object: { short: { ttl, limit }, long: { ttl, limit } }
const entries = Object.entries(raw) as [string, CustomThrottleOptions][];
for (const [name, val] of entries) {
policies.push({ ...(val as CustomThrottleOptions), key: name });
}
} else {
policies.push({ ...(raw as CustomThrottleOptions), key: (raw as any).key ?? 'policy0' });
}

const request = context.switchToHttp().getRequest();
const user = request.user;

const identifierBase = user?.uuid ? `cet:uid:${user.uuid}` : `cet:ip:${request.ip}`;
const route = request.route?.path ?? request.originalUrl ?? 'unknown';

// Apply all policies. If any policy is violated, throw.
for (let i = 0; i < policies.length; i++) {
const p = policies[i];
// Prefer an explicit stable key from the policy so the identity
// remains the same even if the array order changes. Fallback to
// index-based id when no key provided.
const policyId = p.key ? String(p.key) : `policy${i}`;
const sanitizedRoute = String(route).replace(/\s+/g, '_');
const sanitizedPolicyId = policyId.replace(/\s+/g, '_');
const key = `${sanitizedRoute}:${sanitizedPolicyId}:${identifierBase}`;
const record = await this.cacheService.increment(key, p.ttl);
if (record.totalHits > p.limit) {
throw new ThrottlerException();
}
}

return true;
}
}
76 changes: 5 additions & 71 deletions src/guards/throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard as BaseThrottlerGuard, ThrottlerModuleOptions, ThrottlerRequest, ThrottlerStorageService } from '@nestjs/throttler';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { User } from '../modules/user/user.domain';

import { ThrottlerGuard as BaseThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class ThrottlerGuard extends BaseThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> {
Expand All @@ -15,70 +10,9 @@ export class ThrottlerGuard extends BaseThrottlerGuard {
}

@Injectable()
export class CustomThrottlerGuard extends BaseThrottlerGuard {
constructor(
options: ThrottlerModuleOptions,
storageService: ThrottlerStorageService,
reflector: Reflector,
private readonly config: ConfigService,
) {
super(options, storageService, reflector);
}

protected async getTracker(req: Record<string, any>): Promise<string> {
const user = req.user;
if (user && (user.id || user.uuid)) {
return `user:${user.id ?? user.uuid}`;
}
const auth = req.headers['authorization'] as string | undefined;
if (auth) return `token:${auth.slice(0, 200)}`;
const forwarded = (req.headers['x-forwarded-for'] as string) || '';
const ip = forwarded ? forwarded.split(',')[0].trim() : req.ip || req.socket?.remoteAddress || 'unknown';
return `ip:${ip}`;
}

protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;

const handlerContext = context.getHandler();
const classContext = context.getClass();

const isPublic = this.reflector.get<boolean>('isPublic', handlerContext);
const disableGlobalAuth = this.reflector.getAllAndOverride<boolean>(
'disableGlobalAuth',
[handlerContext, classContext],
);

const req = context.switchToHttp().getRequest<Request>();

if (isPublic || disableGlobalAuth || !req.user) {
const anonymousLimit = this.config.get('users.rateLimit.anonymous.limit');
const anonymousTTL = this.config.get('users.rateLimit.anonymous.ttl');

requestProps.ttl = anonymousTTL;
requestProps.limit = anonymousLimit;

return super.handleRequest(requestProps);
}

const user = req.user as User;
const isFreeUser = user.tierId === this.config.get('users.freeTierId');

if (isFreeUser) {
const freeLimit = this.config.get('users.rateLimit.free.limit');
const freeTTL = this.config.get('users.rateLimit.free.ttl');

requestProps.ttl = freeTTL;
requestProps.limit = freeLimit;

return super.handleRequest(requestProps);
}

const paidLimit = this.config.get('users.rateLimit.paid.limit');
const paidTTL = this.config.get('users.rateLimit.paid.ttl');
requestProps.ttl = paidTTL;
requestProps.limit = paidLimit;

return super.handleRequest(requestProps);
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: any): Promise<string> {
const userId = req.user?.uuid;
return userId ? `rl:${userId}` : `rl:${req.ip}`;
}
}
Loading