From b1e60bc23ea0d58d8eca0fa3dac7f97d40e8d9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 12:49:48 +0100 Subject: [PATCH 01/24] feat(cache): add getRecord and increment functions for the rate limiter usage --- .../cache-manager.service.spec.ts | 108 ++++++++++++++++++ .../cache-manager/cache-manager.service.ts | 51 ++++++++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index c2fc92df3..a9aba91cb 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -307,4 +307,112 @@ describe('CacheManagerService', () => { }); }); }); + + describe('getRecord', () => { + const key = 'throttle:some:key'; + + it('When entry exists and not expired then returns a record succesfully', async () => { + const now = 1_600_000_000_000; + const expirationTTL = 5_000; + const expiresAt = now + expirationTTL; + const entry = { hits: 3, expiresAt }; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(entry as any); + + const result = await cacheManagerService.getRecord(key); + + expect(cacheManager.get).toHaveBeenCalledWith(key); + expect(result).toEqual({ + totalHits: 3, + timeToExpire: expirationTTL, + isBlocked: false, + timeToBlockExpire: 0, + }); + }); + + it('When entry exists but expired then returns record with time to expire set to 0', async () => { + const now = 1_600_000_010_000; + const expiresAt = now - 1_000; + const entry = { hits: 2, expiresAt }; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(entry as any); + + const result = await cacheManagerService.getRecord(key); + + expect(result).toEqual({ + totalHits: 2, + timeToExpire: 0, + isBlocked: false, + timeToBlockExpire: 0, + }); + }); + + it('When cache returns null then returns undefined', async () => { + jest.spyOn(cacheManager, 'get').mockResolvedValue(null); + + const result = await cacheManagerService.getRecord(key); + + expect(result).toBeUndefined(); + }); + }); + + describe('increment', () => { + const key = 'throttle:some:key'; + + it('When there is no existing entry then it sets hits=1 and ttl equals requested ttl (ms)', async () => { + const now = 1_600_000_020_000; + const ttlSeconds = 60; + const ttlMs = ttlSeconds * 1000; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(null); + const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + + expect(cacheManager.get).toHaveBeenCalledWith(key); + expect(setSpy).toHaveBeenCalledWith(key, { hits: 1, expiresAt: now + ttlMs }, ttlMs); + expect(result.totalHits).toBe(1); + expect(result.timeToExpire).toBe(ttlMs); + }); + + it('When existing entry present and not expired then it increments hits and preserves the expiration time', async () => { + const now = 1_600_000_030_000; + const expiresAt = now + 3_000; + const existing = { hits: 2, expiresAt }; + const ttlSeconds = 10; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); + const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + const expectedNewHits = existing.hits + 1; + + expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, expiresAt - now); + expect(result.totalHits).toBe(expectedNewHits); + expect(result.timeToExpire).toBe(expiresAt - now); + }); + + it('When existing entry expired then it still increments but ttl becomes 0', async () => { + const now = 1_600_000_040_000; + const expiresAt = now - 500; + const existing = { hits: 5, expiresAt }; + const ttlSeconds = 30; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); + const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + const expectedNewHits = existing.hits + 1; + const expectedTimeToExpire = 0; + + expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, 0); + expect(result.totalHits).toBe(expectedNewHits); + expect(result.timeToExpire).toBe(expectedTimeToExpire); + }); + }); }); diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index d50622ca1..faa81b56f 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; +import { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface'; @Injectable() export class CacheManagerService { @@ -8,7 +9,7 @@ export class CacheManagerService { private readonly LIMIT_KEY_PREFIX = 'limit:'; private readonly JWT_KEY_PREFIX = 'jwt:'; private readonly AVATAR_KEY_PREFIX = 'avatar:'; - private readonly TTL_10_MINUTES = 10000 * 60; + private readonly TTL_10_MINUTES = 10 * 60 * 1000; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} @@ -100,4 +101,52 @@ export class CacheManagerService { async deleteUserAvatar(userUuid: string) { return this.cacheManager.del(`${this.AVATAR_KEY_PREFIX}${userUuid}`); } + + async getRecord(key: string): Promise { + const entry = await this.cacheManager.get<{ hits: number; expiresAt: number }>( + key, + ); + + if (entry && typeof entry.hits === 'number') { + const now = Date.now(); + const timeToExpire = entry.expiresAt > now ? entry.expiresAt - now : 0; + const record: ThrottlerStorageRecord = { + totalHits: entry.hits, + timeToExpire, + isBlocked: false, + timeToBlockExpire: 0, + }; + return record; + } + return undefined; + } + + async increment(key: string, ttlSeconds: number): Promise { + const ttlMs = ttlSeconds * 1000; + const now = Date.now(); + + const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>( + key, + ); + + let hits = 1; + let expiresAt = now + ttlMs; + + if (existing && typeof existing.hits === 'number' && existing.expiresAt) { + hits = existing.hits + 1; + expiresAt = existing.expiresAt; + } + + const remainingTtl = Math.max(0, expiresAt - now); + + await this.cacheManager.set(key, { hits, expiresAt }, remainingTtl); + + const record: ThrottlerStorageRecord = { + totalHits: hits, + timeToExpire: remainingTtl, + isBlocked: false, + timeToBlockExpire: 0, + }; + return record; + } } From 4def7db327f7b397d780b0703ac7f194f30d3256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 12:52:59 +0100 Subject: [PATCH 02/24] refactor(guards): simplify CustomThrottlerGuard significatively --- src/guards/throttler.guard.ts | 76 +++-------------------------------- 1 file changed, 5 insertions(+), 71 deletions(-) diff --git a/src/guards/throttler.guard.ts b/src/guards/throttler.guard.ts index b8f9b4159..012e25fb9 100644 --- a/src/guards/throttler.guard.ts +++ b/src/guards/throttler.guard.ts @@ -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): Promise { @@ -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): Promise { - 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 { - const { context } = requestProps; - - const handlerContext = context.getHandler(); - const classContext = context.getClass(); - - const isPublic = this.reflector.get('isPublic', handlerContext); - const disableGlobalAuth = this.reflector.getAllAndOverride( - 'disableGlobalAuth', - [handlerContext, classContext], - ); - - const req = context.switchToHttp().getRequest(); - - 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 { + const userId = req.user?.uuid; + return userId ? `rl:${userId}` : `rl:${req.ip}`; } } \ No newline at end of file From 12ae20b761433a996b80eda4e0016c96180c9e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 12:53:50 +0100 Subject: [PATCH 03/24] feat(guards): add custom throttler --- .../custom-endpoint-throttle.decorator.ts | 20 ++++ .../custom-endpoint-throttle.guard.spec.ts | 103 ++++++++++++++++++ src/guards/custom-endpoint-throttle.guard.ts | 65 +++++++++++ 3 files changed, 188 insertions(+) create mode 100644 src/guards/custom-endpoint-throttle.decorator.ts create mode 100644 src/guards/custom-endpoint-throttle.guard.spec.ts create mode 100644 src/guards/custom-endpoint-throttle.guard.ts diff --git a/src/guards/custom-endpoint-throttle.decorator.ts b/src/guards/custom-endpoint-throttle.decorator.ts new file mode 100644 index 000000000..a34aba68e --- /dev/null +++ b/src/guards/custom-endpoint-throttle.decorator.ts @@ -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; + +export const CustomThrottle = (opts: CustomThrottleArg) => + SetMetadata(CUSTOM_ENDPOINT_THROTTLE_KEY, opts); diff --git a/src/guards/custom-endpoint-throttle.guard.spec.ts b/src/guards/custom-endpoint-throttle.guard.spec.ts new file mode 100644 index 000000000..9b864440d --- /dev/null +++ b/src/guards/custom-endpoint-throttle.guard.spec.ts @@ -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; + + beforeEach(() => { + reflector = tsjest.createMock(); + cacheService = tsjest.createMock(); + 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(); + + 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(); + (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(); + (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(); + (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(); + (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); + }); + }); + }); +}); diff --git a/src/guards/custom-endpoint-throttle.guard.ts b/src/guards/custom-endpoint-throttle.guard.ts new file mode 100644 index 000000000..bbe7449c5 --- /dev/null +++ b/src/guards/custom-endpoint-throttle.guard.ts @@ -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 { + const raw = this.reflector.get(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 = []; + + 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; + } +} From 1ec15e014c1fdc244ef2af8a8a64dc6a2d6672e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 13:03:10 +0100 Subject: [PATCH 04/24] feat(guards): create custom interceptor for global throttling --- src/guards/throttler.interceptor.spec.ts | 89 ++++++++++++++++++++++++ src/guards/throttler.interceptor.ts | 65 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/guards/throttler.interceptor.spec.ts create mode 100644 src/guards/throttler.interceptor.ts diff --git a/src/guards/throttler.interceptor.spec.ts b/src/guards/throttler.interceptor.spec.ts new file mode 100644 index 000000000..ad12a2ee0 --- /dev/null +++ b/src/guards/throttler.interceptor.spec.ts @@ -0,0 +1,89 @@ +import * as tsjest from '@golevelup/ts-jest'; +import { CustomThrottlerInterceptor } from './throttler.interceptor'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { ThrottlerException } from '@nestjs/throttler'; +import { of } from 'rxjs'; + +describe('CustomThrottlerInterceptor', () => { + let interceptor: CustomThrottlerInterceptor; + let configService: jest.Mocked; + let cacheService: jest.Mocked; + let reflector: Reflector; + + beforeEach(() => { + configService = tsjest.createMock(); + cacheService = tsjest.createMock(); + cacheService.increment = jest.fn(); + reflector = tsjest.createMock(); + + interceptor = new CustomThrottlerInterceptor( + configService as any, + cacheService as any, + reflector, + ); + }); + + describe('intercept', () => { + it('When handler or class has custom throttle metadata then bypasses global throttling', async () => { + (reflector.get as jest.Mock).mockReturnValue(true); + const context = tsjest.createMock(); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await interceptor.intercept(context, next as CallHandler); + + expect((next.handle as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1); + expect(cacheService.increment).not.toHaveBeenCalled(); + }); + + it('When anonymous request under limit then increments by ip and allows', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + configService.get = jest.fn((key: string) => { + if (key === 'users.rateLimit.anonymous.ttl') return 30; + if (key === 'users.rateLimit.anonymous.limit') return 10; + return undefined; + }) as any; + + const request: any = { ip: '10.0.0.1', user: null }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 1, timeToExpire: 1000 }); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await interceptor.intercept(context, next as CallHandler); + + expect(cacheService.increment).toHaveBeenCalledWith(`rl:${request.ip}`, 30); + expect((next.handle as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('When authenticated free-tier user exceeds limit then the request is throttled', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + const freeTierId = 'free-tier'; + configService.get = jest.fn((key: string) => { + switch (key) { + case 'users.freeTierId': + return freeTierId; + case 'users.rateLimit.free.ttl': + return 20; + case 'users.rateLimit.free.limit': + return 1; + default: + return undefined; + } + }) as any; + + const request: any = { ip: '1.1.1.1', user: { uuid: 'u123', tierId: freeTierId } }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + (cacheService.increment as jest.Mock).mockResolvedValue({ totalHits: 5, timeToExpire: 100 }); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await expect(interceptor.intercept(context, next as CallHandler)).rejects.toBeInstanceOf(ThrottlerException); + expect(cacheService.increment).toHaveBeenCalledWith(`rl:${request.user.uuid}`, 20); + }); + }); +}); diff --git a/src/guards/throttler.interceptor.ts b/src/guards/throttler.interceptor.ts new file mode 100644 index 000000000..7a187911e --- /dev/null +++ b/src/guards/throttler.interceptor.ts @@ -0,0 +1,65 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { CacheManagerService } from "../modules/cache-manager/cache-manager.service"; +import { Observable } from "rxjs"; +import { ThrottlerException } from "@nestjs/throttler"; +import { User } from "src/modules/user/user.domain"; +import { Reflector } from '@nestjs/core'; +import { CUSTOM_ENDPOINT_THROTTLE_KEY } from './custom-endpoint-throttle.decorator'; + +@Injectable() +export class CustomThrottlerInterceptor implements NestInterceptor { + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheManagerService, + private readonly reflector: Reflector, + ) {} + + private getRateLimit(user?: User): { ttl: number; limit: number } { + if (!user) { + return { + ttl: this.configService.get('users.rateLimit.anonymous.ttl'), + limit: this.configService.get('users.rateLimit.anonymous.limit') + }; + } + if (user.tierId === this.configService.get('users.freeTierId')) { + return { + ttl: this.configService.get('users.rateLimit.free.ttl'), + limit: this.configService.get('users.rateLimit.free.limit') + } + } + return { + ttl: this.configService.get('users.rateLimit.paid.ttl'), + limit: this.configService.get('users.rateLimit.paid.limit') + } + } + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + + // Interceptors run before guards, so we must check metadata and + // bypass the global interceptor when custom throttle is present. + const hasCustom = + this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler()) || + this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getClass()); + + if (hasCustom) { + return next.handle(); + } + const user = request.user as User | null; + let key = `rl:${request.ip}`; + if (user && user.uuid) { + key = `rl:${user.uuid}` + } + + const { ttl, limit } = this.getRateLimit(user); + + const record = await this.cacheService.increment(key, ttl); + + if (record.totalHits > limit) { + throw new ThrottlerException(); + } + + return next.handle(); + } +} \ No newline at end of file From 594b9019442af7c839616871e3e7e21b14f750f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 13:04:30 +0100 Subject: [PATCH 05/24] feat(guards): wire the throttler guard and interceptor with the app --- src/app.module.ts | 18 ++--------------- src/guards/throttler.module.ts | 35 ++++++++++++++++++++++++++++++++++ src/main.ts | 2 ++ 3 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 src/guards/throttler.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index a884f3764..3fcbe64d4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ @@ -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, diff --git a/src/guards/throttler.module.ts b/src/guards/throttler.module.ts new file mode 100644 index 000000000..fb71f8b65 --- /dev/null +++ b/src/guards/throttler.module.ts @@ -0,0 +1,35 @@ +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { seconds, ThrottlerModule } from "@nestjs/throttler"; +import { CacheManagerService } from "../modules/cache-manager/cache-manager.service"; +import { Module } from "@nestjs/common"; +import { CustomThrottlerInterceptor } from "./throttler.interceptor"; +import { CacheManagerModule } from "../modules/cache-manager/cache-manager.module"; + +@Module({ + imports: [ + CacheManagerModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigModule, CacheManagerModule], + inject: [CacheManagerService, ConfigService], + useFactory: ( + customStorage: CacheManagerService, + configService: ConfigService, + ) => ({ + storage: customStorage, + throttlers: [ + { + ttl: seconds(configService.get('users.rateLimit.default.ttl')), + limit: configService.get('users.rateLimit.default.limit') + }, + ], + }), + }), + ], + providers: [ + CustomThrottlerInterceptor, + ], + exports: [ + CustomThrottlerInterceptor, + ], +}) +export class CustomThrottlerModule {} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 9af9f2674..a18e14a41 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import configuration from './config/configuration'; import { TransformInterceptor } from './lib/transform.interceptor'; import { RequestLoggerInterceptor } from './middlewares/requests-logger.interceptor'; import { NewRelicInterceptor } from './lib/newrelic.interceptor'; +import { CustomThrottlerInterceptor } from './guards/throttler.interceptor'; const config = configuration(); const APP_PORT = config.port || 3000; @@ -57,6 +58,7 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe({ transform: true })); app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new NewRelicInterceptor()); + app.useGlobalInterceptors(app.get(CustomThrottlerInterceptor)); app.use(helmet()); app.use(apiMetrics()); From 45c685bfc5d69bfd8081f1527144d4d689ef0dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 14:24:10 +0100 Subject: [PATCH 06/24] fix(cache): dont keep the expiration time when the record is already expired --- .../cache-manager/cache-manager.service.spec.ts | 11 +++++++---- src/modules/cache-manager/cache-manager.service.ts | 11 ++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index a9aba91cb..dcab743a0 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -396,7 +396,7 @@ describe('CacheManagerService', () => { expect(result.timeToExpire).toBe(expiresAt - now); }); - it('When existing entry expired then it still increments but ttl becomes 0', async () => { + it('When existing entry expired then it sets hits=1 and ttl equals requested ttl (ms)', async () => { const now = 1_600_000_040_000; const expiresAt = now - 500; const existing = { hits: 5, expiresAt }; @@ -407,10 +407,13 @@ describe('CacheManagerService', () => { const setSpy = jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined as any); const result = await cacheManagerService.increment(key, ttlSeconds); - const expectedNewHits = existing.hits + 1; - const expectedTimeToExpire = 0; + const expectedNewHits = 1 + const expectedTimeToExpire = ttlSeconds * 1000; - expect(setSpy).toHaveBeenCalledWith(key, { hits: expectedNewHits, expiresAt }, 0); + expect(setSpy).toHaveBeenCalledWith(key, { + hits: expectedNewHits, + expiresAt: now + expectedTimeToExpire + }, expectedTimeToExpire); expect(result.totalHits).toBe(expectedNewHits); expect(result.timeToExpire).toBe(expectedTimeToExpire); }); diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index faa81b56f..e5ce58457 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -12,7 +12,9 @@ export class CacheManagerService { private readonly TTL_10_MINUTES = 10 * 60 * 1000; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; - constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} /** * Get user's storage usage @@ -125,14 +127,13 @@ export class CacheManagerService { const ttlMs = ttlSeconds * 1000; const now = Date.now(); - const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>( - key, - ); + const existing = await this.cacheManager.get<{ hits: number; expiresAt: number }>(key); let hits = 1; let expiresAt = now + ttlMs; + const existingAndNotExpired = existing && existing.expiresAt > now; - if (existing && typeof existing.hits === 'number' && existing.expiresAt) { + if (existingAndNotExpired) { hits = existing.hits + 1; expiresAt = existing.expiresAt; } From e1bff4fed53aae1ecd322d3d2be73f0ffa20853c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:17:08 +0100 Subject: [PATCH 07/24] fix(folders): rate limit folder meta to 30 x min --- src/modules/folder/folder.controller.ts | 7 +++++++ src/modules/folder/folder.module.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index a099d402a..8604c4b00 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -14,6 +14,7 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, @@ -66,6 +67,8 @@ import { ValidateUUIDPipe } from '../../common/pipes/validate-uuid.pipe'; import { GetFilesInFoldersDto } from './dto/get-files-in-folder.dto'; import { GetFoldersInFoldersDto } from './dto/get-folders-in-folder.dto'; import { GetFoldersQueryDto } from './dto/get-folders.dto'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; export class BadRequestWrongFolderIdException extends BadRequestException { constructor() { @@ -760,6 +763,10 @@ export class FolderController { return folderDto; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 30 }, + }) @Get('/meta') async getFolderMetaByPath( @UserDecorator() user: User, diff --git a/src/modules/folder/folder.module.ts b/src/modules/folder/folder.module.ts index be045868b..43d11d2bb 100644 --- a/src/modules/folder/folder.module.ts +++ b/src/modules/folder/folder.module.ts @@ -13,6 +13,8 @@ import { WorkspacesModule } from '../workspaces/workspaces.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; import { TrashModule } from '../trash/trash.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CacheManagerModule } from '../cache-manager/cache-manager.module'; @Module({ imports: [ @@ -25,9 +27,15 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; NotificationModule, forwardRef(() => TrashModule), FeatureLimitModule, + CacheManagerModule, ], controllers: [FolderController], - providers: [SequelizeFolderRepository, CryptoService, FolderUseCases], + providers: [ + SequelizeFolderRepository, + CryptoService, + FolderUseCases, + CustomEndpointThrottleGuard + ], exports: [FolderUseCases, SequelizeFolderRepository], }) export class FolderModule {} From fecb1e71cd068802d060abbd8a87cc4a48bce2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:25:49 +0100 Subject: [PATCH 08/24] fix(files): rate limit file meta requests x min --- src/modules/file/file.controller.ts | 6 ++++++ src/modules/file/file.module.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/modules/file/file.controller.ts b/src/modules/file/file.controller.ts index 39e96970c..6c4c10917 100644 --- a/src/modules/file/file.controller.ts +++ b/src/modules/file/file.controller.ts @@ -56,6 +56,8 @@ import { CreateThumbnailDto } from '../thumbnail/dto/create-thumbnail.dto'; import { ThumbnailUseCases } from '../thumbnail/thumbnail.usecase'; import { RequestLoggerInterceptor } from '../../middlewares/requests-logger.interceptor'; import { Version } from '../../common/decorators/version.decorator'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; @ApiTags('File') @Controller('files') @@ -449,6 +451,10 @@ export class FileController { return files; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/meta') @ApiOkResponse({ type: FileDto }) async getFileMetaByPath( diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index 22e02c93b..9496ca0a7 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -21,6 +21,7 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { RedisService } from '../../externals/redis/redis.service'; import { TrashModule } from '../trash/trash.module'; import { CacheManagerModule } from '../cache-manager/cache-manager.module'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { CacheManagerModule } from '../cache-manager/cache-manager.module'; FileUseCases, MailerService, RedisService, + CustomEndpointThrottleGuard ], exports: [ FileUseCases, From 025e249deb4afe20c98a4ad6936b02b65bad4009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:35:35 +0100 Subject: [PATCH 09/24] refactor: remove deprecated prometheus integration --- package.json | 2 -- src/main.ts | 4 +--- yarn.lock | 48 +----------------------------------------------- 3 files changed, 2 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 5f148715d..4dae6ee76 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main.ts b/src/main.ts index a18e14a41..7e837af2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,9 @@ import dotenv from 'dotenv'; dotenv.config({ path: `.env.${process.env.NODE_ENV}` }); import { ValidationPipe } from '@nestjs/common'; -import { NestFactory, Reflector } from '@nestjs/core'; +import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { Logger } from 'nestjs-pino'; -import apiMetrics from 'prometheus-api-metrics'; import helmet from 'helmet'; import { DocumentBuilder, @@ -61,7 +60,6 @@ async function bootstrap() { app.useGlobalInterceptors(app.get(CustomThrottlerInterceptor)); app.use(helmet()); - app.use(apiMetrics()); if (!config.isProduction) { app.useGlobalInterceptors(new RequestLoggerInterceptor()); diff --git a/yarn.lock b/yarn.lock index 8551bb56e..52529566b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1858,7 +1858,7 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -3759,11 +3759,6 @@ bignumber.js@^9.0.0: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== -bintrees@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" - integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== - bip39@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bip39/-/bip39-3.1.0.tgz#c55a418deaf48826a6ceb34ac55b3ee1577e18a3" @@ -4353,13 +4348,6 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d dependencies: ms "^2.1.3" -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -6389,11 +6377,6 @@ lodash.defaults@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== - lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -7319,11 +7302,6 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkginfo@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" - integrity sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ== - pluralize@8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -7409,23 +7387,6 @@ process-warning@^5.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7" integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== -prom-client@^15.0.0: - version "15.1.3" - resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2" - integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== - dependencies: - "@opentelemetry/api" "^1.4.0" - tdigest "^0.1.1" - -prometheus-api-metrics@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/prometheus-api-metrics/-/prometheus-api-metrics-4.0.0.tgz#f69b2ab5dffea5638d680b9287613d08cbf855e6" - integrity sha512-xZq/fFTCOfEFCWRCok5cF969Xs2qPOlRkO8Tn3rVijeGkVdMEwlsDUM3oDXs/VdzMgCg5NFGEWHlGq4E3JgKKw== - dependencies: - debug "^3.2.7" - lodash.get "^4.4.2" - pkginfo "^0.4.1" - prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -8341,13 +8302,6 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -tdigest@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" - integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA== - dependencies: - bintrees "1.0.2" - terser-webpack-plugin@^5.3.11: version "5.3.14" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz#9031d48e57ab27567f02ace85c7d690db66c3e06" From fe9418470f8e95720d3da918f94589432e8dfac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:39:22 +0100 Subject: [PATCH 10/24] fix(users): custom rate limit for usage requests --- src/modules/user/user.controller.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index da1b6e4ad..adbf07bf0 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -108,6 +108,8 @@ import { PaymentRequiredException } from '../feature-limit/exceptions/payment-re import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { KlaviyoTrackingService } from '../../externals/klaviyo/klaviyo-tracking.service'; import { CaptchaGuard } from '../auth/captcha.guard'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; @ApiTags('User') @Controller('users') @@ -1257,6 +1259,10 @@ export class UserController { } } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/usage') @ApiBearerAuth() @ApiOperation({ From 5e46eb387de2ebcdbd60fb2485fab0d57df4cd69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Fri, 16 Jan 2026 16:46:24 +0100 Subject: [PATCH 11/24] fix(files): throttle listing requests x min --- src/modules/file/file.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/file/file.controller.ts b/src/modules/file/file.controller.ts index 6c4c10917..3936efa45 100644 --- a/src/modules/file/file.controller.ts +++ b/src/modules/file/file.controller.ts @@ -347,6 +347,10 @@ export class FileController { return result; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/') @ApiOkResponse({ isArray: true, type: FileDto }) async getFiles( From cf1e4763173bec0ec30dffddb4bd5900f0b5a2de Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:48:28 -0600 Subject: [PATCH 12/24] fix(folders): add custom throttle guard for folder listing requests --- src/modules/folder/folder.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 8604c4b00..723bc965b 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -482,6 +482,10 @@ export class FolderController { }; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/') @ApiOkResponse({ isArray: true, type: FolderDto }) async getFolders( From 35dfccee9f8193ed9e4f6a0a294844a90ba601be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Mon, 19 Jan 2026 09:33:31 +0100 Subject: [PATCH 13/24] fix(users, folders): set custom throttle to refresh and folder creation --- src/modules/folder/folder.controller.ts | 4 ++++ src/modules/user/user.controller.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 723bc965b..51b01c9a6 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -87,6 +87,10 @@ export class FolderController { private readonly storageNotificationService: StorageNotificationService, ) {} + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 80 } + }) @Post('/') @ApiOperation({ summary: 'Create Folder', diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index adbf07bf0..7dc05203f 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -462,6 +462,10 @@ export class UserController { return userCredentials; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 5 }, + }) @Get('/refresh') @HttpCode(200) @ApiOperation({ summary: 'Refresh session token' }) From cad6597fb45c7b088b7884ce40be9e3cac729a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Guti=C3=A9rrez?= Date: Mon, 19 Jan 2026 09:48:13 +0100 Subject: [PATCH 14/24] fix(folder): increase create folder throttler to 30k/h --- src/modules/folder/folder.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 51b01c9a6..d1f76164b 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -89,7 +89,7 @@ export class FolderController { @UseGuards(CustomEndpointThrottleGuard) @CustomThrottle({ - short: { ttl: 60, limit: 80 } + long: { ttl: 3600, limit: 30000 } }) @Post('/') @ApiOperation({ From d80d80705184a2434c8220091228bcffa815e763 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 16 Dec 2025 12:08:26 +0100 Subject: [PATCH 15/24] feat(migration): add user_id column to file_versions table --- ...1216104346-add-user-id-to-file-versions.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 migrations/20251216104346-add-user-id-to-file-versions.js diff --git a/migrations/20251216104346-add-user-id-to-file-versions.js b/migrations/20251216104346-add-user-id-to-file-versions.js new file mode 100644 index 000000000..fc88efc2e --- /dev/null +++ b/migrations/20251216104346-add-user-id-to-file-versions.js @@ -0,0 +1,28 @@ +'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.INTEGER, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); + + await queryInterface.addIndex(tableName, ['user_id', 'status'], { + name: 'file_versions_user_id_status_idx', + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex(tableName, 'file_versions_user_id_status_idx'); + await queryInterface.removeColumn(tableName, 'user_id'); + }, +}; From 1bc3e7468b2e1582e1350227894f48c20d276a45 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Tue, 30 Dec 2025 07:50:50 +0100 Subject: [PATCH 16/24] fix(migration): use user uuid instead of id for file_versions --- migrations/20251216104346-add-user-id-to-file-versions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/20251216104346-add-user-id-to-file-versions.js b/migrations/20251216104346-add-user-id-to-file-versions.js index fc88efc2e..b939e53df 100644 --- a/migrations/20251216104346-add-user-id-to-file-versions.js +++ b/migrations/20251216104346-add-user-id-to-file-versions.js @@ -6,11 +6,11 @@ const tableName = 'file_versions'; module.exports = { async up(queryInterface, Sequelize) { await queryInterface.addColumn(tableName, 'user_id', { - type: Sequelize.INTEGER, + type: Sequelize.STRING(36), allowNull: true, references: { model: 'users', - key: 'id', + key: 'uuid', }, onUpdate: 'CASCADE', onDelete: 'CASCADE', From 0118ada705b01162e55cc7353777e6bd762e666b Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Fri, 16 Jan 2026 16:03:51 +0100 Subject: [PATCH 17/24] chore(migration): update timestamp for add-user-id-to-file-versions migration --- ...versions.js => 20260116150327-add-user-id-to-file-versions.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename migrations/{20251216104346-add-user-id-to-file-versions.js => 20260116150327-add-user-id-to-file-versions.js} (100%) diff --git a/migrations/20251216104346-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js similarity index 100% rename from migrations/20251216104346-add-user-id-to-file-versions.js rename to migrations/20260116150327-add-user-id-to-file-versions.js From bf96510293a74eb4d7b9bd435aa4512833133d2d Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Fri, 16 Jan 2026 16:08:32 +0100 Subject: [PATCH 18/24] fix(migration): make user_id NOT NULL in file_versions IMPORTANT: Delete all test records from file_versions table before running this migration: DELETE FROM file_versions WHERE 1=1; --- migrations/20260116150327-add-user-id-to-file-versions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index b939e53df..228d1d561 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -7,7 +7,7 @@ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.addColumn(tableName, 'user_id', { type: Sequelize.STRING(36), - allowNull: true, + allowNull: false, references: { model: 'users', key: 'uuid', From 4f4825b84df688bfe3d057347699089e7b0bcb47 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 11:26:37 +0100 Subject: [PATCH 19/24] fix(migration): create file_versions user_id status index concurrently --- ...260116150327-add-user-id-to-file-versions.js | 5 ----- ...28-add-index-file-versions-user-id-status.js | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 migrations/20260116150328-add-index-file-versions-user-id-status.js diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index 228d1d561..8360c74f0 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -15,14 +15,9 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }); - - await queryInterface.addIndex(tableName, ['user_id', 'status'], { - name: 'file_versions_user_id_status_idx', - }); }, async down(queryInterface) { - await queryInterface.removeIndex(tableName, 'file_versions_user_id_status_idx'); await queryInterface.removeColumn(tableName, 'user_id'); }, }; diff --git a/migrations/20260116150328-add-index-file-versions-user-id-status.js b/migrations/20260116150328-add-index-file-versions-user-id-status.js new file mode 100644 index 000000000..67b2e2fa8 --- /dev/null +++ b/migrations/20260116150328-add-index-file-versions-user-id-status.js @@ -0,0 +1,17 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + CREATE INDEX CONCURRENTLY file_versions_user_id_status_idx + ON file_versions (user_id, status); + `); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + DROP INDEX CONCURRENTLY file_versions_user_id_status_idx; + `); + } +}; From c7f7e15ff36f7a802755be7f8a23bd935ad2f10d Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 13:43:45 +0100 Subject: [PATCH 20/24] perf(migration): use partial index for file_versions user_id lookup --- .../20260116150327-add-user-id-to-file-versions.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index 8360c74f0..63c56e972 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -15,9 +15,18 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }); + + await queryInterface.sequelize.query(` + CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx + ON file_versions(user_id) + WHERE status = 'EXISTS'; + `); }, async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; + `); await queryInterface.removeColumn(tableName, 'user_id'); }, }; From daa199c072b2f3235750760ff36d0a968ed440b8 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 13:53:40 +0100 Subject: [PATCH 21/24] perf(migration): use partial index for file_versions user_id lookup --- ...0260116150328-add-index-file-versions-user-id-status.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/migrations/20260116150328-add-index-file-versions-user-id-status.js b/migrations/20260116150328-add-index-file-versions-user-id-status.js index 67b2e2fa8..baf5eacb2 100644 --- a/migrations/20260116150328-add-index-file-versions-user-id-status.js +++ b/migrations/20260116150328-add-index-file-versions-user-id-status.js @@ -4,14 +4,15 @@ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.sequelize.query(` - CREATE INDEX CONCURRENTLY file_versions_user_id_status_idx - ON file_versions (user_id, status); + 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 file_versions_user_id_status_idx; + DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; `); } }; From de4d38810dcd711ef1344bf6dcc42db6385d0383 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 13:55:57 +0100 Subject: [PATCH 22/24] fix(migration): remove duplicate index creation from user_id migration --- .../20260116150327-add-user-id-to-file-versions.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js index 63c56e972..8360c74f0 100644 --- a/migrations/20260116150327-add-user-id-to-file-versions.js +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -15,18 +15,9 @@ module.exports = { onUpdate: 'CASCADE', onDelete: 'CASCADE', }); - - await queryInterface.sequelize.query(` - CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx - ON file_versions(user_id) - WHERE status = 'EXISTS'; - `); }, async down(queryInterface) { - await queryInterface.sequelize.query(` - DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; - `); await queryInterface.removeColumn(tableName, 'user_id'); }, }; From 27175ccf04c2482558d8c11a266f2a446ae83add Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Mon, 19 Jan 2026 16:09:13 +0100 Subject: [PATCH 23/24] feat(usage): include file versions in storage calculation --- src/modules/file/file-version.domain.spec.ts | 2 + src/modules/file/file-version.domain.ts | 4 + src/modules/file/file-version.model.ts | 8 ++ .../file/file-version.repository.spec.ts | 70 ++++++++++++++ src/modules/file/file-version.repository.ts | 17 ++++ src/modules/file/file.usecase.spec.ts | 92 +++++++++++++++++-- src/modules/file/file.usecase.ts | 8 +- test/fixtures.ts | 1 + 8 files changed, 193 insertions(+), 9 deletions(-) diff --git a/src/modules/file/file-version.domain.spec.ts b/src/modules/file/file-version.domain.spec.ts index fe4bee07a..423e2dc02 100644 --- a/src/modules/file/file-version.domain.spec.ts +++ b/src/modules/file/file-version.domain.spec.ts @@ -8,6 +8,7 @@ describe('FileVersion Domain', () => { const mockAttributes: FileVersionAttributes = { id: 'version-id-123', fileId: 'file-id-456', + userId: 'user-uuid-789', networkFileId: 'network-file-id-789', size: BigInt(1024), status: FileVersionStatus.EXISTS, @@ -63,6 +64,7 @@ describe('FileVersion Domain', () => { expect(json).toEqual({ id: mockAttributes.id, fileId: mockAttributes.fileId, + userId: mockAttributes.userId, networkFileId: mockAttributes.networkFileId, size: mockAttributes.size, status: mockAttributes.status, diff --git a/src/modules/file/file-version.domain.ts b/src/modules/file/file-version.domain.ts index 05b36bde2..e396d6539 100644 --- a/src/modules/file/file-version.domain.ts +++ b/src/modules/file/file-version.domain.ts @@ -6,6 +6,7 @@ export enum FileVersionStatus { export interface FileVersionAttributes { id: string; fileId: string; + userId: string; networkFileId: string; size: bigint; status: FileVersionStatus; @@ -16,6 +17,7 @@ export interface FileVersionAttributes { export class FileVersion implements FileVersionAttributes { id: string; fileId: string; + userId: string; networkFileId: string; size: bigint; status: FileVersionStatus; @@ -25,6 +27,7 @@ export class FileVersion implements FileVersionAttributes { private constructor(attributes: FileVersionAttributes) { this.id = attributes.id; this.fileId = attributes.fileId; + this.userId = attributes.userId; this.networkFileId = attributes.networkFileId; this.size = attributes.size; this.status = attributes.status; @@ -48,6 +51,7 @@ export class FileVersion implements FileVersionAttributes { return { id: this.id, fileId: this.fileId, + userId: this.userId, networkFileId: this.networkFileId, size: this.size, status: this.status, diff --git a/src/modules/file/file-version.model.ts b/src/modules/file/file-version.model.ts index 9aa400481..691b66c3f 100644 --- a/src/modules/file/file-version.model.ts +++ b/src/modules/file/file-version.model.ts @@ -9,6 +9,7 @@ import { Table, } from 'sequelize-typescript'; import { FileModel } from './file.model'; +import { UserModel } from '../user/user.model'; import { FileVersionAttributes, FileVersionStatus, @@ -33,6 +34,13 @@ export class FileVersionModel extends Model implements FileVersionAttributes { @BelongsTo(() => FileModel, 'fileId') file: FileModel; + @ForeignKey(() => UserModel) + @Column(DataType.STRING(36)) + userId: string; + + @BelongsTo(() => UserModel, 'userId') + user: UserModel; + @Column(DataType.STRING) networkFileId: string; diff --git a/src/modules/file/file-version.repository.spec.ts b/src/modules/file/file-version.repository.spec.ts index 6bbee7f4a..5566d1179 100644 --- a/src/modules/file/file-version.repository.spec.ts +++ b/src/modules/file/file-version.repository.spec.ts @@ -29,6 +29,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -37,6 +38,7 @@ describe('SequelizeFileVersionRepository', () => { expect(result).toBeInstanceOf(FileVersion); expect(fileVersionModel.create).toHaveBeenCalledWith({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -53,6 +55,7 @@ describe('SequelizeFileVersionRepository', () => { await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, } as any); @@ -76,6 +79,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -95,6 +99,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -208,6 +213,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.upsert({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -217,6 +223,7 @@ describe('SequelizeFileVersionRepository', () => { expect(fileVersionModel.upsert).toHaveBeenCalledWith( expect.objectContaining({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -403,4 +410,67 @@ describe('SequelizeFileVersionRepository', () => { ); }); }); + + describe('sumExistingSizesByUser', () => { + it('When user has versions, then it returns the sum', async () => { + const userId = 'user-uuid-123'; + const totalSize = 5000; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: totalSize }] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(totalSize); + expect(fileVersionModel.findAll).toHaveBeenCalledWith({ + attributes: expect.any(Array), + where: { + userId, + status: FileVersionStatus.EXISTS, + }, + raw: true, + }); + }); + + it('When user has no versions, then it returns 0', async () => { + const userId = 'user-uuid-456'; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: null }] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(0); + }); + + it('When query returns empty array, then it returns 0', async () => { + const userId = 'user-uuid-789'; + + jest.spyOn(fileVersionModel, 'findAll').mockResolvedValue([] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(0); + }); + + it('When summing sizes, then it only counts EXISTS status versions', async () => { + const userId = 'user-uuid-abc'; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: 1000 }] as any); + + await repository.sumExistingSizesByUser(userId); + + expect(fileVersionModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: FileVersionStatus.EXISTS, + }), + }), + ); + }); + }); }); diff --git a/src/modules/file/file-version.repository.ts b/src/modules/file/file-version.repository.ts index 02e4a4d6e..1fa44a4ce 100644 --- a/src/modules/file/file-version.repository.ts +++ b/src/modules/file/file-version.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize'; import { FileVersionModel } from './file-version.model'; import { FileVersion, @@ -20,6 +21,7 @@ export interface FileVersionRepository { updateStatus(id: string, status: FileVersionStatus): Promise; updateStatusBatch(ids: string[], status: FileVersionStatus): Promise; deleteAllByFileId(fileId: string): Promise; + sumExistingSizesByUser(userId: string): Promise; } @Injectable() @@ -32,6 +34,7 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { async create(version: CreateFileVersionData): Promise { const createdVersion = await this.model.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status || FileVersionStatus.EXISTS, @@ -44,6 +47,7 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { const [instance] = await this.model.upsert( { fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status || FileVersionStatus.EXISTS, @@ -108,4 +112,17 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { }, ); } + + async sumExistingSizesByUser(userId: string): Promise { + const result = await this.model.findAll({ + attributes: [[Sequelize.fn('SUM', Sequelize.col('size')), 'total']], + where: { + userId, + status: FileVersionStatus.EXISTS, + }, + raw: true, + }); + + return Number(result[0]?.['total']) || 0; + } } diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 791a052f4..4aa22fca4 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -1637,31 +1637,101 @@ describe('FileUseCases', () => { }); describe('getUserUsedStorage', () => { - it('When called, it should return the user total used space', async () => { - const totalUsage = 1000; + it('When called, it should return the sum of files and versions usage', async () => { + const filesUsage = 1000; + const versionsUsage = 500; + const expectedTotal = filesUsage + versionsUsage; + + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(expectedTotal); + expect(service.getUserUsedStorageIncrementally).toHaveBeenCalledWith( + userMocked, + ); + expect(fileVersionRepository.sumExistingSizesByUser).toHaveBeenCalledWith( + userMocked.uuid, + ); + }); + + it('When user has only files usage, then it returns files usage', async () => { + const filesUsage = 1000; + const versionsUsage = 0; + + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(filesUsage); + }); + + it('When user has only versions usage, then it returns versions usage', async () => { + const filesUsage = 0; + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') - .mockResolvedValueOnce(totalUsage); + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); - expect(result).toEqual(totalUsage); + + expect(result).toEqual(versionsUsage); }); - it('When getUserUsedStorageIncrementally returns null, it should return 0', async () => { + it('When getUserUsedStorageIncrementally returns null, it should treat as 0', async () => { + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') .mockResolvedValueOnce(null); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); - expect(result).toEqual(0); + + expect(result).toEqual(versionsUsage); }); - it('When getUserUsedStorageIncrementally returns undefined, it should return 0', async () => { + it('When getUserUsedStorageIncrementally returns undefined, it should treat as 0', async () => { + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') .mockResolvedValueOnce(undefined); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(versionsUsage); + }); + + it('When both return null/undefined, it should return 0', async () => { + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(null); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(0); + + const result = await service.getUserUsedStorage(userMocked); + expect(result).toEqual(0); }); }); @@ -1874,6 +1944,7 @@ describe('FileUseCases', () => { FileVersion.build({ id: v4(), fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-1', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -1925,6 +1996,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -1969,6 +2041,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: 'different-file-uuid', + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -1992,6 +2065,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'old-network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2021,6 +2095,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'old-network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2083,6 +2158,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: 'different-file-uuid', + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2105,6 +2181,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.DELETED, @@ -2259,6 +2336,7 @@ describe('FileUseCases', () => { ); expect(upsertSpy).toHaveBeenCalledWith({ fileId: mockFile.uuid, + userId: userMocked.uuid, networkFileId: mockFile.fileId, size: mockFile.size, status: 'EXISTS', diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 70999d261..ad7645be3 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -107,9 +107,12 @@ export class FileUseCases { } async getUserUsedStorage(user: User): Promise { - const usageCalculation = await this.getUserUsedStorageIncrementally(user); + const [filesUsage, versionsUsage] = await Promise.all([ + this.getUserUsedStorageIncrementally(user), + this.fileVersionRepository.sumExistingSizesByUser(user.uuid), + ]); - return usageCalculation || 0; + return (filesUsage || 0) + versionsUsage; } async getUserUsedStorageIncrementally(user: User): Promise { @@ -906,6 +909,7 @@ export class FileUseCases { await Promise.all([ this.fileVersionRepository.upsert({ fileId: file.uuid, + userId: user.uuid, networkFileId: file.fileId, size: file.size, status: FileVersionStatus.EXISTS, diff --git a/test/fixtures.ts b/test/fixtures.ts index 3c8bb042f..544da6006 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -762,6 +762,7 @@ export const newFileVersion = (params?: { const fileVersion = FileVersion.build({ id: v4(), fileId: v4(), + userId: v4(), networkFileId: randomDataGenerator.hash({ length: constants.BUCKET_ID_LENGTH, }), From d6dfa6e30a31bef34905e99939c6b8b308339a70 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:14:38 -0600 Subject: [PATCH 24/24] feat(migration): drop legacy unique index on folders table --- ...-unique-index-folders-plainname-parentid.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 migrations/20260205022310-drop-legacy-unique-index-folders-plainname-parentid.js diff --git a/migrations/20260205022310-drop-legacy-unique-index-folders-plainname-parentid.js b/migrations/20260205022310-drop-legacy-unique-index-folders-plainname-parentid.js new file mode 100644 index 000000000..eb07d856d --- /dev/null +++ b/migrations/20260205022310-drop-legacy-unique-index-folders-plainname-parentid.js @@ -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; + `); + }, +};