Browse Source

Merge branch 'ghostfolio:main' into #3955-Extend-notification-service-by-prompt-functionality

pull/4117/head
Brandon 9 months ago
committed by GitHub
parent
commit
bf1f8e3499
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      CHANGELOG.md
  2. 2
      apps/api/src/app/app.module.ts
  3. 76
      apps/api/src/app/auth/api-key.strategy.ts
  4. 4
      apps/api/src/app/auth/auth.module.ts
  5. 25
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  6. 11
      apps/api/src/app/endpoints/api-keys/api-keys.module.ts
  7. 181
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  8. 3
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  9. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  10. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  11. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  12. 164
      apps/api/src/app/portfolio/portfolio.service.ts
  13. 25
      apps/api/src/app/user/user.service.ts
  14. 14
      apps/api/src/helper/string.helper.ts
  15. 12
      apps/api/src/services/api-key/api-key.module.ts
  16. 63
      apps/api/src/services/api-key/api-key.service.ts
  17. 50
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  18. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  19. 56
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  20. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  21. 43
      apps/client/src/app/pages/api/api-page.component.ts
  22. 31
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  23. 52
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  24. 16
      apps/client/src/app/services/admin.service.ts
  25. 27
      apps/client/src/app/services/data.service.ts
  26. 6
      libs/common/src/lib/interfaces/index.ts
  27. 5
      libs/common/src/lib/interfaces/portfolio-report.interface.ts
  28. 3
      libs/common/src/lib/interfaces/responses/api-key-response.interface.ts
  29. 9
      libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts
  30. 2
      libs/common/src/lib/models/portfolio-snapshot.ts
  31. 1
      libs/common/src/lib/permissions.ts
  32. 6
      libs/ui/src/lib/assistant/assistant.html
  33. 19
      libs/ui/src/lib/membership-card/membership-card.component.html
  34. 6
      libs/ui/src/lib/membership-card/membership-card.component.scss
  35. 17
      libs/ui/src/lib/membership-card/membership-card.component.ts
  36. 77
      package-lock.json
  37. 9
      package.json
  38. 5
      prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql
  39. 3
      prisma/schema.prisma

16
CHANGELOG.md

@ -5,7 +5,17 @@ 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
## 2.127.0 - 2024-12-08
### Added
- Extended the _X-ray_ page by a summary
### Fixed
- Fixed an exception in the caching of the portfolio snapshot in the portfolio calculator
## 2.126.1 - 2024-12-07
### Added
@ -13,7 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the labels of the assistant
- Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries immediately in case of errors
- Extracted the historical market data editor to a reusable component
- Upgraded `prettier` from version `3.3.3` to `3.4.2`
- Upgraded `prisma` from version `6.0.0` to `6.0.1`
## 2.125.0 - 2024-11-30

2
apps/api/src/app/app.module.ts

@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
@ -55,6 +56,7 @@ import { UserModule } from './user/user.module';
AdminModule,
AccessModule,
AccountModule,
ApiKeysModule,
AssetModule,
AuthDeviceModule,
AuthModule,

76
apps/api/src/app/auth/api-key.strategy.ts

@ -0,0 +1,76 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(
HeaderAPIKeyStrategy,
'api-key'
) {
public constructor(
private readonly apiKeyService: ApiKeyService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super(
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
true,
async (apiKey: string, done: (error: any, user?: any) => void) => {
try {
const user = await this.validateApiKey(apiKey);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
done(null, user);
} catch (error) {
done(error, null);
}
}
);
}
private async validateApiKey(apiKey: string) {
if (!apiKey) {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
try {
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
return this.userService.user({ id });
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
}
}

4
apps/api/src/app/auth/auth.module.ts

@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
UserModule
],
providers: [
ApiKeyService,
ApiKeyStrategy,
AuthDeviceService,
AuthService,
GoogleStrategy,

25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts

@ -0,0 +1,25 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@Controller('api-keys')
export class ApiKeysController {
public constructor(
private readonly apiKeyService: ApiKeyService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.createApiKey)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createApiKey(): Promise<ApiKeyResponse> {
return this.apiKeyService.create({ userId: this.request.user.id });
}
}

11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts

@ -0,0 +1,11 @@
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module';
import { Module } from '@nestjs/common';
import { ApiKeysController } from './api-keys.controller';
@Module({
controllers: [ApiKeysController],
imports: [ApiKeyModule]
})
export class ApiKeysModule {}

181
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -18,7 +18,8 @@ import {
Inject,
Param,
Query,
UseGuards
UseGuards,
Version
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -36,9 +37,52 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
/**
* @deprecated
*/
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividendsV1(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getDividends(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
@ -75,9 +119,52 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistoricalV1(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getHistorical(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
@ -114,9 +201,51 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbolV1(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
@ -152,9 +281,48 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotesV1(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getQuotes(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
@ -187,9 +355,20 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}

3
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -220,8 +220,7 @@ export class GhostfolioService {
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
dataProviderGhostfolioDailyRequests: { increment: 1 },
lastRequestAt: new Date()
dataProviderGhostfolioDailyRequests: { increment: 1 }
},
where: { userId }
});

1
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -176,6 +176,7 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) {
return {
currentValueInBaseCurrency: new Big(0),
errors: [],
hasErrors: false,
historicalData: [],
positions: [],

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -101,6 +101,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)

4
apps/api/src/app/portfolio/portfolio.controller.ts

@ -23,7 +23,7 @@ import {
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioReport
PortfolioReportResponse
} from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
@ -611,7 +611,7 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> {
): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport(impersonationId);
if (

164
apps/api/src/app/portfolio/portfolio.service.ts

@ -37,7 +37,7 @@ import {
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReport,
PortfolioReportResponse,
PortfolioSummary,
Position,
UserSettings
@ -1162,7 +1162,9 @@ export class PortfolioService {
};
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
public async getReport(
impersonationId: string
): Promise<PortfolioReportResponse> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = this.request.user.Settings.settings as UserSettings;
@ -1179,79 +1181,79 @@ export class PortfolioService {
})
).toNumber();
return {
rules: {
accountClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
accounts
)
],
userSettings
)
: undefined,
economicMarketClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
)
: undefined,
currencyClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
)
],
userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
const rules: PortfolioReportResponse['rules'] = {
accountClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
accounts
)
],
userSettings
)
],
userSettings
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
summary.committedFunds,
summary.fees
: undefined,
economicMarketClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
)
],
userSettings
)
}
: undefined,
currencyClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
)
],
userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
)
],
userSettings
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
summary.committedFunds,
summary.fees
)
],
userSettings
)
};
return { rules, statistics: this.getReportStatistics(rules) };
}
public async updateTags({
@ -1670,6 +1672,24 @@ export class PortfolioService {
return { markets, marketsAdvanced };
}
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['rules']
): PortfolioReportResponse['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()
.filter(({ isActive }) => {
return isActive === true;
}).length;
const rulesFulfilledCount = Object.values(evaluatedRules)
.flat()
.filter(({ value }) => {
return value === true;
}).length;
return { rulesActiveCount, rulesFulfilledCount };
}
private getStreaks({
investments,
savingsRate

25
apps/api/src/app/user/user.service.ts

@ -2,6 +2,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -37,11 +38,10 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash';
const crypto = require('crypto');
@Injectable()
export class UserService {
private i18nService = new I18nService();
@ -61,7 +61,7 @@ export class UserService {
}
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
const hash = createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
@ -309,6 +309,7 @@ export class UserService {
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch);
@ -408,10 +409,7 @@ export class UserService {
}
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,
this.getRandomString(10)
);
const accessToken = this.createAccessToken(user.id, getRandomString(10));
const hashedAccessToken = this.createAccessToken(
accessToken,
@ -528,17 +526,4 @@ export class UserService {
return settings;
}
private getRandomString(length: number) {
const bytes = crypto.randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];
for (let i = 0; i < length; i++) {
const randomByte = bytes[i];
result.push(characters[randomByte % characters.length]);
}
return result.join('');
}
}

14
apps/api/src/helper/string.helper.ts

@ -0,0 +1,14 @@
import { randomBytes } from 'crypto';
export function getRandomString(length: number) {
const bytes = randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];
for (let i = 0; i < length; i++) {
const randomByte = bytes[i];
result.push(characters[randomByte % characters.length]);
}
return result.join('');
}

12
apps/api/src/services/api-key/api-key.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
@Module({
exports: [ApiKeyService],
imports: [PrismaModule],
providers: [ApiKeyService]
})
export class ApiKeyModule {}

63
apps/api/src/services/api-key/api-key.service.ts

@ -0,0 +1,63 @@
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { pbkdf2Sync } from 'crypto';
@Injectable()
export class ApiKeyService {
private readonly algorithm = 'sha256';
private readonly iterations = 100000;
private readonly keyLength = 64;
public constructor(private readonly prismaService: PrismaService) {}
public async create({ userId }: { userId: string }): Promise<ApiKeyResponse> {
const apiKey = this.generateApiKey();
const hashedKey = this.hashApiKey(apiKey);
await this.prismaService.apiKey.deleteMany({ where: { userId } });
await this.prismaService.apiKey.create({
data: {
hashedKey,
userId
}
});
return { apiKey };
}
public async getUserByApiKey(apiKey: string) {
const hashedKey = this.hashApiKey(apiKey);
const { user } = await this.prismaService.apiKey.findUnique({
include: { user: true },
where: { hashedKey }
});
return user;
}
public hashApiKey(apiKey: string): string {
return pbkdf2Sync(
apiKey,
'',
this.iterations,
this.keyLength,
this.algorithm
).toString('hex');
}
private generateApiKey(): string {
return getRandomString(32)
.split('')
.reduce((acc, char, index) => {
const chunkIndex = Math.floor(index / 4);
acc[chunkIndex] = (acc[chunkIndex] || '') + char;
return acc;
}, [])
.join('-');
}
}

50
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -93,7 +93,7 @@ export class GhostfolioService implements DataProviderInterface {
}, requestTimeout);
const { dividends } = await got(
`${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to,
DATE_FORMAT
)}`,
@ -111,8 +111,13 @@ export class GhostfolioService implements DataProviderInterface {
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
@ -138,7 +143,7 @@ export class GhostfolioService implements DataProviderInterface {
}, requestTimeout);
const { historicalData } = await got(
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to,
DATE_FORMAT
)}`,
@ -158,8 +163,13 @@ export class GhostfolioService implements DataProviderInterface {
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
@ -201,7 +211,7 @@ export class GhostfolioService implements DataProviderInterface {
}, requestTimeout);
const { quotes } = await got(
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
`${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
@ -213,15 +223,20 @@ export class GhostfolioService implements DataProviderInterface {
} catch (error) {
let message = error;
if (error?.code === 'ABORT_ERR') {
if (error.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
@ -245,7 +260,7 @@ export class GhostfolioService implements DataProviderInterface {
}, this.configurationService.get('REQUEST_TIMEOUT'));
searchResult = await got(
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`,
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
@ -255,15 +270,20 @@ export class GhostfolioService implements DataProviderInterface {
} catch (error) {
let message = error;
if (error?.code === 'ABORT_ERR') {
if (error.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
@ -278,7 +298,7 @@ export class GhostfolioService implements DataProviderInterface {
)) as string;
return {
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}`
[HEADER_KEY_TOKEN]: `Api-Key ${apiKey}`
};
}
}

4
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -86,7 +86,9 @@ export class PortfolioSnapshotProcessor {
const expiration = addMilliseconds(
new Date(),
this.configurationService.get('CACHE_QUOTES_TTL')
snapshot.errors.length === 0
? this.configurationService.get('CACHE_QUOTES_TTL')
: 0
);
this.redisCacheService.set(

56
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -1,3 +1,4 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -16,7 +17,7 @@ import {
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
import { StringValue } from 'ms';
import ms, { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -34,6 +35,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public defaultDateFormat: string;
public durationExtension: StringValue;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateApiKey: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
@ -73,6 +75,11 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.user.settings.locale
);
this.hasPermissionToCreateApiKey = hasPermission(
this.user.permissions,
permissions.createApiKey
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
@ -100,15 +107,15 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({ sessionId });
}),
catchError((error) => {
this.notificationService.alert({
title: error.message
});
throw error;
}),
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({ sessionId });
})
)
.subscribe((result) => {
@ -120,6 +127,41 @@ export class UserAccountMembershipComponent implements OnDestroy {
});
}
public onGenerateApiKey() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.postApiKey()
.pipe(
catchError(() => {
this.snackBar.open(
'😞 ' + $localize`Could not generate an API key`,
undefined,
{
duration: ms('3 seconds')
}
);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ apiKey }) => {
this.notificationService.alert({
discardLabel: $localize`Okay`,
message:
$localize`Set this API key in your self-hosted environment:` +
'<br />' +
apiKey,
title: $localize`Ghostfolio Premium Data Provider API Key`
});
});
},
confirmType: ConfirmationDialogType.Primary,
title: $localize`Do you really want to generate a new API key?`
});
}
public onRedeemCoupon() {
let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim();
@ -128,18 +170,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.dataService
.redeemCoupon(couponCode)
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`,
undefined,
{
duration: 3000
duration: ms('3 seconds')
}
);
return EMPTY;
})
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.snackBarRef = this.snackBar.open(

2
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -4,7 +4,9 @@
<div class="align-items-center d-flex flex-column">
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[name]="user?.subscription?.type"
(generateApiKeyClicked)="onGenerateApiKey()"
/>
@if (user?.subscription?.type === 'Basic') {
<div class="d-flex flex-column mt-5">

43
apps/client/src/app/pages/api/api-page.component.ts

@ -1,3 +1,7 @@
import {
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
@ -8,7 +12,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { format, startOfYear } from 'date-fns';
import { map, Observable, Subject, takeUntil } from 'rxjs';
@ -28,11 +32,14 @@ export class GfApiPageComponent implements OnInit {
public status$: Observable<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>;
private apiKey: string;
private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {}
public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
@ -52,8 +59,11 @@ export class GfApiPageComponent implements OnInit {
return this.http
.get<DividendsResponse>(
`/api/v1/data-providers/ghostfolio/dividends/${symbol}`,
{ params }
`/api/v2/data-providers/ghostfolio/dividends/${symbol}`,
{
params,
headers: this.getHeaders()
}
)
.pipe(
map(({ dividends }) => {
@ -70,8 +80,11 @@ export class GfApiPageComponent implements OnInit {
return this.http
.get<HistoricalResponse>(
`/api/v1/data-providers/ghostfolio/historical/${symbol}`,
{ params }
`/api/v2/data-providers/ghostfolio/historical/${symbol}`,
{
params,
headers: this.getHeaders()
}
)
.pipe(
map(({ historicalData }) => {
@ -85,8 +98,9 @@ export class GfApiPageComponent implements OnInit {
const params = new HttpParams().set('symbols', symbols.join(','));
return this.http
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
params
.get<QuotesResponse>('/api/v2/data-providers/ghostfolio/quotes', {
params,
headers: this.getHeaders()
})
.pipe(
map(({ quotes }) => {
@ -99,7 +113,8 @@ export class GfApiPageComponent implements OnInit {
private fetchStatus() {
return this.http
.get<DataProviderGhostfolioStatusResponse>(
'/api/v1/data-providers/ghostfolio/status'
'/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() }
)
.pipe(takeUntil(this.unsubscribeSubject));
}
@ -118,8 +133,9 @@ export class GfApiPageComponent implements OnInit {
}
return this.http
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
params
.get<LookupResponse>('/api/v2/data-providers/ghostfolio/lookup', {
params,
headers: this.getHeaders()
})
.pipe(
map(({ items }) => {
@ -128,4 +144,11 @@ export class GfApiPageComponent implements OnInit {
takeUntil(this.unsubscribeSubject)
);
}
private getHeaders() {
return new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Api-Key ${this.apiKey}`
});
}
}

31
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -2,11 +2,28 @@
<div class="row">
<div class="col">
<h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2>
<p class="mb-4" i18n>
<p i18n>
Ghostfolio X-ray uses static analysis to uncover potential issues and
risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy.
</p>
<p class="mb-4">
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="w-100"
[theme]="{
height: '1rem',
width: '100%'
}"
/>
} @else {
{{ statistics?.rulesFulfilledCount }}
<ng-container i18n>of</ng-container>
{{ statistics?.rulesActiveCount }}
<ng-container i18n>rules are currently fulfilled.</ng-container>
}
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@ -20,7 +37,7 @@
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[isLoading]="isLoading"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
@ -39,7 +56,7 @@
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[isLoading]="isLoading"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
@ -58,7 +75,7 @@
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[isLoading]="isLoading"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
@ -77,7 +94,7 @@
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
@ -96,7 +113,7 @@
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[isLoading]="isLoading"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
@ -111,7 +128,7 @@
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[isLoading]="isLoading"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"

52
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -3,8 +3,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioReportRule,
PortfolioReport
PortfolioReportResponse,
PortfolioReportRule
} from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces/user.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +26,8 @@ export class XRayPageComponent {
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoadingPortfolioReport = false;
public isLoading = false;
public statistics: PortfolioReportResponse['statistics'];
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -87,56 +88,53 @@ export class XRayPageComponent {
}
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.isLoading = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
.subscribe(({ rules, statistics }) => {
this.inactiveRules = this.mergeInactiveRules(rules);
this.statistics = statistics;
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
rules['accountClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
rules['currencyClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
rules['economicMarketClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
private mergeInactiveRules(
rules: PortfolioReportResponse['rules']
): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
for (const category in rules) {
const rulesArray = rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {

16
apps/client/src/app/services/admin.service.ts

@ -24,7 +24,7 @@ import {
Filter
} from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
@ -147,14 +147,14 @@ export class AdminService {
public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe(
switchMap(({ settings }) => {
const headers = new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
});
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`,
{
headers: {
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}
}
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
{ headers }
);
})
);

27
apps/client/src/app/services/data.service.ts

@ -22,6 +22,7 @@ import {
AccountBalancesResponse,
Accounts,
AdminMarketDataDetails,
ApiKeyResponse,
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkResponse,
@ -36,7 +37,7 @@ import {
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioReport,
PortfolioReportResponse,
PublicPortfolioResponse,
User
} from '@ghostfolio/common/interfaces';
@ -289,7 +290,7 @@ export class DataService {
public deleteActivities({ filters }) {
const params = this.buildFiltersAsQueryParams({ filters });
return this.http.delete<any>(`/api/v1/order`, { params });
return this.http.delete<any>('/api/v1/order', { params });
}
public deleteActivity(aId: string) {
@ -612,7 +613,7 @@ export class DataService {
}
public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report');
}
public fetchPublicPortfolio(aAccessId: string) {
@ -636,36 +637,40 @@ export class DataService {
}
public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken
});
}
public postAccess(aAccess: CreateAccessDto) {
return this.http.post<OrderModel>(`/api/v1/access`, aAccess);
return this.http.post<OrderModel>('/api/v1/access', aAccess);
}
public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
return this.http.post<OrderModel>('/api/v1/account', aAccount);
}
public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) {
return this.http.post<AccountBalance>(
`/api/v1/account-balance`,
'/api/v1/account-balance',
aAccountBalance
);
}
public postApiKey() {
return this.http.post<ApiKeyResponse>('/api/v1/api-keys', {});
}
public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post(`/api/v1/benchmark`, benchmark);
return this.http.post('/api/v1/benchmark', benchmark);
}
public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
return this.http.post<OrderModel>('/api/v1/order', aOrder);
}
public postUser() {
return this.http.post<UserItem>(`/api/v1/user`, {});
return this.http.post<UserItem>('/api/v1/user', {});
}
public putAccount(aAccount: UpdateAccountDto) {
@ -692,7 +697,7 @@ export class DataService {
}
public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>(`/api/v1/user/setting`, aData);
return this.http.put<User>('/api/v1/user/setting', aData);
}
public redeemCoupon(couponCode: string) {

6
libs/common/src/lib/interfaces/index.ts

@ -34,11 +34,11 @@ import type { PortfolioOverview } from './portfolio-overview.interface';
import type { PortfolioPerformance } from './portfolio-performance.interface';
import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioReport } from './portfolio-report.interface';
import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface';
import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
import type { DividendsResponse } from './responses/dividends-response.interface';
@ -49,6 +49,7 @@ import type { LookupResponse } from './responses/lookup-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PortfolioReportResponse } from './responses/portfolio-report.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { QuotesResponse } from './responses/quotes-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface';
@ -72,6 +73,7 @@ export {
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
ApiKeyResponse,
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,
@ -106,7 +108,7 @@ export {
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReport,
PortfolioReportResponse,
PortfolioReportRule,
PortfolioSummary,
Position,

5
libs/common/src/lib/interfaces/portfolio-report.interface.ts

@ -1,5 +0,0 @@
import { PortfolioReportRule } from './portfolio-report-rule.interface';
export interface PortfolioReport {
rules: { [group: string]: PortfolioReportRule[] };
}

3
libs/common/src/lib/interfaces/responses/api-key-response.interface.ts

@ -0,0 +1,3 @@
export interface ApiKeyResponse {
apiKey: string;
}

9
libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts

@ -0,0 +1,9 @@
import { PortfolioReportRule } from '../portfolio-report-rule.interface';
export interface PortfolioReportResponse {
rules: { [group: string]: PortfolioReportRule[] };
statistics: {
rulesActiveCount: number;
rulesFulfilledCount: number;
};
}

2
libs/common/src/lib/models/portfolio-snapshot.ts

@ -13,7 +13,7 @@ export class PortfolioSnapshot {
@Type(() => Big)
currentValueInBaseCurrency: Big;
errors?: AssetProfileIdentifier[];
errors: AssetProfileIdentifier[];
hasErrors: boolean;

1
libs/common/src/lib/permissions.ts

@ -9,6 +9,7 @@ export const permissions = {
createAccess: 'createAccess',
createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance',
createApiKey: 'createApiKey',
createOrder: 'createOrder',
createPlatform: 'createPlatform',
createTag: 'createTag',

6
libs/ui/src/lib/assistant/assistant.html

@ -104,7 +104,7 @@
<div class="p-3">
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Accounts</mat-label>
<mat-label i18n>Account</mat-label>
<mat-select formControlName="account">
<mat-option [value]="null" />
@for (account of accounts; track account.id) {
@ -152,7 +152,7 @@
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-label i18n>Tag</mat-label>
<mat-select formControlName="tag">
<mat-option [value]="null" />
@for (tag of tags; track tag.id) {
@ -163,7 +163,7 @@
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Classes</mat-label>
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) {

19
libs/ui/src/lib/membership-card/membership-card.component.html

@ -13,6 +13,25 @@
[showLabel]="false"
/>
</div>
@if (hasPermissionToCreateApiKey) {
<div class="d-none mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>

6
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -42,6 +42,12 @@
background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text));
line-height: 1.2;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
}
.heading {
font-size: 13px;

17
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -3,15 +3,18 @@ import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
Input
EventEmitter,
Input,
Output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfLogoComponent } from '../logo';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, GfLogoComponent, RouterModule],
imports: [CommonModule, GfLogoComponent, MatButtonModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-membership-card',
standalone: true,
@ -20,7 +23,17 @@ import { GfLogoComponent } from '../logo';
})
export class GfMembershipCardComponent {
@Input() public expiresAt: string;
@Input() public hasPermissionToCreateApiKey: boolean;
@Input() public name: string;
@Output() generateApiKeyClicked = new EventEmitter<void>();
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public onGenerateApiKey(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.generateApiKeyClicked.emit();
}
}

77
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.125.0",
"version": "2.127.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.125.0",
"version": "2.127.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -40,7 +40,7 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "6.0.0",
"@prisma/client": "6.0.1",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0",
@ -83,6 +83,7 @@
"papaparse": "5.3.1",
"passport": "0.7.0",
"passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
@ -148,9 +149,9 @@
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0",
"nx": "20.1.2",
"prettier": "3.3.3",
"prettier": "3.4.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.0.0",
"prisma": "6.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "7.0.1",
@ -8352,9 +8353,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.0.tgz",
"integrity": "sha512-tOBhG35ozqZ/5Y6B0TNOa6cwULUW8ijXqBXcgb12bfozqf6eGMyGs+jphywCsj6uojv5lAZZnxVSoLMVebIP+g==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.1.tgz",
"integrity": "sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -8370,24 +8371,24 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.0.tgz",
"integrity": "sha512-eUjoNThlDXdyJ1iQ2d7U6aTVwm59EwvODb5zFVNJEokNoSiQmiYWNzZIwZyDmZ+j51j42/0iTaHIJ4/aZPKFRg==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.1.tgz",
"integrity": "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.0.tgz",
"integrity": "sha512-ZZCVP3q22ifN6Ex6C8RIcTDBlRtMJS2H1ljV0knCiWNGArvvkEbE88W3uDdq/l4+UvyvHpGzdf9ZsCWSQR7ZQQ==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.1.tgz",
"integrity": "sha512-4hxzI+YQIR2uuDyVsDooFZGu5AtixbvM2psp+iayDZ4hRrAHo/YwgA17N23UWq7G6gRu18NvuNMb48qjP3DPQw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.0.0",
"@prisma/debug": "6.0.1",
"@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e",
"@prisma/fetch-engine": "6.0.0",
"@prisma/get-platform": "6.0.0"
"@prisma/fetch-engine": "6.0.1",
"@prisma/get-platform": "6.0.1"
}
},
"node_modules/@prisma/engines-version": {
@ -8398,25 +8399,25 @@
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.0.tgz",
"integrity": "sha512-j2m+iO5RDPRI7SUc7sHo8wX7SA4iTkJ+18Sxch8KinQM46YiCQD1iXKN6qU79C1Fliw5Bw/qDyTHaTsa3JMerA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.1.tgz",
"integrity": "sha512-T36bWFVGeGYYSyYOj9d+O9G3sBC+pAyMC+jc45iSL63/Haq1GrYjQPgPMxrEj9m739taXrupoysRedQ+VyvM/Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.0.0",
"@prisma/debug": "6.0.1",
"@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e",
"@prisma/get-platform": "6.0.0"
"@prisma/get-platform": "6.0.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.0.tgz",
"integrity": "sha512-PS6nYyIm9g8C03E4y7LknOfdCw/t2KyEJxntMPQHQZCOUgOpF82Ma60mdlOD08w90I3fjLiZZ0+MadenR3naDQ==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.1.tgz",
"integrity": "sha512-zspC9vlxAqx4E6epMPMLLBMED2VD8axDe8sPnquZ8GOsn6tiacWK0oxrGK4UAHYzYUVuMVUApJbdXB2dFpLhvg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.0.0"
"@prisma/debug": "6.0.1"
}
},
"node_modules/@redis/bloom": {
@ -28414,6 +28415,16 @@
"node": ">= 0.4.0"
}
},
"node_modules/passport-headerapikey": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz",
"integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15",
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
@ -29408,9 +29419,9 @@
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true,
"license": "MIT",
"bin": {
@ -29489,14 +29500,14 @@
}
},
"node_modules/prisma": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.0.tgz",
"integrity": "sha512-RX7KtbW7IoEByf7MR32JK1PkVYLVYFqeODTtiIX3cqekq1aKdsF3Eud4zp2sUShMLjvdb5Jow0LbUjRq5LVxPw==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.1.tgz",
"integrity": "sha512-CaMNFHkf+DDq8zq3X/JJsQ4Koy7dyWwwtOKibkT/Am9j/tDxcfbg7+lB1Dzhx18G/+RQCMgjPYB61bhRqteNBQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "6.0.0"
"@prisma/engines": "6.0.1"
},
"bin": {
"prisma": "build/index.js"

9
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.125.0",
"version": "2.127.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -86,7 +86,7 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "6.0.0",
"@prisma/client": "6.0.1",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0",
@ -129,6 +129,7 @@
"papaparse": "5.3.1",
"passport": "0.7.0",
"passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
@ -194,9 +195,9 @@
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0",
"nx": "20.1.2",
"prettier": "3.3.3",
"prettier": "3.4.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.0.0",
"prisma": "6.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "7.0.1",

5
prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql

@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "ApiKey_hashedKey_idx";
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey");

3
prisma/schema.prisma

@ -79,13 +79,12 @@ model Analytics {
model ApiKey {
createdAt DateTime @default(now())
hashedKey String
hashedKey String @unique
id String @id @default(uuid())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
@@index([hashedKey])
@@index([userId])
}

Loading…
Cancel
Save