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