mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
32 changed files with 683 additions and 168 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,83 @@ |
|||
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 { Big } from 'big.js'; |
|||
|
|||
@Controller('api') |
|||
export class ApiController { |
|||
public constructor( |
|||
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, latestActivities, markets, performance } = |
|||
await this.portfolioService.getPortfolioOverview({ |
|||
filters, |
|||
impersonationId: undefined, |
|||
userCurrency: user.settings.settings.baseCurrency ?? DEFAULT_CURRENCY, |
|||
userId: user.id |
|||
}); |
|||
|
|||
const totalValueInBaseCurrency = getSum( |
|||
Object.values(holdings).map(({ valueInBaseCurrency }) => { |
|||
return new Big(valueInBaseCurrency ?? 0); |
|||
}) |
|||
).toNumber(); |
|||
|
|||
const apiPortfolioResponse: ApiPortfolioResponse = { |
|||
createdAt, |
|||
latestActivities, |
|||
markets, |
|||
performance, |
|||
totalValueInBaseCurrency, |
|||
alias: apiAccess.alias, |
|||
holdings: {} |
|||
}; |
|||
|
|||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { |
|||
const allocationInPercentage = |
|||
totalValueInBaseCurrency > 0 |
|||
? (portfolioPosition.valueInBaseCurrency ?? 0) / |
|||
totalValueInBaseCurrency |
|||
: 0; |
|||
|
|||
apiPortfolioResponse.holdings[symbol] = { |
|||
allocationInPercentage, |
|||
assetProfile: portfolioPosition.assetProfile, |
|||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, |
|||
markets: portfolioPosition.markets, |
|||
netPerformancePercentWithCurrencyEffect: |
|||
portfolioPosition.netPerformancePercentWithCurrencyEffect, |
|||
quantity: portfolioPosition.quantity, |
|||
valueInBaseCurrency: portfolioPosition.valueInBaseCurrency, |
|||
valueInPercentage: allocationInPercentage |
|||
}; |
|||
} |
|||
|
|||
return apiPortfolioResponse; |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
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'; |
|||
|
|||
import { ApiController } from './api.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [ApiController], |
|||
imports: [PortfolioModule, TransformDataSourceInResponseModule] |
|||
}) |
|||
export class ApiModule {} |
|||
@ -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'); |
|||
} |
|||
@ -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