diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index b57ecab6b..12958dea9 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -30,6 +30,7 @@ import { AccessType, Access as AccessModel } from '@prisma/client'; +import { endOfDay, isBefore, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { omit } from 'lodash'; @@ -106,9 +107,21 @@ export class AccessController { ); } - const isApiAccess = data.type === AccessType.API; + const type = + data.type ?? + (data.granteeUserId ? AccessType.PRIVATE : AccessType.PUBLIC); + const isApiAccess = type === AccessType.API; - if (isApiAccess && data.granteeUserId) { + const expiresAt = data.expiresAt + ? endOfDay(parseISO(data.expiresAt)) + : undefined; + + if ( + (type === AccessType.PRIVATE && !data.granteeUserId) || + (type !== AccessType.PRIVATE && data.granteeUserId) || + (!isApiAccess && expiresAt) || + (expiresAt && isBefore(expiresAt, new Date())) + ) { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), StatusCodes.BAD_REQUEST @@ -121,17 +134,15 @@ export class AccessController { : { apiToken: undefined, hashedApiToken: undefined }; const access = await this.accessService.createAccess({ + expiresAt, hashedApiToken, + type, alias: data.alias || undefined, - expiresAt: isApiAccess ? data.expiresAt : undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, permissions: isApiAccess ? [AccessPermission.READ] : data.permissions, settings: this.accessService.buildSettings(data.filters), - type: - data.type ?? - (data.granteeUserId ? AccessType.PRIVATE : AccessType.PUBLIC), user: { connect: { id: this.request.user.id } } }); @@ -203,7 +214,10 @@ export class AccessController { const isApiAccess = originalAccess.type === AccessType.API; - if (isApiAccess && data.granteeUserId) { + if ( + (originalAccess.type === AccessType.PRIVATE && !data.granteeUserId) || + (originalAccess.type !== AccessType.PRIVATE && data.granteeUserId) + ) { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), StatusCodes.BAD_REQUEST @@ -216,11 +230,11 @@ export class AccessController { alias: data.alias, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } - : isApiAccess - ? undefined - : { disconnect: true }, + : undefined, permissions: isApiAccess ? undefined : data.permissions, - settings: this.accessService.buildSettings(data.filters) + settings: data.filters + ? this.accessService.buildSettings(data.filters) + : undefined }, where: { id } }); diff --git a/apps/api/src/app/access/access.module.ts b/apps/api/src/app/access/access.module.ts index d8b91fda1..b41f3c716 100644 --- a/apps/api/src/app/access/access.module.ts +++ b/apps/api/src/app/access/access.module.ts @@ -1,4 +1,4 @@ -import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -10,7 +10,7 @@ import { AccessService } from './access.service'; @Module({ controllers: [AccessController], exports: [AccessService], - imports: [ConfigurationModule, PrismaModule], - providers: [AccessService, ApiKeyService] + imports: [ApiKeyModule, ConfigurationModule, PrismaModule], + providers: [AccessService] }) export class AccessModule {} diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 0d8be29bc..7055ed420 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -1,3 +1,4 @@ +import { createSha512HmacHash } from '@ghostfolio/api/helper/hash.helper'; 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'; @@ -6,7 +7,6 @@ import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Access, Prisma } from '@prisma/client'; -import { createHmac } from 'node:crypto'; @Injectable() export class AccessService { @@ -101,12 +101,10 @@ export class AccessService { } private hashApiToken(apiToken: string) { - const hash = createHmac( - 'sha512', + // Rotating ACCESS_TOKEN_SALT invalidates all stored api tokens + return createSha512HmacHash( + apiToken, this.configurationService.get('ACCESS_TOKEN_SALT') ); - hash.update(apiToken); - - return hash.digest('hex'); } } diff --git a/apps/api/src/app/endpoints/api/api.controller.ts b/apps/api/src/app/endpoints/api/api.controller.ts index 50c5e47d3..738ece9cf 100644 --- a/apps/api/src/app/endpoints/api/api.controller.ts +++ b/apps/api/src/app/endpoints/api/api.controller.ts @@ -1,4 +1,3 @@ -import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; @@ -18,13 +17,11 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; @Controller('api') export class ApiController { public constructor( - private readonly activitiesService: ActivitiesService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: { user: UserWithApiAccess } ) {} @@ -37,64 +34,13 @@ export class ApiController { const { filters } = (apiAccess.settings ?? {}) as AccessSettings; - const [ - { createdAt, holdings, markets }, - { performance: performance1d }, - { performance: performanceMax }, - { performance: performanceYtd } - ] = await Promise.all([ - this.portfolioService.getDetails({ + const { createdAt, holdings, latestActivities, markets, performance } = + await this.portfolioService.getPortfolioOverview({ filters, - impersonationId: apiAccess.userId, - userId: user.id, - withMarkets: true - }), - ...['1d', 'max', 'ytd'].map((dateRange) => { - return this.portfolioService.getPerformance({ - dateRange, - filters, - impersonationId: undefined, - userId: user.id - }); - }) - ]); - - const { activities } = await this.activitiesService.getActivities({ - filters, - sortColumn: 'date', - sortDirection: 'desc', - take: 10, - types: [ActivityType.BUY, ActivityType.SELL], - userCurrency: user.settings.settings.baseCurrency ?? DEFAULT_CURRENCY, - userId: user.id, - withExcludedAccountsAndActivities: false - }); - - const latestActivities = activities.map( - ({ - currency, - date, - fee, - quantity, - SymbolProfile, - type, - unitPrice, - value, - valueInBaseCurrency - }) => { - return { - currency, - date, - fee, - quantity, - SymbolProfile, - type, - unitPrice, - value, - valueInBaseCurrency - }; - } - ); + impersonationId: undefined, + userCurrency: user.settings.settings.baseCurrency ?? DEFAULT_CURRENCY, + userId: user.id + }); const totalValueInBaseCurrency = getSum( Object.values(holdings).map(({ valueInBaseCurrency }) => { @@ -106,29 +52,21 @@ export class ApiController { createdAt, latestActivities, markets, + performance, totalValueInBaseCurrency, alias: apiAccess.alias, - holdings: {}, - performance: { - '1d': { - relativeChange: - performance1d.netPerformancePercentageWithCurrencyEffect - }, - max: { - relativeChange: - performanceMax.netPerformancePercentageWithCurrencyEffect - }, - ytd: { - relativeChange: - performanceYtd.netPerformancePercentageWithCurrencyEffect - } - } + holdings: {} }; for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + const allocationInPercentage = + totalValueInBaseCurrency > 0 + ? (portfolioPosition.valueInBaseCurrency ?? 0) / + totalValueInBaseCurrency + : 0; + apiPortfolioResponse.holdings[symbol] = { - allocationInPercentage: - portfolioPosition.valueInBaseCurrency / totalValueInBaseCurrency, + allocationInPercentage, assetProfile: portfolioPosition.assetProfile, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, markets: portfolioPosition.markets, @@ -136,8 +74,7 @@ export class ApiController { portfolioPosition.netPerformancePercentWithCurrencyEffect, quantity: portfolioPosition.quantity, valueInBaseCurrency: portfolioPosition.valueInBaseCurrency, - valueInPercentage: - portfolioPosition.valueInBaseCurrency / totalValueInBaseCurrency + valueInPercentage: allocationInPercentage }; } diff --git a/apps/api/src/app/endpoints/api/api.module.ts b/apps/api/src/app/endpoints/api/api.module.ts index 76ad9da93..6ea63c82e 100644 --- a/apps/api/src/app/endpoints/api/api.module.ts +++ b/apps/api/src/app/endpoints/api/api.module.ts @@ -1,22 +1,5 @@ -import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; -import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; -import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; -import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; -import { UserModule } from '@ghostfolio/api/app/user/user.module'; -import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; -import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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'; -import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; -import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; -import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; -import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; -import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { Module } from '@nestjs/common'; @@ -24,28 +7,6 @@ import { ApiController } from './api.controller'; @Module({ controllers: [ApiController], - imports: [ - ActivitiesModule, - BenchmarkModule, - DataProviderModule, - ExchangeRateDataModule, - I18nModule, - ImpersonationModule, - MarketDataModule, - PortfolioSnapshotQueueModule, - PrismaModule, - RedisCacheModule, - SymbolProfileModule, - TransformDataSourceInRequestModule, - UserModule - ], - providers: [ - AccountBalanceService, - AccountService, - CurrentRateService, - PortfolioCalculatorFactory, - PortfolioService, - RulesService - ] + imports: [PortfolioModule, TransformDataSourceInResponseModule] }) export class ApiModule {} diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index a982b8d6e..cc736c9e4 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -1,5 +1,4 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; -import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; @@ -21,12 +20,7 @@ import { Param, UseInterceptors } from '@nestjs/common'; -import { - AccessType, - AssetClass, - AssetSubClass, - Type as ActivityType -} from '@prisma/client'; +import { AccessType, AssetClass, AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -34,7 +28,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; export class PublicController { public constructor( private readonly accessService: AccessService, - private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly portfolioService: PortfolioService, @@ -72,69 +65,13 @@ export class PublicController { const { filters } = (access.settings ?? {}) as AccessSettings; - const [ - { createdAt, holdings, markets }, - { performance: performance1d }, - { performance: performanceMax }, - { performance: performanceYtd } - ] = await Promise.all([ - this.portfolioService.getDetails({ + const { createdAt, holdings, latestActivities, markets, performance } = + await this.portfolioService.getPortfolioOverview({ filters, impersonationId: access.userId, - userId: user.id, - withMarkets: true - }), - ...['1d', 'max', 'ytd'].map((dateRange) => { - return this.portfolioService.getPerformance({ - dateRange, - filters, - impersonationId: undefined, - userId: user.id - }); - }) - ]); - - const { activities } = await this.activitiesService.getActivities({ - filters, - sortColumn: 'date', - sortDirection: 'desc', - take: 10, - types: [ActivityType.BUY, ActivityType.SELL], - userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, - userId: user.id, - withExcludedAccountsAndActivities: false - }); - - // Experimental - const latestActivities = this.configurationService.get( - 'ENABLE_FEATURE_SUBSCRIPTION' - ) - ? [] - : activities.map( - ({ - currency, - date, - fee, - quantity, - SymbolProfile, - type, - unitPrice, - value, - valueInBaseCurrency - }) => { - return { - currency, - date, - fee, - quantity, - SymbolProfile, - type, - unitPrice, - value, - valueInBaseCurrency - }; - } - ); + userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, + userId: user.id + }); Object.values(markets ?? {}).forEach((market) => { delete market.valueInBaseCurrency; @@ -143,24 +80,16 @@ export class PublicController { const publicPortfolioResponse: PublicPortfolioResponse = { createdAt, hasDetails, - latestActivities, markets, + performance, alias: access.alias, holdings: {}, - performance: { - '1d': { - relativeChange: - performance1d.netPerformancePercentageWithCurrencyEffect - }, - max: { - relativeChange: - performanceMax.netPerformancePercentageWithCurrencyEffect - }, - ytd: { - relativeChange: - performanceYtd.netPerformancePercentageWithCurrencyEffect - } - } + // Experimental + latestActivities: this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? [] + : latestActivities }; const totalValue = getSum( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e75e54890..27ecb08a9 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1100,6 +1100,98 @@ export class PortfolioService { }; } + public async getPortfolioOverview({ + filters, + impersonationId, + userCurrency, + userId + }: { + filters?: Filter[]; + impersonationId: string; + userCurrency: string; + userId: string; + }) { + const [ + { activities }, + { createdAt, holdings, markets }, + { performance: performance1d }, + { performance: performanceMax }, + { performance: performanceYtd } + ] = await Promise.all([ + this.activitiesService.getActivities({ + filters, + userCurrency, + userId, + sortColumn: 'date', + sortDirection: 'desc', + take: 10, + types: [ActivityType.BUY, ActivityType.SELL], + withExcludedAccountsAndActivities: false + }), + this.getDetails({ + filters, + impersonationId, + userId, + withMarkets: true + }), + ...(['1d', 'max', 'ytd'] as DateRange[]).map((dateRange) => { + return this.getPerformance({ + dateRange, + filters, + userId, + impersonationId: undefined + }); + }) + ]); + + const latestActivities = activities.map( + ({ + currency, + date, + fee, + quantity, + SymbolProfile, + type, + unitPrice, + value, + valueInBaseCurrency + }) => { + return { + currency, + date, + fee, + quantity, + SymbolProfile, + type, + unitPrice, + value, + valueInBaseCurrency + }; + } + ); + + return { + createdAt, + holdings, + latestActivities, + markets, + performance: { + '1d': { + relativeChange: + performance1d.netPerformancePercentageWithCurrencyEffect + }, + max: { + relativeChange: + performanceMax.netPerformancePercentageWithCurrencyEffect + }, + ytd: { + relativeChange: + performanceYtd.netPerformancePercentageWithCurrencyEffect + } + } + }; + } + public async getReport({ impersonationId, userId diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 0c159bc1c..4526ec90b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -2,6 +2,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser 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 { createSha512HmacHash } from '@ghostfolio/api/helper/hash.helper'; 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'; @@ -54,7 +55,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; import { without } from 'lodash'; -import { createHmac } from 'node:crypto'; @Injectable() export class UserService { @@ -80,10 +80,7 @@ export class UserService { password: string; salt: string; }): string { - const hash = createHmac('sha512', salt); - hash.update(password); - - return hash.digest('hex'); + return createSha512HmacHash(password, salt); } public generateAccessToken({ userId }: { userId: string }) { diff --git a/apps/api/src/helper/hash.helper.ts b/apps/api/src/helper/hash.helper.ts new file mode 100644 index 000000000..4fdaa0a3c --- /dev/null +++ b/apps/api/src/helper/hash.helper.ts @@ -0,0 +1,8 @@ +import { createHmac } from 'node:crypto'; + +export function createSha512HmacHash(value: string, salt: string) { + const hash = createHmac('sha512', salt); + hash.update(value); + + return hash.digest('hex'); +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 56d260d4d..d3fc5343e 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -88,6 +88,7 @@ export class TransformDataSourceInResponseInterceptor< 'holdings[*].assetProfile.dataSource', 'holdings[*].dataSource', 'items[*].dataSource', + 'latestActivities[*].SymbolProfile.dataSource', 'SymbolProfile.dataSource', 'watchlist[*].dataSource' ] diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 9fc4a33ec..faabdf0ab 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -76,6 +76,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { protected readonly minExpiresAtDate = new Date(); protected readonly mode: 'create' | 'update'; + private hasLoadedFilters = false; + private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly data = @@ -250,6 +252,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.updateFiltersFormControl(this.data.access?.settings?.filters); + this.hasLoadedFilters = true; + this.changeDetectorRef.markForCheck(); }); } @@ -265,7 +269,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, - filters: filters.length > 0 ? filters : undefined, + filters: this.hasLoadedFilters ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, id: accessId, permissions: [this.accessForm.get('permissions')?.value]