Browse Source

Merge branch 'main' into task/upgrade-helmet-to-version-8.2.0

pull/7300/head
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
8ce32b1a0c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 13
      CHANGELOG.md
  2. 43
      README.md
  3. 53
      apps/api/src/app/app.module.ts
  4. 5
      apps/api/src/app/auth/auth.controller.ts
  5. 6
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  6. 8
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  7. 13
      apps/api/src/app/info/info.service.ts
  8. 11
      apps/api/src/app/redis-cache/redis-cache.module.ts
  9. 15
      apps/api/src/app/user/user.controller.ts
  10. 21
      apps/api/src/guards/custom-throttler.guard.ts
  11. 22
      apps/api/src/helper/redis.helper.ts
  12. 20
      apps/api/src/main.ts
  13. 29
      apps/api/src/services/configuration/configuration.service.ts
  14. 6
      apps/api/src/services/data-provider/data-provider.service.ts
  15. 32
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  16. 3
      apps/api/src/services/interfaces/environment.interface.ts
  17. 8
      apps/api/src/services/twitter-bot/twitter-bot.module.ts
  18. 10
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  19. 2
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html
  20. 3
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts
  21. 15
      apps/client/src/app/components/header/header.component.ts
  22. 46
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  23. 22
      apps/client/src/app/core/http-response.interceptor.ts
  24. 40
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts
  25. 2
      apps/client/src/locales/messages.ca.xlf
  26. 94
      apps/client/src/locales/messages.ko.xlf
  27. 4
      apps/client/src/locales/messages.tr.xlf
  28. 6
      libs/common/src/lib/config.ts
  29. 50
      package-lock.json
  30. 4
      package.json

13
CHANGELOG.md

@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `helmet` from version `7.0.0` to `8.2.0`
## 3.24.0 - 2026-07-11
### 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
### Changed
- Rounded the value of the _Fear & Greed Index_ (market mood)
- Improved the language localization for Korean (`ko`)
## 3.23.0 - 2026-07-10
### Changed

43
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)

53
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
],

5
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<OAuthResponse> {
@ -55,6 +57,7 @@ export class AuthController {
}
@Post('anonymous')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
@ -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 }
) {

6
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

8
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 {}

13
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);

11
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')
};
}

15
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<UserItem> {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();

21
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<boolean> {
try {
return await super.canActivate(context);
} catch (error) {
if (error instanceof ThrottlerException) {
throw error;
}
this.logger.error(error);
return true;
}
}
}

22
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}`;
}

20
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<NestExpressApplication>(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<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT;

29
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<boolean | number | string>((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;
@ -34,6 +56,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]
@ -47,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 }),
@ -111,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' }),

6
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')];
}

32
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);

3
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[];
@ -22,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;
@ -56,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;

8
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 {}

10
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
}
});

2
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -5,7 +5,7 @@
<div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong
><strong>{{ fearAndGreedIndex | number: '1.0-0' }}</strong
>/100</small
>
</div>

3
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'

15
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;
}),

46
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<Subscription>();
public subscriptionsDisplayedColumns = [
protected baseCurrency: string;
protected readonly getCountryName = getCountryName;
protected readonly subscriptionsDataSource =
new MatTableDataSource<Subscription>();
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<UserDetailDialogParams>(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<GfUserDetailDialogComponent, UserDetailDialogResult>>(
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();
}
}

22
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()) {

40
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<UserAccountRegistrationDialogParams>(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;
}
}

2
apps/client/src/locales/messages.ca.xlf

@ -4073,7 +4073,7 @@
</trans-unit>
<trans-unit id="8065260392868074625" datatype="html">
<source>How does <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Ghostfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> work?</source>
<target state="translatetd">Com ho fa <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Ghostfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> treballar?</target>
<target state="translated">Com ho fa <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Ghostfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> treballar?</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">286</context>

94
apps/client/src/locales/messages.ko.xlf

@ -433,7 +433,7 @@
</trans-unit>
<trans-unit id="4323470180912194028" datatype="html">
<source>Copy</source>
<target state="new">Copy</target>
<target state="translated">복사</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html</context>
<context context-type="linenumber">20</context>
@ -605,7 +605,7 @@
</trans-unit>
<trans-unit id="8282940047848889809" datatype="html">
<source>Paid</source>
<target state="new">Paid</target>
<target state="translated">유료</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts</context>
<context context-type="linenumber">122</context>
@ -693,7 +693,7 @@
</trans-unit>
<trans-unit id="5611965261696422586" datatype="html">
<source>and is driven by the efforts of its <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio/graphs/contributors&quot; i18n-title title=&quot;Contributors to Ghostfolio&quot; &gt;"/>contributors<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/></source>
<target state="new"><x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio/graphs/contributors&quot; title=&quot;Contributors to Ghostfolio&quot; &gt;"/>기여자<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>들의 노력으로 발전하고 있습니다</target>
<target state="translated"><x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio/graphs/contributors&quot; title=&quot;Contributors to Ghostfolio&quot; &gt;"/>기여자<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>들의 노력으로 발전하고 있습니다</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">50</context>
@ -769,7 +769,7 @@
</trans-unit>
<trans-unit id="8410000928786197012" datatype="html">
<source>Watch the Ghostfol.io Trailer on YouTube</source>
<target state="new">Watch the Ghostfol.io Trailer on YouTube</target>
<target state="translated">YouTube에서 Ghostfol.io 트레일러 보기</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">19</context>
@ -805,7 +805,7 @@
</trans-unit>
<trans-unit id="1806667489382256324" datatype="html">
<source>Category</source>
<target state="new">Category</target>
<target state="translated">카테고리</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">77</context>
@ -869,7 +869,7 @@
</trans-unit>
<trans-unit id="6418462810730461014" datatype="html">
<source>Data Gathering Frequency</source>
<target state="new">Data Gathering Frequency</target>
<target state="translated">데이터 수집 빈도</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context>
<context context-type="linenumber">454</context>
@ -913,7 +913,7 @@
</trans-unit>
<trans-unit id="6135731497699355929" datatype="html">
<source>Subscription History</source>
<target state="new">Subscription History</target>
<target state="translated">구독 내역</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">136</context>
@ -1097,7 +1097,7 @@
</trans-unit>
<trans-unit id="5915287617703658226" datatype="html">
<source>Total</source>
<target state="new">Total</target>
<target state="translated">합계</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">155</context>
@ -1721,7 +1721,7 @@
</trans-unit>
<trans-unit id="5289957034780335504" datatype="html">
<source>The source code is fully available as <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio&quot; i18n-title title=&quot;Find Ghostfolio on GitHub&quot; &gt;"/>open source software<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> (OSS) under the <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://www.gnu.org/licenses/agpl-3.0.html&quot; title=&quot;GNU Affero General Public License&quot; &gt;"/>AGPL-3.0 license<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/></source>
<target state="new">소스 코드는 <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio&quot; title=&quot;Find Ghostfolio on GitHub&quot; &gt;"/>오픈 소스 소프트웨어<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>로 완전히 공개되어 있으며, <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://www.gnu.org/licenses/agpl-3.0.html&quot; title=&quot;GNU Affero General Public License&quot; &gt;"/>AGPL-3.0 라이선스<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> 하에 제공됩니다</target>
<target state="translated">소스 코드는 <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio&quot; title=&quot;Find Ghostfolio on GitHub&quot; &gt;"/>오픈 소스 소프트웨어<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>로 완전히 공개되어 있으며, <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://www.gnu.org/licenses/agpl-3.0.html&quot; title=&quot;GNU Affero General Public License&quot; &gt;"/>AGPL-3.0 라이선스<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> 하에 제공됩니다</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">16</context>
@ -2293,7 +2293,7 @@
</trans-unit>
<trans-unit id="8793726805339626615" datatype="html">
<source>Ghostfolio in Numbers: Monthly Active Users (MAU)</source>
<target state="new">Ghostfolio in Numbers: Monthly Active Users (MAU)</target>
<target state="translated">Ghostfolio 통계: 월간 활성 사용자 수(MAU)</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">63</context>
@ -2333,7 +2333,7 @@
</trans-unit>
<trans-unit id="4037247308022519888" datatype="html">
<source>The value has been copied to the clipboard</source>
<target state="new">The value has been copied to the clipboard</target>
<target state="translated">값이 클립보드에 복사되었습니다.</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context>
@ -2493,7 +2493,7 @@
</trans-unit>
<trans-unit id="5463045633785723738" datatype="html">
<source>Coupon</source>
<target state="new">Coupon</target>
<target state="translated">쿠폰</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts</context>
<context context-type="linenumber">127</context>
@ -2529,7 +2529,7 @@
</trans-unit>
<trans-unit id="8664947843178872012" datatype="html">
<source>Close Account</source>
<target state="new">Close Account</target>
<target state="translated">계정 폐쇄</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-settings/user-account-settings.html</context>
<context context-type="linenumber">337</context>
@ -2549,7 +2549,7 @@
</trans-unit>
<trans-unit id="4102764207131986196" datatype="html">
<source>Contributors to Ghostfolio</source>
<target state="new">Contributors to Ghostfolio</target>
<target state="translated">Ghostfolio의 기여자</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">54</context>
@ -2885,7 +2885,7 @@
</trans-unit>
<trans-unit id="8643034887919513109" datatype="html">
<source>Financial Planning</source>
<target state="new">Financial Planning</target>
<target state="translated">재무 설계</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">108</context>
@ -3221,7 +3221,7 @@
</trans-unit>
<trans-unit id="4489207161748215824" datatype="html">
<source>For security reasons, please delete all activities and accounts first before your Ghostfolio account can be closed.</source>
<target state="new">For security reasons, please delete all activities and accounts first before your Ghostfolio account can be closed.</target>
<target state="translated">보안을 위해 Ghostfolio 계정을 폐쇄하려면 먼저 모든 거래 내역과 계좌를 삭제해 주세요.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-settings/user-account-settings.html</context>
<context context-type="linenumber">348</context>
@ -3349,7 +3349,7 @@
</trans-unit>
<trans-unit id="2047393478951255414" datatype="html">
<source>Creation</source>
<target state="new">Creation</target>
<target state="translated">생성</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">185</context>
@ -3389,7 +3389,7 @@
</trans-unit>
<trans-unit id="4733690367258997247" datatype="html">
<source>just now</source>
<target state="new">just now</target>
<target state="translated">방금 전</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-users/admin-users.component.ts</context>
<context context-type="linenumber">217</context>
@ -3713,7 +3713,7 @@
</trans-unit>
<trans-unit id="2756436642316668410" datatype="html">
<source>Oops! Could not delete the asset profiles.</source>
<target state="new">Oops! Could not delete the asset profiles.</target>
<target state="translated">이런! 자산 프로필을 삭제할 수 없습니다.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/admin-market-data.service.ts</context>
<context context-type="linenumber">52</context>
@ -3801,7 +3801,7 @@
</trans-unit>
<trans-unit id="3824165347269033834" datatype="html">
<source>At Ghostfolio, transparency is at the core of our values. We publish the source code as <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio&quot; i18n-title title=&quot;Find Ghostfolio on GitHub&quot; &gt;"/>open source software<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> (OSS) under the <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://www.gnu.org/licenses/agpl-3.0.html&quot; title=&quot;GNU Affero General Public License&quot; &gt;"/>AGPL-3.0 license<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> and we openly share aggregated key metrics of the platform’s operational status.</source>
<target state="new">Ghostfolio는 투명성을 핵심 가치로 삼습니다. 우리는 소스 코드를 <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio&quot; title=&quot;Find Ghostfolio on GitHub&quot; &gt;"/>오픈 소스 소프트웨어<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>로 공개하며, <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://www.gnu.org/licenses/agpl-3.0.html&quot; title=&quot;GNU Affero General Public License&quot; &gt;"/>AGPL-3.0 라이선스<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> 하에 배포합니다. 또한 플랫폼 운영 현황에 대한 집계된 핵심 지표를 공개적으로 공유합니다.</target>
<target state="translated">Ghostfolio는 투명성을 핵심 가치로 삼습니다. 우리는 소스 코드를 <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio&quot; title=&quot;Find Ghostfolio on GitHub&quot; &gt;"/>오픈 소스 소프트웨어<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>로 공개하며, <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://www.gnu.org/licenses/agpl-3.0.html&quot; title=&quot;GNU Affero General Public License&quot; &gt;"/>AGPL-3.0 라이선스<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> 하에 배포합니다. 또한 플랫폼 운영 현황에 대한 집계된 핵심 지표를 공개적으로 공유합니다.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/open/open-page.html</context>
<context context-type="linenumber">7</context>
@ -3897,7 +3897,7 @@
</trans-unit>
<trans-unit id="6075566839446502414" datatype="html">
<source>Available on</source>
<target state="new">Available on</target>
<target state="translated">이용 가능 플랫폼</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">130</context>
@ -4205,7 +4205,7 @@
</trans-unit>
<trans-unit id="8894377483833272091" datatype="html">
<source>Price</source>
<target state="new">Price</target>
<target state="translated">가격</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">171</context>
@ -4305,7 +4305,7 @@
</trans-unit>
<trans-unit id="6999515396807067782" datatype="html">
<source>Trial</source>
<target state="new">Trial</target>
<target state="translated">체험판</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts</context>
<context context-type="linenumber">126</context>
@ -4613,7 +4613,7 @@
</trans-unit>
<trans-unit id="2640607428459636406" datatype="html">
<source>Hourly</source>
<target state="new">Hourly</target>
<target state="translated">매시간</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">214</context>
@ -4725,7 +4725,7 @@
</trans-unit>
<trans-unit id="3323137014509063489" datatype="html">
<source>Expiration</source>
<target state="new">Expiration</target>
<target state="translated">만료</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">204</context>
@ -5034,7 +5034,7 @@
</trans-unit>
<trans-unit id="237127378624497814" datatype="html">
<source>Upgrade to Ghostfolio Premium</source>
<target state="new">Upgrade to Ghostfolio Premium</target>
<target state="translated">Ghostfolio 프리미엄으로 업그레이드</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/premium-indicator/premium-indicator.component.html</context>
<context context-type="linenumber">4</context>
@ -5122,7 +5122,7 @@
</trans-unit>
<trans-unit id="2191562378582791940" datatype="html">
<source>Stock Tracking</source>
<target state="new">Stock Tracking</target>
<target state="translated">주식 추적</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">111</context>
@ -5146,7 +5146,7 @@
</trans-unit>
<trans-unit id="9167786874272926575" datatype="html">
<source>Web</source>
<target state="new">Web</target>
<target state="translated">웹</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">120</context>
@ -5342,7 +5342,7 @@
</trans-unit>
<trans-unit id="9028573429495160158" datatype="html">
<source>Portfolio Filters</source>
<target state="new">Portfolio Filters</target>
<target state="translated">포트폴리오 필터</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html</context>
<context context-type="linenumber">63</context>
@ -5406,7 +5406,7 @@
</trans-unit>
<trans-unit id="1541521390115871091" datatype="html">
<source>{VAR_PLURAL, plural, =1 {Profile} other {Profiles}}</source>
<target state="new">{VAR_PLURAL, plural, =1 {Profile} other {Profiles}}</target>
<target state="translated">{VAR_PLURAL, plural, =1 {프로필} other {프로필}}</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/admin-market-data.html</context>
<context context-type="linenumber">255</context>
@ -5718,7 +5718,7 @@
</trans-unit>
<trans-unit id="6300009182465422833" datatype="html">
<source>Ghostfolio in Numbers: Pulls on Docker Hub</source>
<target state="new">Ghostfolio in Numbers: Pulls on Docker Hub</target>
<target state="translated">Ghostfolio 통계: Docker Hub 다운로드 수</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">101</context>
@ -6350,7 +6350,7 @@
</trans-unit>
<trans-unit id="3995811497329884593" datatype="html">
<source>Expires <x id="INTERPOLATION" equiv-text="{{ formatDistanceToNow(element.subscription.expiresAt) }}"/> (<x id="INTERPOLATION_1" equiv-text="{{ element.subscription.expiresAt | date: defaultDateFormat }}"/>)</source>
<target state="new">Expires <x id="INTERPOLATION" equiv-text="{{ formatDistanceToNow(element.subscription.expiresAt) }}"/> (<x id="INTERPOLATION_1" equiv-text="{{ element.subscription.expiresAt | date: defaultDateFormat }}"/>)</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ formatDistanceToNow(element.subscription.expiresAt) }}"/> 만료 (<x id="INTERPOLATION_1" equiv-text="{{ element.subscription.expiresAt | date: defaultDateFormat }}"/>)</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-users/admin-users.html</context>
<context context-type="linenumber">34</context>
@ -6511,7 +6511,7 @@
</trans-unit>
<trans-unit id="2988589012964101797" datatype="html">
<source>Daily</source>
<target state="new">Daily</target>
<target state="translated">매일</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">210</context>
@ -6811,7 +6811,7 @@
</trans-unit>
<trans-unit id="1284643802050750978" datatype="html">
<source>Oops! Could not delete the asset profile.</source>
<target state="new">Oops! Could not delete the asset profile.</target>
<target state="translated">이런! 자산 프로필을 삭제할 수 없습니다.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/admin-market-data.service.ts</context>
<context context-type="linenumber">51</context>
@ -6931,7 +6931,7 @@
</trans-unit>
<trans-unit id="1518717392874668219" datatype="html">
<source>Do you really want to delete these <x id="count" equiv-text="assetProfileCount"/> asset profiles?</source>
<target state="new">Do you really want to delete these <x id="count" equiv-text="assetProfileCount"/> asset profiles?</target>
<target state="translated">이 <x id="count" equiv-text="assetProfileCount"/>개의 자산 프로필을 정말 삭제하시겠습니까?</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/admin-market-data.service.ts</context>
<context context-type="linenumber">67</context>
@ -7159,7 +7159,7 @@
</trans-unit>
<trans-unit id="6608617124920241143" datatype="html">
<source><x id="START_BLOCK_IF" equiv-text="@if ( assetProfile?.currency &amp;&amp; data.baseCurrency !== assetProfile?.currency ) {"/> Performance with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Performance <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source>
<target state="new"><x id="START_BLOCK_IF" equiv-text="@if ( SymbolProfile?.currency &amp;&amp; data.baseCurrency !== SymbolProfile?.currency ) {"/> 환율 효과 반영 수익률 <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> 수익률 <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if ( assetProfile?.currency &amp;&amp; data.baseCurrency !== assetProfile?.currency ) {"/> 환율 효과 반영 수익률 <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> 수익률 <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context>
<context context-type="linenumber">83</context>
@ -7175,7 +7175,7 @@
</trans-unit>
<trans-unit id="6586833258036069278" datatype="html">
<source>Tax Reporting</source>
<target state="new">Tax Reporting</target>
<target state="translated">세금 보고</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">112</context>
@ -7183,7 +7183,7 @@
</trans-unit>
<trans-unit id="8375528527939577247" datatype="html">
<source><x id="START_BLOCK_IF" equiv-text="@if ( assetProfile?.currency &amp;&amp; data.baseCurrency !== assetProfile?.currency ) {"/> Change with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Change <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source>
<target state="new"><x id="START_BLOCK_IF" equiv-text="@if ( SymbolProfile?.currency &amp;&amp; data.baseCurrency !== SymbolProfile?.currency ) {"/> 환율 효과 반영 변동 <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> 변동 <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<target state="translated"><x id="START_BLOCK_IF" equiv-text="@if ( assetProfile?.currency &amp;&amp; data.baseCurrency !== assetProfile?.currency ) {"/> 환율 효과 반영 변동 <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> 변동 <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context>
<context context-type="linenumber">63</context>
@ -7199,7 +7199,7 @@
</trans-unit>
<trans-unit id="6389025757025171607" datatype="html">
<source>Compare Ghostfolio to <x id="INTERPOLATION" equiv-text="{{ personalFinanceTool.name }}"/> - <x id="INTERPOLATION_1" equiv-text="{{ personalFinanceTool.slogan }}"/></source>
<target state="new">Compare Ghostfolio to <x id="INTERPOLATION" equiv-text="{{ personalFinanceTool.name }}"/> - <x id="INTERPOLATION_1" equiv-text="{{ personalFinanceTool.slogan }}"/></target>
<target state="translated">Ghostfolio와 <x id="INTERPOLATION" equiv-text="{{ personalFinanceTool.name }}"/> 비교 - <x id="INTERPOLATION_1" equiv-text="{{ personalFinanceTool.slogan }}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html</context>
<context context-type="linenumber">32</context>
@ -7239,7 +7239,7 @@
</trans-unit>
<trans-unit id="3527222903865200876" datatype="html">
<source>Dividend Tracking</source>
<target state="new">Dividend Tracking</target>
<target state="translated">배당 추적</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">105</context>
@ -7303,7 +7303,7 @@
</trans-unit>
<trans-unit id="7547813413369998179" datatype="html">
<source>Delete <x id="INTERPOLATION" equiv-text="{{ selection.selected.length &gt; 1 ? selection.selected.length : &apos;&apos; }}"/> <x id="ICU" equiv-text="{selection.selected.length, plural, =1 {Profile} other {Profiles} }"/></source>
<target state="new">Delete <x id="INTERPOLATION" equiv-text="{{ selection.selected.length &gt; 1 ? selection.selected.length : &apos;&apos; }}"/> <x id="ICU" equiv-text="{selection.selected.length, plural, =1 {Profile} other {Profiles} }"/></target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{ selection.selected.length &gt; 1 ? selection.selected.length : &apos;&apos; }}"/> <x id="ICU" equiv-text="{selection.selected.length, plural, =1 {Profile} other {Profiles} }"/> 삭제</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/admin-market-data.html</context>
<context context-type="linenumber">250</context>
@ -7419,7 +7419,7 @@
</trans-unit>
<trans-unit id="1550367033316836764" datatype="html">
<source>Investment Research</source>
<target state="new">Investment Research</target>
<target state="translated">투자 리서치</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">109</context>
@ -7701,7 +7701,7 @@
</trans-unit>
<trans-unit id="4060547242431613838" datatype="html">
<source>Net Worth Tracking</source>
<target state="new">Net Worth Tracking</target>
<target state="translated">순자산 추적</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">110</context>
@ -7833,7 +7833,7 @@
</trans-unit>
<trans-unit id="1237494164624005096" datatype="html">
<source>Ghostfolio in Numbers: Stars on GitHub</source>
<target state="new">Ghostfolio in Numbers: Stars on GitHub</target>
<target state="translated">Ghostfolio 통계: GitHub 스타 수</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">82</context>
@ -8139,7 +8139,7 @@
</trans-unit>
<trans-unit id="3910789128199500333" datatype="html">
<source>ETF Tracking</source>
<target state="new">ETF Tracking</target>
<target state="translated">ETF 추적</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">106</context>
@ -8305,7 +8305,7 @@
</trans-unit>
<trans-unit id="2813837590488774096" datatype="html">
<source>Post to Ghostfolio on X (formerly Twitter)</source>
<target state="new">Post to Ghostfolio on X (formerly Twitter)</target>
<target state="translated">X(이전의 Twitter)에서 Ghostfolio에 게시하세요.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">85</context>
@ -8526,7 +8526,7 @@
</trans-unit>
<trans-unit id="5199695670214400859" datatype="html">
<source>If you encounter a bug, would like to suggest an improvement or a new <x id="START_LINK" ctype="x-a" equiv-text="&lt;a [routerLink]=&quot;routerLinkFeatures&quot;&gt;"/>feature<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>, please join the Ghostfolio <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg&quot; i18n-title title=&quot;Join the Ghostfolio Slack community&quot; &gt;"/>Slack<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> community, post to <x id="START_LINK_2" equiv-text="&lt;a href=&quot;https://x.com/ghostfolio_&quot; i18n-title title=&quot;Post to Ghostfolio on X (formerly Twitter)&quot; &gt;"/>@ghostfolio_<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/></source>
<target state="new">버그가 발생하거나 개선 사항이나 새로운 <x id="START_LINK" ctype="x-a" equiv-text="&lt;a [routerLink]=&quot;routerLinkFeatures&quot;&gt;"/>기능<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>을 제안하고 싶다면 Ghostfolio <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg&quot; title=&quot;Join the Ghostfolio Slack community&quot; &gt;"/>슬랙<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> 커뮤니티에 가입하고 <x id="START_LINK_2" equiv-text="&lt;a href=&quot;https://x.com/ghostfolio_&quot; title=&quot;Post to Ghostfolio on X (formerly Twitter)&quot; &gt;"/>@ghostfolio_<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>에 게시하세요.</target>
<target state="translated">버그가 발생하거나 개선 사항이나 새로운 <x id="START_LINK" ctype="x-a" equiv-text="&lt;a [routerLink]=&quot;routerLinkFeatures&quot;&gt;"/>기능<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>을 제안하고 싶다면 Ghostfolio <x id="START_LINK_1" equiv-text="&lt;a href=&quot;https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg&quot; title=&quot;Join the Ghostfolio Slack community&quot; &gt;"/>슬랙<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/> 커뮤니티에 가입하고 <x id="START_LINK_2" equiv-text="&lt;a href=&quot;https://x.com/ghostfolio_&quot; title=&quot;Post to Ghostfolio on X (formerly Twitter)&quot; &gt;"/>@ghostfolio_<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/>에 게시하세요.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">71</context>

4
apps/client/src/locales/messages.tr.xlf

@ -2096,7 +2096,7 @@
</trans-unit>
<trans-unit id="8768104874317770689" datatype="html">
<source>1Y</source>
<target state="trasnlated">1Y</target>
<target state="translated">1Y</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">232</context>
@ -4273,7 +4273,7 @@
</trans-unit>
<trans-unit id="9040028765832531851" datatype="html">
<source>This overview page features a curated collection of personal finance tools compared to the open source alternative <x id="START_LINK" ctype="x-a" equiv-text="&lt;a [routerLink]=&quot;routerLinkAbout&quot;&gt;"/>Ghostfolio<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/>. If you value transparency, data privacy, and community collaboration, Ghostfolio provides an excellent opportunity to take control of your financial management.</source>
<target state="translatedew">Bu genel bakış sayfası, diğer kişisel finans araçlarının seçilmiş bir koleksiyonunun açık kaynak alternatifi<x id="START_LINK" ctype="x-a" equiv-text="&lt;a [routerLink]=&quot;routerLinkAbout&quot;&gt;"/>Ghostfolio ile karşılaştırmasını sunmaktadır.<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/>. Ş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.</target>
<target state="translated">Bu genel bakış sayfası, diğer kişisel finans araçlarının seçilmiş bir koleksiyonunun açık kaynak alternatifi<x id="START_LINK" ctype="x-a" equiv-text="&lt;a [routerLink]=&quot;routerLinkAbout&quot;&gt;"/>Ghostfolio ile karşılaştırmasını sunmaktadır.<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/>. Ş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.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html</context>
<context context-type="linenumber">9</context>

6
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`;
@ -330,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';

50
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": {
@ -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",

4
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",
@ -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",

Loading…
Cancel
Save