mirror of https://github.com/ghostfolio/ghostfolio
27 changed files with 652 additions and 79 deletions
@ -0,0 +1,85 @@ |
|||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service'; |
||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { |
||||
|
DEFAULT_CURRENCY, |
||||
|
DEFAULT_LANGUAGE_CODE, |
||||
|
HEADER_KEY_TOKEN |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { hasRole } from '@ghostfolio/common/permissions'; |
||||
|
import { UserWithApiAccess } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { PassportStrategy } from '@nestjs/passport'; |
||||
|
import { AccessType } from '@prisma/client'; |
||||
|
import { isBefore } from 'date-fns'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ApiAccessTokenStrategy extends PassportStrategy( |
||||
|
HeaderAPIKeyStrategy, |
||||
|
'api-access-token' |
||||
|
) { |
||||
|
public constructor( |
||||
|
private readonly accessService: AccessService, |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly userService: UserService |
||||
|
) { |
||||
|
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false); |
||||
|
} |
||||
|
|
||||
|
public async validate(apiToken: string): Promise<UserWithApiAccess> { |
||||
|
if (!apiToken) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
||||
|
StatusCodes.UNAUTHORIZED |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const access = await this.accessService.getAccessByApiToken(apiToken); |
||||
|
|
||||
|
if ( |
||||
|
!access || |
||||
|
access.type !== AccessType.API || |
||||
|
(access.expiresAt && isBefore(access.expiresAt, new Date())) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
||||
|
StatusCodes.UNAUTHORIZED |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const user = await this.userService.user({ id: access.userId }); |
||||
|
|
||||
|
if (!user) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
||||
|
StatusCodes.UNAUTHORIZED |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && |
||||
|
hasRole(user, 'INACTIVE') |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (!user.settings.settings.baseCurrency) { |
||||
|
user.settings.settings.baseCurrency = DEFAULT_CURRENCY; |
||||
|
} |
||||
|
|
||||
|
if (!user.settings.settings.language) { |
||||
|
user.settings.settings.language = DEFAULT_LANGUAGE_CODE; |
||||
|
} |
||||
|
|
||||
|
// The api token only grants a read-only view of the portfolio
|
||||
|
user.permissions = []; |
||||
|
|
||||
|
return Object.assign(user, { apiAccess: access }); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,146 @@ |
|||||
|
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'; |
||||
|
import { getSum } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AccessSettings, |
||||
|
ApiPortfolioResponse |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import type { UserWithApiAccess } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
Inject, |
||||
|
UseGuards, |
||||
|
UseInterceptors |
||||
|
} 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 } |
||||
|
) {} |
||||
|
|
||||
|
@Get('portfolio') |
||||
|
@UseGuards(AuthGuard('api-access-token')) |
||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
||||
|
public async getPortfolio(): Promise<ApiPortfolioResponse> { |
||||
|
const { apiAccess, ...user } = this.request.user; |
||||
|
|
||||
|
const { filters } = (apiAccess.settings ?? {}) as AccessSettings; |
||||
|
|
||||
|
const [ |
||||
|
{ createdAt, holdings, markets }, |
||||
|
{ performance: performance1d }, |
||||
|
{ performance: performanceMax }, |
||||
|
{ performance: performanceYtd } |
||||
|
] = await Promise.all([ |
||||
|
this.portfolioService.getDetails({ |
||||
|
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 |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
const totalValueInBaseCurrency = getSum( |
||||
|
Object.values(holdings).map(({ valueInBaseCurrency }) => { |
||||
|
return new Big(valueInBaseCurrency ?? 0); |
||||
|
}) |
||||
|
).toNumber(); |
||||
|
|
||||
|
const apiPortfolioResponse: ApiPortfolioResponse = { |
||||
|
createdAt, |
||||
|
latestActivities, |
||||
|
markets, |
||||
|
totalValueInBaseCurrency, |
||||
|
alias: apiAccess.alias, |
||||
|
holdings: {}, |
||||
|
performance: { |
||||
|
'1d': { |
||||
|
relativeChange: |
||||
|
performance1d.netPerformancePercentageWithCurrencyEffect |
||||
|
}, |
||||
|
max: { |
||||
|
relativeChange: |
||||
|
performanceMax.netPerformancePercentageWithCurrencyEffect |
||||
|
}, |
||||
|
ytd: { |
||||
|
relativeChange: |
||||
|
performanceYtd.netPerformancePercentageWithCurrencyEffect |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { |
||||
|
apiPortfolioResponse.holdings[symbol] = { |
||||
|
allocationInPercentage: |
||||
|
portfolioPosition.valueInBaseCurrency / totalValueInBaseCurrency, |
||||
|
assetProfile: portfolioPosition.assetProfile, |
||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, |
||||
|
markets: portfolioPosition.markets, |
||||
|
netPerformancePercentWithCurrencyEffect: |
||||
|
portfolioPosition.netPerformancePercentWithCurrencyEffect, |
||||
|
quantity: portfolioPosition.quantity, |
||||
|
valueInBaseCurrency: portfolioPosition.valueInBaseCurrency, |
||||
|
valueInPercentage: |
||||
|
portfolioPosition.valueInBaseCurrency / totalValueInBaseCurrency |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return apiPortfolioResponse; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
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 { Module } from '@nestjs/common'; |
||||
|
|
||||
|
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 |
||||
|
] |
||||
|
}) |
||||
|
export class ApiModule {} |
||||
@ -0,0 +1,52 @@ |
|||||
|
import { |
||||
|
EnhancedSymbolProfile, |
||||
|
PortfolioDetails, |
||||
|
PortfolioPosition |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { Market } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Order } from '@prisma/client'; |
||||
|
|
||||
|
export interface ApiPortfolioResponse { |
||||
|
alias?: string; |
||||
|
createdAt: Date; |
||||
|
holdings: { |
||||
|
[symbol: string]: Pick< |
||||
|
PortfolioPosition, |
||||
|
| 'allocationInPercentage' |
||||
|
| 'assetProfile' |
||||
|
| 'dateOfFirstActivity' |
||||
|
| 'markets' |
||||
|
| 'netPerformancePercentWithCurrencyEffect' |
||||
|
| 'quantity' |
||||
|
| 'valueInBaseCurrency' |
||||
|
| 'valueInPercentage' |
||||
|
>; |
||||
|
}; |
||||
|
latestActivities: (Pick< |
||||
|
Order, |
||||
|
'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice' |
||||
|
> & { |
||||
|
SymbolProfile?: EnhancedSymbolProfile; |
||||
|
value: number; |
||||
|
valueInBaseCurrency: number; |
||||
|
})[]; |
||||
|
markets: { |
||||
|
[key in Market]: Pick< |
||||
|
NonNullable<PortfolioDetails['markets']>[key], |
||||
|
'id' | 'valueInBaseCurrency' | 'valueInPercentage' |
||||
|
>; |
||||
|
}; |
||||
|
performance: { |
||||
|
'1d': { |
||||
|
relativeChange: number; |
||||
|
}; |
||||
|
max: { |
||||
|
relativeChange: number; |
||||
|
}; |
||||
|
ytd: { |
||||
|
relativeChange: number; |
||||
|
}; |
||||
|
}; |
||||
|
totalValueInBaseCurrency: number; |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
import { Access } from '@prisma/client'; |
||||
|
|
||||
|
export interface CreateAccessResponse extends Omit<Access, 'hashedApiToken'> { |
||||
|
apiToken?: string; |
||||
|
} |
||||
@ -1 +0,0 @@ |
|||||
export type AccessType = 'PRIVATE' | 'PUBLIC'; |
|
||||
@ -0,0 +1,6 @@ |
|||||
|
import { AccessWithGranteeUser } from './access-with-grantee-user.type'; |
||||
|
import { UserWithSettings } from './user-with-settings.type'; |
||||
|
|
||||
|
export type UserWithApiAccess = UserWithSettings & { |
||||
|
apiAccess: AccessWithGranteeUser; |
||||
|
}; |
||||
@ -0,0 +1,13 @@ |
|||||
|
-- CreateEnum |
||||
|
CREATE TYPE "AccessType" AS ENUM ('API', 'PRIVATE', 'PUBLIC'); |
||||
|
|
||||
|
-- AlterTable |
||||
|
ALTER TABLE "Access" ADD COLUMN "expiresAt" TIMESTAMP(3), |
||||
|
ADD COLUMN "hashedApiToken" TEXT, |
||||
|
ADD COLUMN "type" "AccessType" NOT NULL DEFAULT 'PRIVATE'; |
||||
|
|
||||
|
-- Backfill type of existing accesses |
||||
|
UPDATE "Access" SET "type" = 'PUBLIC' WHERE "granteeUserId" IS NULL; |
||||
|
|
||||
|
-- CreateIndex |
||||
|
CREATE UNIQUE INDEX "Access_hashedApiToken_key" ON "Access"("hashedApiToken"); |
||||
Loading…
Reference in new issue