Browse Source

Setup API keys for Ghostfolio data provider

pull/4093/head
Thomas Kaul 9 months ago
parent
commit
57814dbd41
  1. 2
      apps/api/src/app/app.module.ts
  2. 25
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  3. 11
      apps/api/src/app/endpoints/api-keys/api-keys.module.ts
  4. 21
      apps/api/src/app/user/user.service.ts
  5. 14
      apps/api/src/helper/string.helper.ts
  6. 12
      apps/api/src/services/api-key/api-key.module.ts
  7. 43
      apps/api/src/services/api-key/api-key.service.ts
  8. 46
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  9. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  10. 23
      apps/client/src/app/services/data.service.ts
  11. 2
      libs/common/src/lib/interfaces/index.ts
  12. 3
      libs/common/src/lib/interfaces/responses/api-key-response.interface.ts
  13. 1
      libs/common/src/lib/permissions.ts
  14. 19
      libs/ui/src/lib/membership-card/membership-card.component.html
  15. 6
      libs/ui/src/lib/membership-card/membership-card.component.scss
  16. 17
      libs/ui/src/lib/membership-card/membership-card.component.ts

2
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 { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.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 { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
@ -55,6 +56,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
ApiKeysModule,
AssetModule, AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,

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

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

21
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 { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; 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 { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; 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'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -309,6 +310,8 @@ export class UserService {
// Reset holdings view mode // Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined; user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
// TODO
// currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
@ -408,10 +411,7 @@ export class UserService {
} }
if (data.provider === 'ANONYMOUS') { if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken( const accessToken = this.createAccessToken(user.id, getRandomString(10));
user.id,
this.getRandomString(10)
);
const hashedAccessToken = this.createAccessToken( const hashedAccessToken = this.createAccessToken(
accessToken, accessToken,
@ -528,17 +528,4 @@ export class UserService {
return settings; 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('');
}
} }

14
apps/api/src/helper/string.helper.ts

@ -0,0 +1,14 @@
const crypto = require('crypto');
export function 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('');
}

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

43
apps/api/src/services/api-key/api-key.service.ts

@ -0,0 +1,43 @@
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';
const crypto = require('crypto');
@Injectable()
export class ApiKeyService {
public constructor(private readonly prismaService: PrismaService) {}
public async create({ userId }: { userId: string }): Promise<ApiKeyResponse> {
let apiKey = getRandomString(32);
apiKey = apiKey
.split('')
.reduce((acc, char, index) => {
const chunkIndex = Math.floor(index / 4);
acc[chunkIndex] = (acc[chunkIndex] || '') + char;
return acc;
}, [])
.join('-');
const iterations = 100000;
const keyLength = 64;
const hashedKey = crypto
.pbkdf2Sync(apiKey, '', iterations, keyLength, 'sha256')
.toString('hex');
await this.prismaService.apiKey.deleteMany({ where: { userId } });
await this.prismaService.apiKey.create({
data: {
hashedKey,
userId
}
});
return { apiKey };
}
}

46
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 { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -16,7 +17,7 @@ import {
MatSnackBarRef, MatSnackBarRef,
TextOnlySnackBar TextOnlySnackBar
} from '@angular/material/snack-bar'; } from '@angular/material/snack-bar';
import { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -34,6 +35,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public defaultDateFormat: string; public defaultDateFormat: string;
public durationExtension: StringValue; public durationExtension: StringValue;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToCreateApiKey: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public price: number; public price: number;
public priceId: string; public priceId: string;
@ -73,6 +75,11 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.user.settings.locale this.user.settings.locale
); );
this.hasPermissionToCreateApiKey = hasPermission(
this.user.permissions,
permissions.createApiKey
);
this.hasPermissionToUpdateUserSettings = hasPermission( this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions, this.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
@ -120,6 +127,41 @@ export class UserAccountMembershipComponent implements OnDestroy {
}); });
} }
public onGenerateApiKey() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.postApiKey()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.snackBar.open(
'😞 ' + $localize`Could not generate an API key`,
undefined,
{
duration: ms('3 seconds')
}
);
return EMPTY;
})
)
.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() { public onRedeemCoupon() {
let couponCode = prompt($localize`Please enter your coupon code:`); let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim(); couponCode = couponCode?.trim();
@ -134,7 +176,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
'😞 ' + $localize`Could not redeem coupon code`, '😞 ' + $localize`Could not redeem coupon code`,
undefined, undefined,
{ {
duration: 3000 duration: ms('3 seconds')
} }
); );

2
apps/client/src/app/components/user-account-membership/user-account-membership.html

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

23
apps/client/src/app/services/data.service.ts

@ -22,6 +22,7 @@ import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts, Accounts,
AdminMarketDataDetails, AdminMarketDataDetails,
ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
@ -289,7 +290,7 @@ export class DataService {
public deleteActivities({ filters }) { public deleteActivities({ filters }) {
const params = this.buildFiltersAsQueryParams({ 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) { public deleteActivity(aId: string) {
@ -636,36 +637,40 @@ export class DataService {
} }
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, { return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken accessToken
}); });
} }
public postAccess(aAccess: CreateAccessDto) { 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) { 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) { public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) {
return this.http.post<AccountBalance>( return this.http.post<AccountBalance>(
`/api/v1/account-balance`, '/api/v1/account-balance',
aAccountBalance aAccountBalance
); );
} }
public postApiKey() {
return this.http.post<ApiKeyResponse>('/api/v1/api-keys', {});
}
public postBenchmark(benchmark: AssetProfileIdentifier) { public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post(`/api/v1/benchmark`, benchmark); return this.http.post('/api/v1/benchmark', benchmark);
} }
public postOrder(aOrder: CreateOrderDto) { public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/v1/order`, aOrder); return this.http.post<OrderModel>('/api/v1/order', aOrder);
} }
public postUser() { public postUser() {
return this.http.post<UserItem>(`/api/v1/user`, {}); return this.http.post<UserItem>('/api/v1/user', {});
} }
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {
@ -692,7 +697,7 @@ export class DataService {
} }
public putUserSetting(aData: UpdateUserSettingDto) { 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) { public redeemCoupon(couponCode: string) {

2
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 { Position } from './position.interface';
import type { Product } from './product'; import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; 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 { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
import type { DividendsResponse } from './responses/dividends-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface';
@ -72,6 +73,7 @@ export {
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AdminUsers, AdminUsers,
ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,

3
libs/common/src/lib/interfaces/responses/api-key-response.interface.ts

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

1
libs/common/src/lib/permissions.ts

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

19
libs/ui/src/lib/membership-card/membership-card.component.html

@ -13,6 +13,25 @@
[showLabel]="false" [showLabel]="false"
/> />
</div> </div>
@if (hasPermissionToCreateApiKey) {
<div class="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 class="d-flex justify-content-between">
<div> <div>
<div class="heading text-muted" i18n>Membership</div> <div class="heading text-muted" i18n>Membership</div>

6
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -42,6 +42,12 @@
background-color: #1d2124; background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth)); border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text)); color: rgba(var(--light-primary-text));
line-height: 1.2;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
}
.heading { .heading {
font-size: 13px; font-size: 13px;

17
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -3,15 +3,18 @@ import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
Input EventEmitter,
Input,
Output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLogoComponent } from '../logo'; import { GfLogoComponent } from '../logo';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, GfLogoComponent, RouterModule], imports: [CommonModule, GfLogoComponent, MatButtonModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-membership-card', selector: 'gf-membership-card',
standalone: true, standalone: true,
@ -20,7 +23,17 @@ import { GfLogoComponent } from '../logo';
}) })
export class GfMembershipCardComponent { export class GfMembershipCardComponent {
@Input() public expiresAt: string; @Input() public expiresAt: string;
@Input() public hasPermissionToCreateApiKey: boolean;
@Input() public name: string; @Input() public name: string;
@Output() generateApiKeyClicked = new EventEmitter<void>();
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public onGenerateApiKey(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.generateApiKeyClicked.emit();
}
} }

Loading…
Cancel
Save