From ac8616856a152003c8ba4e8fd952e82096a17b63 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:33:16 +0200 Subject: [PATCH 1/8] Task/expose data source for Fear & Greed Index (#7285) * Expose data source for Fear & Greed Index * Update changelog --- CHANGELOG.md | 6 ++++ .../market-data/market-data.controller.ts | 6 ++-- .../market-data/market-data.module.ts | 8 ++++- apps/api/src/app/info/info.service.ts | 13 +++++--- .../configuration/configuration.service.ts | 3 ++ .../data-provider/data-provider.service.ts | 6 ++++ .../rapid-api/rapid-api.service.ts | 32 +++++++++++-------- .../interfaces/environment.interface.ts | 1 + .../twitter-bot/twitter-bot.module.ts | 8 ++++- .../twitter-bot/twitter-bot.service.ts | 10 +++--- libs/common/src/lib/config.ts | 1 - 11 files changed, 65 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d049e9a..c139319a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Unreleased + +### Added + +- Exposed the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable to set the data source of the _Fear & Greed Index_ (market mood) + ## 3.23.0 - 2026-07-10 ### Changed diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 5dad0511f..105bb0780 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -1,11 +1,11 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, - ghostfolioFearAndGreedIndexDataSourceStocks, ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; @@ -36,6 +36,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes'; @Controller('market-data') export class MarketDataController { public constructor( + private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly symbolProfileService: SymbolProfileService, @@ -64,7 +65,8 @@ export class MarketDataController { this.symbolService.get({ includeHistoricalData, dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), symbol: ghostfolioFearAndGreedIndexSymbolStocks }, useIntradayData: true diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts index 1de10907b..47ce11502 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.module.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts @@ -1,4 +1,5 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -8,6 +9,11 @@ import { MarketDataController } from './market-data.controller'; @Module({ controllers: [MarketDataController], - imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule] + imports: [ + DataProviderModule, + MarketDataServiceModule, + SymbolModule, + SymbolProfileModule + ] }) export class MarketDataModule {} diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 4441036a2..10836f7b8 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { @@ -15,8 +16,7 @@ import { PROPERTY_GITHUB_STARGAZERS, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SLACK_COMMUNITY_USERS, - PROPERTY_UPTIME, - ghostfolioFearAndGreedIndexDataSourceStocks + PROPERTY_UPTIME } from '@ghostfolio/common/config'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -33,6 +33,7 @@ export class InfoService { public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly jwtService: JwtService, private readonly propertyService: PropertyService, @@ -60,13 +61,15 @@ export class InfoService { } if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { + const fearAndGreedIndexDataSource = + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(); + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { info.fearAndGreedDataSource = encodeDataSource( - ghostfolioFearAndGreedIndexDataSourceStocks + fearAndGreedIndexDataSource ); } else { - info.fearAndGreedDataSource = - ghostfolioFearAndGreedIndexDataSourceStocks; + info.fearAndGreedDataSource = fearAndGreedIndexDataSource; } globalPermissions.push(permissions.enableFearAndGreedIndex); diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index c96ccd946..b097627df 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -34,6 +34,9 @@ export class ConfigurationService { CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), + DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: str({ + default: DataSource.RAPID_API + }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCES: json({ default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index c6e9c83c1..8723466b0 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -177,6 +177,12 @@ export class DataProviderService implements OnModuleInit { ]; } + public getDataSourceForFearAndGreedIndexStocks(): DataSource { + return DataSource[ + this.configurationService.get('DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS') + ]; + } + public getDataSourceForImport(): DataSource { return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; } diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index d8ba1ec67..e704f2861 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -64,13 +64,15 @@ export class RapidApiService implements DataProviderInterface { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { const fgi = await this.getFearAndGreedIndex(); - return { - [symbol]: { - [format(getYesterday(), DATE_FORMAT)]: { - marketPrice: fgi.previousClose.value + if (fgi) { + return { + [symbol]: { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: fgi.previousClose.value + } } - } - }; + }; + } } } catch (error) { throw new Error( @@ -101,14 +103,16 @@ export class RapidApiService implements DataProviderInterface { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { const fgi = await this.getFearAndGreedIndex(); - return { - [symbol]: { - currency: undefined, - dataSource: this.getName(), - marketPrice: fgi.now.value, - marketState: 'open' - } - }; + if (fgi) { + return { + [symbol]: { + currency: undefined, + dataSource: this.getName(), + marketPrice: fgi.now.value, + marketState: 'open' + } + }; + } } } catch (error) { this.logger.error(error); diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 89463e201..6147d4e45 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -13,6 +13,7 @@ export interface Environment extends CleanedEnvAccessors { CACHE_QUOTES_TTL: number; CACHE_TTL: number; DATA_SOURCE_EXCHANGE_RATES: string; + DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: string; DATA_SOURCE_IMPORT: string; DATA_SOURCES: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts index 80d53169c..bdb1a7988 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.module.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -1,13 +1,19 @@ import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { Module } from '@nestjs/common'; @Module({ exports: [TwitterBotService], - imports: [BenchmarkModule, ConfigurationModule, SymbolModule], + imports: [ + BenchmarkModule, + ConfigurationModule, + DataProviderModule, + SymbolModule + ], providers: [TwitterBotService] }) export class TwitterBotModule {} diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts index 366b016b6..8dffddf7b 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -1,10 +1,8 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { - ghostfolioFearAndGreedIndexDataSourceStocks, - ghostfolioFearAndGreedIndexSymbolStocks -} from '@ghostfolio/common/config'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { resolveFearAndGreedIndex, resolveMarketCondition @@ -24,6 +22,7 @@ export class TwitterBotService implements OnModuleInit { public constructor( private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly symbolService: SymbolService ) {} @@ -49,7 +48,8 @@ export class TwitterBotService implements OnModuleInit { try { const symbolItem = await this.symbolService.get({ dataGatheringItem: { - dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, + dataSource: + this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(), symbol: ghostfolioFearAndGreedIndexSymbolStocks } }); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index c60170bbb..d613fb3cf 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -11,7 +11,6 @@ export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = DataSource.MANUAL; -export const ghostfolioFearAndGreedIndexDataSourceStocks = DataSource.RAPID_API; export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`; From cb692f8d06f710274a9bfcb57a856d6b3fbbc320 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:33:55 +0700 Subject: [PATCH 2/8] Task/improve type safety in user detail dialog component (#7292) Improve type safety --- .../user-detail-dialog.component.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts index 73e09f612..637fe8c31 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts @@ -9,7 +9,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - Inject, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -49,28 +49,30 @@ import { templateUrl: './user-detail-dialog.html' }) export class GfUserDetailDialogComponent implements OnInit { - public baseCurrency: string; - public readonly getCountryName = getCountryName; - public subscriptionsDataSource = new MatTableDataSource(); - public subscriptionsDisplayedColumns = [ + protected baseCurrency: string; + protected readonly getCountryName = getCountryName; + protected readonly subscriptionsDataSource = + new MatTableDataSource(); + protected readonly subscriptionsDisplayedColumns = [ 'createdAt', 'type', 'price', 'expiresAt' ]; - public user: AdminUserResponse; - - public constructor( - private adminService: AdminService, - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, - private dataService: DataService, - private destroyRef: DestroyRef, - public dialogRef: MatDialogRef< - GfUserDetailDialogComponent, - UserDetailDialogResult - > - ) { + protected user: AdminUserResponse; + + protected readonly data = inject(MAT_DIALOG_DATA); + + private readonly adminService = inject(AdminService); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogRef = + inject>( + MatDialogRef + ); + + public constructor() { this.baseCurrency = this.dataService.fetchInfo().baseCurrency; addIcons({ @@ -98,14 +100,14 @@ export class GfUserDetailDialogComponent implements OnInit { }); } - public deleteUser() { + protected deleteUser() { this.dialogRef.close({ action: 'delete', userId: this.data.userId }); } - public getSum() { + protected getSum() { return getSum( this.subscriptionsDataSource.data .filter(({ price }) => { @@ -117,7 +119,7 @@ export class GfUserDetailDialogComponent implements OnInit { ).toNumber(); } - public getType({ createdAt, expiresAt, price }: Subscription) { + protected getType({ createdAt, expiresAt, price }: Subscription) { if (price) { return $localize`Paid`; } @@ -127,7 +129,7 @@ export class GfUserDetailDialogComponent implements OnInit { : $localize`Coupon`; } - public onClose() { + protected onClose() { this.dialogRef.close(); } } From 9cea437b2c1dff27541ef3788a85abadf68333c3 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:31:55 +0700 Subject: [PATCH 3/8] Task/improve type safety in user account registration dialog component (#7301) * fix(client): resolve type errors * feat(client): enforce encapsulation * feat(client): replace constructor based DI with inject functions * feat(client): implement viewChild signal --- ...r-account-registration-dialog.component.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts b/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts index cbbe2d29c..0265357bf 100644 --- a/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts +++ b/apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts @@ -9,8 +9,8 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - Inject, - ViewChild + inject, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -53,26 +53,28 @@ import { UserAccountRegistrationDialogParams } from './interfaces/interfaces'; templateUrl: 'user-account-registration-dialog.html' }) export class GfUserAccountRegistrationDialogComponent { - @ViewChild(MatStepper) stepper!: MatStepper; + protected readonly stepper = viewChild.required(MatStepper); - public accessToken: string; - public authToken: string; - public isCreateAccountButtonDisabled = true; - public isDisclaimerChecked = false; - public role: string; - public routerLinkAboutTermsOfService = + protected accessToken: string | undefined; + protected authToken: string; + protected isCreateAccountButtonDisabled = true; + protected isDisclaimerChecked = false; + protected role: string; + protected readonly routerLinkAboutTermsOfService = publicRoutes.about.subRoutes.termsOfService.routerLink; - public constructor( - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams, - private dataService: DataService, - private destroyRef: DestroyRef - ) { + protected readonly data = + inject(MAT_DIALOG_DATA); + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + + public constructor() { addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline }); } - public createAccount() { + protected createAccount() { this.dataService .postUser() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -81,17 +83,17 @@ export class GfUserAccountRegistrationDialogComponent { this.authToken = authToken; this.role = role; - this.stepper.next(); + this.stepper().next(); this.changeDetectorRef.markForCheck(); }); } - public enableCreateAccountButton() { + protected enableCreateAccountButton() { this.isCreateAccountButtonDisabled = false; } - public onChangeDislaimerChecked() { + protected onChangeDislaimerChecked() { this.isDisclaimerChecked = !this.isDisclaimerChecked; } } From d207d7a753c179aff3f1c2ad08875d5c679e4e04 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:33:32 +0200 Subject: [PATCH 4/8] Feature/add rate limiting to authentication and sign up endpoints (#7263) * Add rate limiting to authentication and sign up endpoints * Update changelog --- CHANGELOG.md | 2 + README.md | 43 +++++++-------- apps/api/src/app/app.module.ts | 53 ++++++++++++++++--- apps/api/src/app/auth/auth.controller.ts | 5 ++ .../src/app/redis-cache/redis-cache.module.ts | 11 +--- apps/api/src/app/user/user.controller.ts | 15 +++++- apps/api/src/guards/custom-throttler.guard.ts | 21 ++++++++ apps/api/src/helper/redis.helper.ts | 22 ++++++++ apps/api/src/main.ts | 20 +++++++ .../configuration/configuration.service.ts | 26 ++++++++- .../interfaces/environment.interface.ts | 2 + .../app/components/header/header.component.ts | 15 ++++-- .../src/app/core/http-response.interceptor.ts | 22 +++++--- libs/common/src/lib/config.ts | 5 ++ package-lock.json | 46 ++++++++++++++++ package.json | 2 + 16 files changed, 259 insertions(+), 51 deletions(-) create mode 100644 apps/api/src/guards/custom-throttler.guard.ts create mode 100644 apps/api/src/helper/redis.helper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c139319a0..842a41aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Exposed the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable to set the data source of the _Fear & Greed Index_ (market mood) +- Exposed the `ENABLE_FEATURE_RATE_LIMITING` environment variable to control rate limiting for authentication and sign-up endpoints +- Exposed the `TRUST_PROXY` environment variable to determine the client IP address when running behind a reverse proxy ## 3.23.0 - 2026-07-10 diff --git a/README.md b/README.md index a9b3a3055..ce89412a3 100644 --- a/README.md +++ b/README.md @@ -85,27 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | -| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| Name | Type | Default Value | Description | +| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | +| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | #### OpenID Connect OIDC (experimental) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 04b1f3bf2..4bdd50c9e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,7 +1,9 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -13,18 +15,22 @@ import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/po import { BULL_BOARD_ROUTE, DEFAULT_LANGUAGE_CODE, - SUPPORTED_LANGUAGE_CODES + SUPPORTED_LANGUAGE_CODES, + THROTTLE_DEFAULT_LIMIT, + THROTTLE_DEFAULT_TTL } from '@ghostfolio/common/config'; import { ExpressAdapter } from '@bull-board/express'; import { BullBoardModule } from '@bull-board/nestjs'; +import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; -import { StatusCodes } from 'http-status-codes'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { join } from 'node:path'; import { AccessModule } from './access/access.module'; @@ -95,12 +101,13 @@ import { UserModule } from './user/user.module'; middleware: BullBoardAuthMiddleware, route: BULL_BOARD_ROUTE }), - BullModule.forRoot({ - redis: { - db: parseInt(process.env.REDIS_DB ?? '0', 10), - host: process.env.REDIS_HOST, - password: process.env.REDIS_PASSWORD, - port: parseInt(process.env.REDIS_PORT ?? '6379', 10) + BullModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + return { + redis: getRedisConnectionOptions(configurationService) + }; } }), CacheModule, @@ -168,6 +175,36 @@ import { UserModule } from './user/user.module'; SubscriptionModule, SymbolModule, TagsModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + const isRateLimitingEnabled = configurationService.get( + 'ENABLE_FEATURE_RATE_LIMITING' + ); + + return { + errorMessage: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + skipIf: () => { + return !isRateLimitingEnabled; + }, + storage: isRateLimitingEnabled + ? new ThrottlerStorageRedisService({ + ...getRedisConnectionOptions(configurationService), + // Reject commands immediately while Redis is unavailable + enableOfflineQueue: false, + maxRetriesPerRequest: 1 + }) + : undefined, + throttlers: [ + { + limit: THROTTLE_DEFAULT_LIMIT, + ttl: THROTTLE_DEFAULT_TTL + } + ] + }; + } + }), UserModule, WatchlistModule ], diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 388f1dbd3..ac50f4b8a 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; @@ -39,6 +40,7 @@ export class AuthController { * @deprecated */ @Get('anonymous/:accessToken') + @UseGuards(CustomThrottlerGuard) public async accessTokenLoginGet( @Param('accessToken') accessToken: string ): Promise { @@ -55,6 +57,7 @@ export class AuthController { } @Post('anonymous') + @UseGuards(CustomThrottlerGuard) public async accessTokenLogin( @Body() body: { accessToken: string } ): Promise { @@ -135,6 +138,7 @@ export class AuthController { } @Post('webauthn/generate-authentication-options') + @UseGuards(CustomThrottlerGuard) public async generateAuthenticationOptions( @Body() body: { deviceId: string } ) { @@ -156,6 +160,7 @@ export class AuthController { } @Post('webauthn/verify-authentication') + @UseGuards(CustomThrottlerGuard) public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } ) { diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index d0e3228b7..8d56c7c51 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,3 +1,4 @@ +import { getRedisConnectionUrl } from '@ghostfolio/api/helper/redis.helper'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service'; imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { - const redisPassword = encodeURIComponent( - configurationService.get('REDIS_PASSWORD') - ); - return { - stores: [ - createKeyv( - `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` - ) - ], + stores: [createKeyv(getRedisConnectionUrl(configurationService))], ttl: configurationService.get('CACHE_TTL') }; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 6346ce43a..7a1fc71dc 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,11 +1,16 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + HEADER_KEY_IMPERSONATION, + THROTTLE_SIGNUP_LIMIT, + THROTTLE_SIGNUP_TTL +} from '@ghostfolio/common/config'; import { DeleteOwnUserDto, UpdateOwnAccessTokenDto, @@ -37,6 +42,7 @@ import { import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; +import { Throttle } from '@nestjs/throttler'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { merge, size } from 'lodash'; @@ -128,6 +134,13 @@ export class UserController { } @Post() + @Throttle({ + default: { + limit: THROTTLE_SIGNUP_LIMIT, + ttl: THROTTLE_SIGNUP_TTL + } + }) + @UseGuards(CustomThrottlerGuard) public async signupUser(): Promise { const isUserSignupEnabled = await this.propertyService.isUserSignupEnabled(); diff --git a/apps/api/src/guards/custom-throttler.guard.ts b/apps/api/src/guards/custom-throttler.guard.ts new file mode 100644 index 000000000..c4f0e806d --- /dev/null +++ b/apps/api/src/guards/custom-throttler.guard.ts @@ -0,0 +1,21 @@ +import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler'; + +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + private readonly logger = new Logger(CustomThrottlerGuard.name); + + public async canActivate(context: ExecutionContext): Promise { + try { + return await super.canActivate(context); + } catch (error) { + if (error instanceof ThrottlerException) { + throw error; + } + + this.logger.error(error); + + return true; + } + } +} diff --git a/apps/api/src/helper/redis.helper.ts b/apps/api/src/helper/redis.helper.ts new file mode 100644 index 000000000..81fe905eb --- /dev/null +++ b/apps/api/src/helper/redis.helper.ts @@ -0,0 +1,22 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export function getRedisConnectionOptions( + configurationService: ConfigurationService +) { + return { + db: configurationService.get('REDIS_DB'), + host: configurationService.get('REDIS_HOST'), + password: configurationService.get('REDIS_PASSWORD'), + port: configurationService.get('REDIS_PORT') + }; +} + +export function getRedisConnectionUrl( + configurationService: ConfigurationService +): string { + const { db, host, password, port } = + getRedisConnectionOptions(configurationService); + const encodedPassword = encodeURIComponent(password); + + return `redis://${encodedPassword ? `:${encodedPassword}` : ''}@${host}:${port}/${db}`; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 63185a48b..33ad032e9 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,3 +1,4 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { BULL_BOARD_ROUTE, DEFAULT_HOST, @@ -39,6 +40,8 @@ async function bootstrap() { ) as LogLevel[]; } catch {} + await configApp.close(); + const app = await NestFactory.create(AppModule, { logger: customLogLevels ?? @@ -97,6 +100,23 @@ async function bootstrap() { }); } + const configurationService = app.get(ConfigurationService); + + const trustProxy = configurationService.get('TRUST_PROXY'); + + if (trustProxy) { + app.set('trust proxy', trustProxy); + } + + if ( + configurationService.get('ENABLE_FEATURE_RATE_LIMITING') && + trustProxy === '' + ) { + logger.warn( + 'Rate limiting is enabled, but TRUST_PROXY is not set. If the Ghostfolio application runs behind a reverse proxy, the rate limits are shared across all clients.' + ); + } + const HOST = configService.get('HOST') || DEFAULT_HOST; const PORT = configService.get('PORT') || DEFAULT_PORT; diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index b097627df..497dc6040 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -13,9 +13,31 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; +import { + bool, + cleanEnv, + host, + json, + makeValidator, + num, + port, + str, + url +} from 'envalid'; import ms from 'ms'; +const trustProxy = makeValidator((input) => { + if (/^\d+$/.test(input)) { + return Number(input); + } else if (input === 'false') { + return false; + } else if (input === 'true') { + return true; + } + + return input; +}); + @Injectable() export class ConfigurationService { private readonly environmentConfiguration: Environment; @@ -50,6 +72,7 @@ export class ConfigurationService { ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), + ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), @@ -114,6 +137,7 @@ export class ConfigurationService { default: environment.rootUrl }), STRIPE_SECRET_KEY: str({ default: '' }), + TRUST_PROXY: trustProxy({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 6147d4e45..bb58b01ab 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -23,6 +23,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_CRON: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; + ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; @@ -57,6 +58,7 @@ export interface Environment extends CleanedEnvAccessors { REQUEST_TIMEOUT: number; ROOT_URL: string; STRIPE_SECRET_KEY: string; + TRUST_PROXY: boolean | number | string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index c12509d58..8e70c1a8c 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -22,6 +22,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; +import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, @@ -42,6 +43,7 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { MatToolbarModule } from '@angular/material/toolbar'; import { Router, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; +import { StatusCodes } from 'http-status-codes'; import { addIcons } from 'ionicons'; import { closeOutline, @@ -315,10 +317,15 @@ export class GfHeaderComponent implements OnChanges { this.dataService .loginAnonymous(data?.accessToken) .pipe( - catchError(() => { - this.notificationService.alert({ - title: $localize`Oops! Incorrect Security Token.` - }); + catchError((error: HttpErrorResponse) => { + if (error.status !== StatusCodes.TOO_MANY_REQUESTS) { + // The notification for too many requests is handled in the + // HttpResponseInterceptor + + this.notificationService.alert({ + title: $localize`Oops! Incorrect Security Token.` + }); + } return EMPTY; }), diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 17927a924..7385e090c 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -98,15 +98,23 @@ export class HttpResponseInterceptor implements HttpInterceptor { }); } } else if (error.status === StatusCodes.TOO_MANY_REQUESTS) { - if (!this.snackBarRef) { - this.snackBarRef = this.snackBar.open( - $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` - ); + // Replace an already visible snack bar so that the rate limiting + // feedback is not swallowed + const snackBarRef = this.snackBar.open( + $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`, + undefined, + { + duration: ms('6 seconds') + } + ); - this.snackBarRef?.afterDismissed().subscribe(() => { + snackBarRef.afterDismissed().subscribe(() => { + if (this.snackBarRef === snackBarRef) { this.snackBarRef = undefined; - }); - } + } + }); + + this.snackBarRef = snackBarRef; } else if (error.status === StatusCodes.UNAUTHORIZED) { if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (this.webAuthnService.isEnabled()) { diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index d613fb3cf..633e13f24 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -329,4 +329,9 @@ 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_DEFAULT_LIMIT = 10; +export const THROTTLE_DEFAULT_TTL = ms('1 minute'); +export const THROTTLE_SIGNUP_LIMIT = 5; +export const THROTTLE_SIGNUP_TTL = ms('1 hour'); + export const UNKNOWN_KEY = 'UNKNOWN'; diff --git a/package-lock.json b/package-lock.json index d8c4974d7..41c915bd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -40,6 +41,7 @@ "@nestjs/platform-express": "11.1.27", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", + "@nestjs/throttler": "6.5.0", "@openrouter/ai-sdk-provider": "2.9.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", @@ -6956,6 +6958,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@nest-lab/throttler-storage-redis": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@nest-lab/throttler-storage-redis/-/throttler-storage-redis-1.2.0.tgz", + "integrity": "sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/throttler": ">=6.0.0", + "ioredis": ">=5.0.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": false + }, + "@nestjs/core": { + "optional": false + }, + "@nestjs/throttler": { + "optional": false + }, + "ioredis": { + "optional": false + }, + "reflect-metadata": { + "optional": false + } + } + }, "node_modules/@nestjs/bull": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", @@ -7441,6 +7476,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@ngtools/webpack": { "version": "21.2.6", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.6.tgz", diff --git a/package.json b/package.json index 261d35b54..ae7d29e6e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -84,6 +85,7 @@ "@nestjs/platform-express": "11.1.27", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", + "@nestjs/throttler": "6.5.0", "@openrouter/ai-sdk-provider": "2.9.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", From 0afd17773bdc048ebcd22b18263425da264a945d Mon Sep 17 00:00:00 2001 From: moduvoice Date: Sat, 11 Jul 2026 15:21:23 +0700 Subject: [PATCH 5/8] Task/improve language localization for KO (#7279) * Update translations * Update changelog --- CHANGELOG.md | 4 ++ apps/client/src/locales/messages.ko.xlf | 94 ++++++++++++------------- 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 842a41aeb..34573918f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exposed the `ENABLE_FEATURE_RATE_LIMITING` environment variable to control rate limiting for authentication and sign-up endpoints - Exposed the `TRUST_PROXY` environment variable to determine the client IP address when running behind a reverse proxy +### Changed + +- Improved the language localization for Korean (`ko`) + ## 3.23.0 - 2026-07-10 ### Changed diff --git a/apps/client/src/locales/messages.ko.xlf b/apps/client/src/locales/messages.ko.xlf index f21f8fa02..ea2df8596 100644 --- a/apps/client/src/locales/messages.ko.xlf +++ b/apps/client/src/locales/messages.ko.xlf @@ -433,7 +433,7 @@ Copy - Copy + 복사 libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html 20 @@ -605,7 +605,7 @@ Paid - Paid + 유료 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts 122 @@ -693,7 +693,7 @@ and is driven by the efforts of its contributors - 기여자들의 노력으로 발전하고 있습니다 + 기여자들의 노력으로 발전하고 있습니다 apps/client/src/app/pages/about/overview/about-overview-page.html 50 @@ -769,7 +769,7 @@ Watch the Ghostfol.io Trailer on YouTube - Watch the Ghostfol.io Trailer on YouTube + YouTube에서 Ghostfol.io 트레일러 보기 apps/client/src/app/pages/landing/landing-page.html 19 @@ -805,7 +805,7 @@ Category - Category + 카테고리 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html 77 @@ -869,7 +869,7 @@ Data Gathering Frequency - Data Gathering Frequency + 데이터 수집 빈도 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 454 @@ -913,7 +913,7 @@ Subscription History - Subscription History + 구독 내역 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html 136 @@ -1097,7 +1097,7 @@ Total - Total + 합계 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html 155 @@ -1721,7 +1721,7 @@ The source code is fully available as open source software (OSS) under the AGPL-3.0 license - 소스 코드는 오픈 소스 소프트웨어로 완전히 공개되어 있으며, AGPL-3.0 라이선스 하에 제공됩니다 + 소스 코드는 오픈 소스 소프트웨어로 완전히 공개되어 있으며, AGPL-3.0 라이선스 하에 제공됩니다 apps/client/src/app/pages/about/overview/about-overview-page.html 16 @@ -2293,7 +2293,7 @@ Ghostfolio in Numbers: Monthly Active Users (MAU) - Ghostfolio in Numbers: Monthly Active Users (MAU) + Ghostfolio 통계: 월간 활성 사용자 수(MAU) apps/client/src/app/pages/landing/landing-page.html 63 @@ -2333,7 +2333,7 @@ The value has been copied to the clipboard - The value has been copied to the clipboard + 값이 클립보드에 복사되었습니다. libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts 46 @@ -2493,7 +2493,7 @@ Coupon - Coupon + 쿠폰 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts 127 @@ -2529,7 +2529,7 @@ Close Account - Close Account + 계정 폐쇄 apps/client/src/app/components/user-account-settings/user-account-settings.html 337 @@ -2549,7 +2549,7 @@ Contributors to Ghostfolio - Contributors to Ghostfolio + Ghostfolio의 기여자 apps/client/src/app/pages/about/overview/about-overview-page.html 54 @@ -2885,7 +2885,7 @@ Financial Planning - Financial Planning + 재무 설계 libs/ui/src/lib/i18n.ts 108 @@ -3221,7 +3221,7 @@ For security reasons, please delete all activities and accounts first before your Ghostfolio account can be closed. - For security reasons, please delete all activities and accounts first before your Ghostfolio account can be closed. + 보안을 위해 Ghostfolio 계정을 폐쇄하려면 먼저 모든 거래 내역과 계좌를 삭제해 주세요. apps/client/src/app/components/user-account-settings/user-account-settings.html 348 @@ -3349,7 +3349,7 @@ Creation - Creation + 생성 apps/client/src/app/components/admin-overview/admin-overview.html 185 @@ -3389,7 +3389,7 @@ just now - just now + 방금 전 apps/client/src/app/components/admin-users/admin-users.component.ts 217 @@ -3713,7 +3713,7 @@ Oops! Could not delete the asset profiles. - Oops! Could not delete the asset profiles. + 이런! 자산 프로필을 삭제할 수 없습니다. apps/client/src/app/components/admin-market-data/admin-market-data.service.ts 52 @@ -3801,7 +3801,7 @@ At Ghostfolio, transparency is at the core of our values. We publish the source code as open source software (OSS) under the AGPL-3.0 license and we openly share aggregated key metrics of the platform’s operational status. - Ghostfolio는 투명성을 핵심 가치로 삼습니다. 우리는 소스 코드를 오픈 소스 소프트웨어로 공개하며, AGPL-3.0 라이선스 하에 배포합니다. 또한 플랫폼 운영 현황에 대한 집계된 핵심 지표를 공개적으로 공유합니다. + Ghostfolio는 투명성을 핵심 가치로 삼습니다. 우리는 소스 코드를 오픈 소스 소프트웨어로 공개하며, AGPL-3.0 라이선스 하에 배포합니다. 또한 플랫폼 운영 현황에 대한 집계된 핵심 지표를 공개적으로 공유합니다. apps/client/src/app/pages/open/open-page.html 7 @@ -3897,7 +3897,7 @@ Available on - Available on + 이용 가능 플랫폼 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html 130 @@ -4205,7 +4205,7 @@ Price - Price + 가격 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html 171 @@ -4305,7 +4305,7 @@ Trial - Trial + 체험판 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts 126 @@ -4613,7 +4613,7 @@ Hourly - Hourly + 매시간 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts 214 @@ -4725,7 +4725,7 @@ Expiration - Expiration + 만료 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html 204 @@ -5034,7 +5034,7 @@ Upgrade to Ghostfolio Premium - Upgrade to Ghostfolio Premium + Ghostfolio 프리미엄으로 업그레이드 libs/ui/src/lib/premium-indicator/premium-indicator.component.html 4 @@ -5122,7 +5122,7 @@ Stock Tracking - Stock Tracking + 주식 추적 libs/ui/src/lib/i18n.ts 111 @@ -5146,7 +5146,7 @@ Web - Web + libs/ui/src/lib/i18n.ts 120 @@ -5342,7 +5342,7 @@ Portfolio Filters - Portfolio Filters + 포트폴리오 필터 apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html 63 @@ -5406,7 +5406,7 @@ {VAR_PLURAL, plural, =1 {Profile} other {Profiles}} - {VAR_PLURAL, plural, =1 {Profile} other {Profiles}} + {VAR_PLURAL, plural, =1 {프로필} other {프로필}} apps/client/src/app/components/admin-market-data/admin-market-data.html 255 @@ -5718,7 +5718,7 @@ Ghostfolio in Numbers: Pulls on Docker Hub - Ghostfolio in Numbers: Pulls on Docker Hub + Ghostfolio 통계: Docker Hub 다운로드 수 apps/client/src/app/pages/landing/landing-page.html 101 @@ -6350,7 +6350,7 @@ Expires () - Expires () + 만료 () apps/client/src/app/components/admin-users/admin-users.html 34 @@ -6511,7 +6511,7 @@ Daily - Daily + 매일 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts 210 @@ -6811,7 +6811,7 @@ Oops! Could not delete the asset profile. - Oops! Could not delete the asset profile. + 이런! 자산 프로필을 삭제할 수 없습니다. apps/client/src/app/components/admin-market-data/admin-market-data.service.ts 51 @@ -6931,7 +6931,7 @@ Do you really want to delete these asset profiles? - Do you really want to delete these asset profiles? + 개의 자산 프로필을 정말 삭제하시겠습니까? apps/client/src/app/components/admin-market-data/admin-market-data.service.ts 67 @@ -7159,7 +7159,7 @@ Performance with currency effect Performance - 환율 효과 반영 수익률 수익률 + 환율 효과 반영 수익률 수익률 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html 83 @@ -7175,7 +7175,7 @@ Tax Reporting - Tax Reporting + 세금 보고 libs/ui/src/lib/i18n.ts 112 @@ -7183,7 +7183,7 @@ Change with currency effect Change - 환율 효과 반영 변동 변동 + 환율 효과 반영 변동 변동 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html 63 @@ -7199,7 +7199,7 @@ Compare Ghostfolio to - - Compare Ghostfolio to - + Ghostfolio와 비교 - apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html 32 @@ -7239,7 +7239,7 @@ Dividend Tracking - Dividend Tracking + 배당 추적 libs/ui/src/lib/i18n.ts 105 @@ -7303,7 +7303,7 @@ Delete - Delete + 삭제 apps/client/src/app/components/admin-market-data/admin-market-data.html 250 @@ -7419,7 +7419,7 @@ Investment Research - Investment Research + 투자 리서치 libs/ui/src/lib/i18n.ts 109 @@ -7701,7 +7701,7 @@ Net Worth Tracking - Net Worth Tracking + 순자산 추적 libs/ui/src/lib/i18n.ts 110 @@ -7833,7 +7833,7 @@ Ghostfolio in Numbers: Stars on GitHub - Ghostfolio in Numbers: Stars on GitHub + Ghostfolio 통계: GitHub 스타 수 apps/client/src/app/pages/landing/landing-page.html 82 @@ -8139,7 +8139,7 @@ ETF Tracking - ETF Tracking + ETF 추적 libs/ui/src/lib/i18n.ts 106 @@ -8305,7 +8305,7 @@ Post to Ghostfolio on X (formerly Twitter) - Post to Ghostfolio on X (formerly Twitter) + X(이전의 Twitter)에서 Ghostfolio에 게시하세요. apps/client/src/app/pages/about/overview/about-overview-page.html 85 @@ -8526,7 +8526,7 @@ If you encounter a bug, would like to suggest an improvement or a new feature, please join the Ghostfolio Slack community, post to @ghostfolio_ - 버그가 발생하거나 개선 사항이나 새로운 기능을 제안하고 싶다면 Ghostfolio 슬랙 커뮤니티에 가입하고 @ghostfolio_에 게시하세요. + 버그가 발생하거나 개선 사항이나 새로운 기능을 제안하고 싶다면 Ghostfolio 슬랙 커뮤니티에 가입하고 @ghostfolio_에 게시하세요. apps/client/src/app/pages/about/overview/about-overview-page.html 71 From 618e22e3fa8cbfcbb28961ae928364373d325a8b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:03:09 +0200 Subject: [PATCH 6/8] Task/round value in fear and greed index component (#7303) * Round value * Update changelog --- CHANGELOG.md | 1 + .../fear-and-greed-index/fear-and-greed-index.component.html | 2 +- .../fear-and-greed-index/fear-and-greed-index.component.ts | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34573918f..b97416560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Rounded the value of the _Fear & Greed Index_ (market mood) - Improved the language localization for Korean (`ko`) ## 3.23.0 - 2026-07-10 diff --git a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html index 67274ae38..dd2925f43 100644 --- a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html +++ b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html @@ -5,7 +5,7 @@
{{ fearAndGreedIndexText }} {{ fearAndGreedIndex }}{{ fearAndGreedIndex | number: '1.0-0' }}/100
diff --git a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts index 32e2cc29a..b507f0008 100644 --- a/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts +++ b/apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts @@ -1,6 +1,7 @@ import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper'; import { translate } from '@ghostfolio/ui/i18n'; +import { DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -11,7 +12,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgxSkeletonLoaderModule], + imports: [DecimalPipe, NgxSkeletonLoaderModule], selector: 'gf-fear-and-greed-index', styleUrls: ['./fear-and-greed-index.component.scss'], templateUrl: './fear-and-greed-index.component.html' From 4ff3e87a68d0c3b39869cfaae006d016d87450ae Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:05:07 +0200 Subject: [PATCH 7/8] Release 3.24.0 (#7304) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97416560..4210b2ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### Unreleased +## 3.24.0 - 2026-07-11 ### Added diff --git a/package-lock.json b/package-lock.json index 41c915bd1..051e79839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.23.0", + "version": "3.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.23.0", + "version": "3.24.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index ae7d29e6e..4c400b8af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.23.0", + "version": "3.24.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From c679e791dca98c5a19d30c12aeaae5ec782fc31c Mon Sep 17 00:00:00 2001 From: Cole Munz <145523609+munzzyy@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:19:17 -0500 Subject: [PATCH 8/8] Bugfix/corrupted state attributes in CA and TR locales (#7297) Fix states --- apps/client/src/locales/messages.ca.xlf | 2 +- apps/client/src/locales/messages.tr.xlf | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf index 27f3729fb..c1d6f9d8b 100644 --- a/apps/client/src/locales/messages.ca.xlf +++ b/apps/client/src/locales/messages.ca.xlf @@ -4073,7 +4073,7 @@
How does Ghostfolio work? - Com ho fa Ghostfolio treballar? + Com ho fa Ghostfolio treballar? apps/client/src/app/pages/landing/landing-page.html 286 diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index 9e43076f7..1acc6ea9e 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -2096,7 +2096,7 @@ 1Y - 1Y + 1Y apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts 232 @@ -4273,7 +4273,7 @@ This overview page features a curated collection of personal finance tools compared to the open source alternative Ghostfolio. If you value transparency, data privacy, and community collaboration, Ghostfolio provides an excellent opportunity to take control of your financial management. - Bu genel bakış sayfası, diğer kişisel finans araçlarının seçilmiş bir koleksiyonunun açık kaynak alternatifiGhostfolio ile karşılaştırmasını sunmaktadır.. Şeffaflığa, veri gizliliğine ve topluluk işbirliğine değer veriyorsanız Ghostfolio, finansal yönetiminizin kontrolünü ele almak için mükemmel Şeffaflığa, veri gizliliğine ve topluluk işbirliğine değer veriyorsanız Ghostfolio, finansal yönetiminizin kontrolünü ele almak için mükemmel bir fırsat sunuyor. + Bu genel bakış sayfası, diğer kişisel finans araçlarının seçilmiş bir koleksiyonunun açık kaynak alternatifiGhostfolio ile karşılaştırmasını sunmaktadır.. Şeffaflığa, veri gizliliğine ve topluluk işbirliğine değer veriyorsanız Ghostfolio, finansal yönetiminizin kontrolünü ele almak için mükemmel bir fırsat sunuyor. apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html 9