Browse Source

Extend user detail dialog with client of last request

pull/8129/head
Thomas Kaul 4 days ago
parent
commit
b00a77346c
  1. 6
      apps/api/src/app/admin/admin.service.ts
  2. 13
      apps/api/src/app/auth/api-key.strategy.ts
  3. 18
      apps/api/src/app/auth/jwt.strategy.ts
  4. 31
      apps/api/src/app/user/user.service.ts
  5. 45
      apps/api/src/helper/user-agent.helper.spec.ts
  6. 14
      apps/api/src/helper/user-agent.helper.ts
  7. 7
      apps/api/src/main.ts
  8. 25
      apps/api/src/middlewares/mcp-authorization.middleware.ts
  9. 9
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  10. 3
      libs/common/src/lib/interfaces/admin-user.interface.ts
  11. 5
      prisma/migrations/20261011000000_added_last_request_client_to_analytics/migration.sql
  12. 20
      prisma/schema.prisma

6
apps/api/src/app/admin/admin.service.ts

@ -725,7 +725,8 @@ export class AdminService {
activityCount: true, activityCount: true,
country: true, country: true,
dataProviderGhostfolioDailyRequests: true, dataProviderGhostfolioDailyRequests: true,
lastRequestAt: true lastRequestAt: true,
lastRequestClient: true
} }
}, },
createdAt: true, createdAt: true,
@ -771,7 +772,8 @@ export class AdminService {
activityCount: _count.activities || 0, activityCount: _count.activities || 0,
country: analytics?.country, country: analytics?.country,
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: analytics?.lastRequestAt lastActivity: analytics?.lastRequestAt,
lastRequestClient: analytics?.lastRequestClient
}; };
} }
); );

13
apps/api/src/app/auth/api-key.strategy.ts

@ -1,12 +1,12 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions'; import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { RequestClient } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
@ -18,7 +18,6 @@ export class ApiKeyStrategy extends PassportStrategy(
public constructor( public constructor(
private readonly apiKeyService: ApiKeyService, private readonly apiKeyService: ApiKeyService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false); super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
@ -42,13 +41,9 @@ export class ApiKeyStrategy extends PassportStrategy(
); );
} }
await this.prismaService.analytics.upsert({ await this.userService.updateAnalytics({
create: { user: { connect: { id: user.id } } }, requestClient: RequestClient.SELF_HOSTING,
update: { userId: user.id
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
}); });
} }

18
apps/api/src/app/auth/jwt.strategy.ts

@ -1,6 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { isBrowserUserAgent } from '@ghostfolio/api/helper/user-agent.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
@ -10,6 +10,7 @@ import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { RequestClient } from '@prisma/client';
import * as countriesAndTimezones from 'countries-and-timezones'; import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@ -18,7 +19,6 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super({ super({
@ -52,14 +52,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({ await this.userService.updateAnalytics({
create: { country, user: { connect: { id: user.id } } }, country,
update: { requestClient: isBrowserUserAgent(request.headers['user-agent'])
country, ? RequestClient.WEB
activityCount: { increment: 1 }, : RequestClient.API,
lastRequestAt: new Date() userId: user.id
},
where: { userId: user.id }
}); });
} }

31
apps/api/src/app/user/user.service.ts

@ -45,7 +45,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, RequestClient, Role, User } from '@prisma/client';
import { addMilliseconds, differenceInDays, isBefore, subDays } from 'date-fns'; import { addMilliseconds, differenceInDays, isBefore, subDays } from 'date-fns';
import { isNil, without } from 'lodash-es'; import { isNil, without } from 'lodash-es';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@ -681,6 +681,35 @@ export class UserService {
}); });
} }
/**
* Counts the request of the user and keeps its date and its client. The
* country is kept only if it is given.
*/
public async updateAnalytics({
country,
requestClient,
userId
}: {
country?: string;
requestClient: RequestClient;
userId: string;
}) {
return this.prismaService.analytics.upsert({
create: {
country,
lastRequestClient: requestClient,
user: { connect: { id: userId } }
},
update: {
country,
activityCount: { increment: 1 },
lastRequestAt: new Date(),
lastRequestClient: requestClient
},
where: { userId }
});
}
public async updateUser({ public async updateUser({
data, data,
where where

45
apps/api/src/helper/user-agent.helper.spec.ts

@ -0,0 +1,45 @@
import { isBrowserUserAgent } from './user-agent.helper';
describe('isBrowserUserAgent', () => {
it('should tell that the user agent of a browser is one', () => {
// Chrome on macOS
expect(
isBrowserUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
)
).toBe(true);
// Chrome on Android
expect(
isBrowserUserAgent(
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36'
)
).toBe(true);
// Firefox on Windows
expect(
isBrowserUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0'
)
).toBe(true);
// Safari on iPhone
expect(
isBrowserUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1'
)
).toBe(true);
});
it('should tell that the user agent of another client is not one', () => {
expect(isBrowserUserAgent('curl/8.7.1')).toBe(false);
expect(isBrowserUserAgent('node')).toBe(false);
expect(isBrowserUserAgent('python-requests/2.32.3')).toBe(false);
expect(isBrowserUserAgent('Dart/3.5 (dart:io)')).toBe(false);
});
it('should tell that a missing user agent is not one', () => {
expect(isBrowserUserAgent()).toBe(false);
expect(isBrowserUserAgent('')).toBe(false);
});
});

14
apps/api/src/helper/user-agent.helper.ts

@ -0,0 +1,14 @@
const PREFIX_OF_BROWSER_USER_AGENT = 'Mozilla/';
/**
* Tells if the user agent is the one of a browser. Every major browser starts
* its user agent with the same prefix for compatibility, while other clients,
* such as scripts and native apps, give the name of their library instead.
* The value is for analytics only, because a client can send any user agent.
*/
export function isBrowserUserAgent(userAgent?: string) {
return (
typeof userAgent === 'string' &&
userAgent.startsWith(PREFIX_OF_BROWSER_USER_AGENT)
);
}

7
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getHelmetOptions } from '@ghostfolio/api/helper/security-headers.helper'; import { getHelmetOptions } from '@ghostfolio/api/helper/security-headers.helper';
import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware';
import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware'; import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware';
@ -159,7 +160,11 @@ async function bootstrap() {
// specification instead of an error of the protocol // specification instead of an error of the protocol
app.use( app.use(
MCP_ENDPOINT, MCP_ENDPOINT,
createMcpAuthorizationMiddleware(app.get(ImpersonationService)) createMcpAuthorizationMiddleware({
configurationService,
impersonationService: app.get(ImpersonationService),
userService: app.get(UserService)
})
); );
const mcpStrategy = app.get<McpStrategy>(MCP_STRATEGY); const mcpStrategy = app.get<McpStrategy>(MCP_STRATEGY);

25
apps/api/src/middlewares/mcp-authorization.middleware.ts

@ -1,8 +1,11 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getAccessIdOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; import { getAccessIdOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_TOKEN, MCP_REALM } from '@ghostfolio/common/config'; import { HEADER_KEY_TOKEN, MCP_REALM } from '@ghostfolio/common/config';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { RequestClient } from '@prisma/client';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@ -16,10 +19,19 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
* The challenge names no resource metadata, because Ghostfolio has no * The challenge names no resource metadata, because Ghostfolio has no
* authorization server. A client which reads the challenge asks the user for * authorization server. A client which reads the challenge asks the user for
* the identifier of the access instead of starting a flow which would fail. * the identifier of the access instead of starting a flow which would fail.
*
* An authorized request is counted in the analytics of the user who granted
* the access, as the strategies do for the requests of the other clients.
*/ */
export function createMcpAuthorizationMiddleware( export function createMcpAuthorizationMiddleware({
impersonationService: ImpersonationService configurationService,
) { impersonationService,
userService
}: {
configurationService: ConfigurationService;
impersonationService: ImpersonationService;
userService: UserService;
}) {
return async ( return async (
request: Request & { impersonationOfBearerToken?: ImpersonationContext }, request: Request & { impersonationOfBearerToken?: ImpersonationContext },
response: Response, response: Response,
@ -52,6 +64,13 @@ export function createMcpAuthorizationMiddleware(
}); });
} }
if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await userService.updateAnalytics({
requestClient: RequestClient.MCP,
userId: impersonation.userId
});
}
// The guard of the route reads the resolved context from the same request, // The guard of the route reads the resolved context from the same request,
// hence the access is looked up once // hence the access is looked up once
request.impersonationOfBearerToken = impersonation; request.impersonationOfBearerToken = impersonation;

9
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -169,6 +169,15 @@
>Last Request</gf-value >Last Request</gf-value
> >
</div> </div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isLoading]="isLoading"
[value]="user?.lastRequestClient || '-'"
>Client</gf-value
>
</div>
</div> </div>
@if (subscriptionsDataSource.data.length > 0) { @if (subscriptionsDataSource.data.length > 0) {

3
libs/common/src/lib/interfaces/admin-user.interface.ts

@ -1,5 +1,5 @@
import type { Subscription } from '@ghostfolio/prisma/browser'; import type { Subscription } from '@ghostfolio/prisma/browser';
import { Provider, Role } from '@ghostfolio/prisma/enums'; import { Provider, RequestClient, Role } from '@ghostfolio/prisma/enums';
export interface AdminUser { export interface AdminUser {
accountCount: number; accountCount: number;
@ -10,6 +10,7 @@ export interface AdminUser {
engagement: number; engagement: number;
id: string; id: string;
lastActivity: Date; lastActivity: Date;
lastRequestClient: RequestClient;
provider: Provider; provider: Provider;
role: Role; role: Role;
subscription?: Subscription; subscription?: Subscription;

5
prisma/migrations/20261011000000_added_last_request_client_to_analytics/migration.sql

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "RequestClient" AS ENUM ('API', 'MCP', 'SELF_HOSTING', 'WEB');
-- AlterTable
ALTER TABLE "Analytics" ADD COLUMN "lastRequestClient" "RequestClient";

20
prisma/schema.prisma

@ -71,13 +71,14 @@ model AccountBalance {
} }
model Analytics { model Analytics {
activityCount Int @default(0) activityCount Int @default(0)
country String? country String?
dataProviderGhostfolioDailyRequests Int @default(0) dataProviderGhostfolioDailyRequests Int @default(0)
lastRequestAt DateTime @default(now()) lastRequestAt DateTime @default(now())
updatedAt DateTime @updatedAt lastRequestClient RequestClient?
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) updatedAt DateTime @updatedAt
userId String @id user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String @id
@@index([lastRequestAt]) @@index([lastRequestAt])
@@index([updatedAt]) @@index([updatedAt])
@ -395,6 +396,13 @@ enum Provider {
OIDC OIDC
} }
enum RequestClient {
API
MCP
SELF_HOSTING
WEB
}
enum Role { enum Role {
ADMIN ADMIN
DEMO DEMO

Loading…
Cancel
Save