From 81e4abbfeb8affcfbf25d189ef4ace300011685b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:41:24 +0200 Subject: [PATCH] Feature/add daily request limit (#7487) Add daily request limit --- apps/api/src/app/auth/api-key.strategy.ts | 7 ++ apps/api/src/app/auth/jwt.strategy.ts | 7 ++ .../app/portfolio/portfolio.service.spec.ts | 1 + apps/api/src/app/user/user.service.ts | 70 +++++++++++++++++-- libs/common/src/lib/config.ts | 3 + 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts index f9937aaa7..232a272bc 100644 --- a/apps/api/src/app/auth/api-key.strategy.ts +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy( ); } + if (await this.userService.isDailyRequestLimitExceeded({ user })) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + await this.prismaService.analytics.upsert({ create: { user: { connect: { id: user.id } } }, update: { diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index c70e8fb60..189389a86 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ); } + if (await this.userService.isDailyRequestLimitExceeded({ user })) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + const country = countriesAndTimezones.getCountryForTimezone(timezone)?.id; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c16590cce..d85258f7e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -92,6 +92,7 @@ describe('PortfolioService', () => { null, null, null, + null, null ); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 7f3631c54..febc967d2 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -31,9 +31,12 @@ import { DEFAULT_LOCALE, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, - TAG_ID_EXCLUDE_FROM_ANALYSIS + TAG_ID_EXCLUDE_FROM_ANALYSIS, + THROTTLE_DAILY_KEY, + THROTTLE_DAILY_TTL } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { @@ -50,15 +53,18 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; -import { without } from 'lodash'; +import { isNil, without } from 'lodash'; import { createHmac } from 'node:crypto'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + public constructor( private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, @@ -67,7 +73,9 @@ export class UserService { private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, - private readonly tagService: TagService + private readonly tagService: TagService, + @InjectThrottlerStorage() + private readonly throttlerStorage: ThrottlerStorage ) {} public async count(args?: Prisma.UserCountArgs) { @@ -228,6 +236,38 @@ export class UserService { return usersWithAdminRole.length > 0; } + public async isDailyRequestLimitExceeded({ + user + }: { + user: UserWithSettings; + }) { + if (user.subscription?.type === SubscriptionType.Premium) { + return false; + } + + const maxDailyRequests = await this.getMaxDailyRequests(); + + if (maxDailyRequests === undefined) { + return false; + } + + try { + const { isBlocked } = await this.throttlerStorage.increment( + `${THROTTLE_DAILY_KEY}-${user.id}`, + THROTTLE_DAILY_TTL, + maxDailyRequests, + THROTTLE_DAILY_TTL, + THROTTLE_DAILY_KEY + ); + + return isBlocked; + } catch (error) { + this.logger.error(error); + + return false; + } + } + public async user( userWhereUniqueInput: Prisma.UserWhereUniqueInput ): Promise { @@ -782,4 +822,26 @@ export class UserService { return settings; } + + private async getMaxDailyRequests() { + const value = await this.propertyService.getByKey( + PROPERTY_MAX_DAILY_REQUESTS + ); + + if (isNil(value) || value === '') { + return undefined; + } + + const maxDailyRequests = Number(value); + + if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) { + this.logger.warn( + `The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied` + ); + + return undefined; + } + + return maxDailyRequests; + } } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 6b070755f..890c29fbb 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -253,6 +253,7 @@ export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID'; export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; +export const PROPERTY_MAX_DAILY_REQUESTS = 'MAX_DAILY_REQUESTS'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES'; @@ -326,6 +327,8 @@ export const TAG_ID_EXCLUDE_FROM_ANALYSIS = 'f2e868af-8333-459f-b161-cbc6544c24bd'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; +export const THROTTLE_DAILY_KEY = 'daily'; +export const THROTTLE_DAILY_TTL = ms('1 day'); export const THROTTLE_DEFAULT_LIMIT = 10; export const THROTTLE_DEFAULT_TTL = ms('1 minute'); export const THROTTLE_SIGNUP_LIMIT = 5;