diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4fbdafb08..b1a240235 100644 --- a/apps/api/src/app/app.module.ts +++ b/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, diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts new file mode 100644 index 000000000..ace7fb245 --- /dev/null +++ b/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 + ); + } + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 67b078c9b..824c432b1 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/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, diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts new file mode 100644 index 000000000..cbc68df93 --- /dev/null +++ b/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 { + return this.apiKeyService.create({ userId: this.request.user.id }); + } +} diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.module.ts b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts new file mode 100644 index 000000000..123f11854 --- /dev/null +++ b/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 {} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 788cfd1bc..f3386f8a7 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/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 { + 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 { + 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 { + 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 { + 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 { @@ -187,9 +355,20 @@ export class GhostfolioController { } } + /** + * @deprecated + */ @Get('status') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getStatusV1(): Promise { + return this.ghostfolioService.getStatus({ user: this.request.user }); + } + + @Get('status') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async getStatus(): Promise { return this.ghostfolioService.getStatus({ user: this.request.user }); } diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index 7858e24f0..78685a61b 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/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 } }); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 54dafda22..6676a00b6 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/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(''); - } } diff --git a/apps/api/src/helper/string.helper.ts b/apps/api/src/helper/string.helper.ts new file mode 100644 index 000000000..38bac79f1 --- /dev/null +++ b/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(''); +} diff --git a/apps/api/src/services/api-key/api-key.module.ts b/apps/api/src/services/api-key/api-key.module.ts new file mode 100644 index 000000000..8681e3ad7 --- /dev/null +++ b/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 {} diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts new file mode 100644 index 000000000..2a1f14d03 --- /dev/null +++ b/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 { + 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.findFirst({ + 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('-'); + } +} diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 25ffdc677..7102176ae 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/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}` }; } } diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index bde555d8e..9b9242dfa 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/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:` + + '
' + + 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( diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html index 82b329a64..64dd2ce8f 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.html +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -4,7 +4,9 @@
@if (user?.subscription?.type === 'Basic') {
diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index aa176c0f0..a45efd9b4 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/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; public symbols$: Observable; + private apiKey: string; private unsubscribeSubject = new Subject(); 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( - `/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( - `/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('/api/v1/data-providers/ghostfolio/quotes', { - params + .get('/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( - '/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('/api/v1/data-providers/ghostfolio/lookup', { - params + .get('/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}` + }); + } } diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 5d252f00f..77d135f57 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/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( - `${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 } ); }) ); diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index dccbb064a..92d030827 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -22,6 +22,7 @@ import { AccountBalancesResponse, Accounts, AdminMarketDataDetails, + ApiKeyResponse, AssetProfileIdentifier, BenchmarkMarketDataDetails, BenchmarkResponse, @@ -289,7 +290,7 @@ export class DataService { public deleteActivities({ filters }) { const params = this.buildFiltersAsQueryParams({ filters }); - return this.http.delete(`/api/v1/order`, { params }); + return this.http.delete('/api/v1/order', { params }); } public deleteActivity(aId: string) { @@ -636,36 +637,40 @@ export class DataService { } public loginAnonymous(accessToken: string) { - return this.http.post(`/api/v1/auth/anonymous`, { + return this.http.post('/api/v1/auth/anonymous', { accessToken }); } public postAccess(aAccess: CreateAccessDto) { - return this.http.post(`/api/v1/access`, aAccess); + return this.http.post('/api/v1/access', aAccess); } public postAccount(aAccount: CreateAccountDto) { - return this.http.post(`/api/v1/account`, aAccount); + return this.http.post('/api/v1/account', aAccount); } public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) { return this.http.post( - `/api/v1/account-balance`, + '/api/v1/account-balance', aAccountBalance ); } + public postApiKey() { + return this.http.post('/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(`/api/v1/order`, aOrder); + return this.http.post('/api/v1/order', aOrder); } public postUser() { - return this.http.post(`/api/v1/user`, {}); + return this.http.post('/api/v1/user', {}); } public putAccount(aAccount: UpdateAccountDto) { @@ -692,7 +697,7 @@ export class DataService { } public putUserSetting(aData: UpdateUserSettingDto) { - return this.http.put(`/api/v1/user/setting`, aData); + return this.http.put('/api/v1/user/setting', aData); } public redeemCoupon(couponCode: string) { diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 4d5ce66d0..344a1f965 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -39,6 +39,7 @@ 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'; @@ -72,6 +73,7 @@ export { AdminMarketDataDetails, AdminMarketDataItem, AdminUsers, + ApiKeyResponse, AssetProfileIdentifier, Benchmark, BenchmarkMarketDataDetails, diff --git a/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts new file mode 100644 index 000000000..dace14a02 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts @@ -0,0 +1,3 @@ +export interface ApiKeyResponse { + apiKey: string; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 1a81938b5..cfee1c9e8 100644 --- a/libs/common/src/lib/permissions.ts +++ b/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', diff --git a/libs/ui/src/lib/membership-card/membership-card.component.html b/libs/ui/src/lib/membership-card/membership-card.component.html index 02a4a03f7..37634b020 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.html +++ b/libs/ui/src/lib/membership-card/membership-card.component.html @@ -13,6 +13,25 @@ [showLabel]="false" />
+ @if (hasPermissionToCreateApiKey) { +
+
API Key
+
+
* * * * * * * * *
+
+ +
+
+
+ }
Membership
diff --git a/libs/ui/src/lib/membership-card/membership-card.component.scss b/libs/ui/src/lib/membership-card/membership-card.component.scss index a7cbce91a..270adc0f1 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.scss +++ b/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; diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts index b19072946..5d05d6fe5 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.ts +++ b/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(); + public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; + + public onGenerateApiKey(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.generateApiKeyClicked.emit(); + } } diff --git a/package-lock.json b/package-lock.json index ae7b60a44..0b93a63b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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", diff --git a/package.json b/package.json index 7f124ea20..24e0d64a4 100644 --- a/package.json +++ b/package.json @@ -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",