Feature/Setup API keys for Ghostfolio data provider (#4093)

* Setup API keys for Ghostfolio data provider
pull/4103/head
Thomas Kaul 2 weeks ago committed by GitHub
parent 45095cfac0
commit 13582afd93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,

@ -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
);
}
}
}

@ -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,

@ -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<ApiKeyResponse> {
return this.apiKeyService.create({ userId: this.request.user.id });
}
}

@ -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 {}

@ -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<DividendsResponse> {
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<HistoricalResponse> {
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<LookupResponse> {
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<QuotesResponse> {
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<QuotesResponse> {
@ -187,9 +355,20 @@ export class GhostfolioController {
}
}
/**
* @deprecated
*/
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}

@ -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 }
});

@ -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('');
}
}

@ -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('');
}

@ -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 {}

@ -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<ApiKeyResponse> {
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('-');
}
}

@ -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) {
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) {
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) {
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) {
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}`
};
}
}

@ -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:` +
'<br />' +
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(

@ -4,7 +4,9 @@
<div class="align-items-center d-flex flex-column">
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[name]="user?.subscription?.type"
(generateApiKeyClicked)="onGenerateApiKey()"
/>
@if (user?.subscription?.type === 'Basic') {
<div class="d-flex flex-column mt-5">

@ -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<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>;
private apiKey: string;
private unsubscribeSubject = new Subject<void>();
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<DividendsResponse>(
`/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<HistoricalResponse>(
`/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<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
params
.get<QuotesResponse>('/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<DataProviderGhostfolioStatusResponse>(
'/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<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
params
.get<LookupResponse>('/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}`
});
}
}

@ -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 }) => {
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`,
{
headers: {
const headers = new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}
}
[HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
});
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
{ headers }
);
})
);

@ -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<any>(`/api/v1/order`, { params });
return this.http.delete<any>('/api/v1/order', { params });
}
public deleteActivity(aId: string) {
@ -636,36 +637,40 @@ export class DataService {
}
public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken
});
}
public postAccess(aAccess: CreateAccessDto) {
return this.http.post<OrderModel>(`/api/v1/access`, aAccess);
return this.http.post<OrderModel>('/api/v1/access', aAccess);
}
public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
return this.http.post<OrderModel>('/api/v1/account', aAccount);
}
public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) {
return this.http.post<AccountBalance>(
`/api/v1/account-balance`,
'/api/v1/account-balance',
aAccountBalance
);
}
public postApiKey() {
return this.http.post<ApiKeyResponse>('/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<OrderModel>(`/api/v1/order`, aOrder);
return this.http.post<OrderModel>('/api/v1/order', aOrder);
}
public postUser() {
return this.http.post<UserItem>(`/api/v1/user`, {});
return this.http.post<UserItem>('/api/v1/user', {});
}
public putAccount(aAccount: UpdateAccountDto) {
@ -692,7 +697,7 @@ export class DataService {
}
public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>(`/api/v1/user/setting`, aData);
return this.http.put<User>('/api/v1/user/setting', aData);
}
public redeemCoupon(couponCode: string) {

@ -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,

@ -0,0 +1,3 @@
export interface ApiKeyResponse {
apiKey: string;
}

@ -9,6 +9,7 @@ export const permissions = {
createAccess: 'createAccess',
createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance',
createApiKey: 'createApiKey',
createOrder: 'createOrder',
createPlatform: 'createPlatform',
createTag: 'createTag',

@ -13,6 +13,25 @@
[showLabel]="false"
/>
</div>
@if (hasPermissionToCreateApiKey) {
<div class="d-none mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>

@ -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;

@ -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<void>();
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public onGenerateApiKey(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.generateApiKeyClicked.emit();
}
}

11
package-lock.json generated

@ -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",

@ -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",

Loading…
Cancel
Save