diff --git a/CHANGELOG.md b/CHANGELOG.md index 506bd6fe85..2e0eff1c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Simplified the portfolio performance on the overview tab of the home page by hiding the currency on mobile +- Extended the user detail dialog in the users section of the admin control panel to include the client of the last request - Extended the access to share the portfolio with the Model Context Protocol (MCP) to support the _View_ and the _View and manage_ permissions (experimental) - Improved the performance of getting the historical market data by selecting only the required columns diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index aedc8e4d0b..92c07ff2d3 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -725,7 +725,8 @@ export class AdminService { activityCount: true, country: true, dataProviderGhostfolioDailyRequests: true, - lastRequestAt: true + lastRequestAt: true, + lastRequestClient: true } }, createdAt: true, @@ -771,7 +772,8 @@ export class AdminService { activityCount: _count.activities || 0, country: analytics?.country, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, - lastActivity: analytics?.lastRequestAt + lastActivity: analytics?.lastRequestAt, + lastRequestClient: analytics?.lastRequestClient }; } ); diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts index af83f4c60d..0e990bb990 100644 --- a/apps/api/src/app/auth/api-key.strategy.ts +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -1,12 +1,12 @@ 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 { RequestClient } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; @@ -18,7 +18,6 @@ export class ApiKeyStrategy extends PassportStrategy( 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 ' }, false); @@ -42,13 +41,9 @@ export class ApiKeyStrategy extends PassportStrategy( ); } - await this.prismaService.analytics.upsert({ - create: { user: { connect: { id: user.id } } }, - update: { - activityCount: { increment: 1 }, - lastRequestAt: new Date() - }, - where: { userId: user.id } + await this.userService.updateAnalytics({ + requestClient: RequestClient.SELF_HOSTING, + userId: user.id }); } diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index 189389a860..703fedcd50 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -1,6 +1,6 @@ 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_CURRENCY, DEFAULT_LANGUAGE_CODE, @@ -10,6 +10,7 @@ import { hasRole } from '@ghostfolio/common/permissions'; import { HttpException, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import { RequestClient } from '@prisma/client'; import * as countriesAndTimezones from 'countries-and-timezones'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { ExtractJwt, Strategy } from 'passport-jwt'; @@ -18,7 +19,6 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { public constructor( private readonly configurationService: ConfigurationService, - private readonly prismaService: PrismaService, private readonly userService: UserService ) { super({ @@ -52,14 +52,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { const country = countriesAndTimezones.getCountryForTimezone(timezone)?.id; - await this.prismaService.analytics.upsert({ - create: { country, user: { connect: { id: user.id } } }, - update: { - country, - activityCount: { increment: 1 }, - lastRequestAt: new Date() - }, - where: { userId: user.id } + await this.userService.updateAnalytics({ + country, + requestClient: isBrowserUserAgent(request.headers['user-agent']) + ? RequestClient.WEB + : RequestClient.API, + userId: user.id }); } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 227d04da80..61242d07cf 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/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 { EventEmitter2 } from '@nestjs/event-emitter'; 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 { isNil, without } from 'lodash-es'; import { createHmac } from 'node:crypto'; @@ -681,6 +681,36 @@ export class UserService { }); } + /** + * Keeps the date and the client of the request of the user. The request is + * counted only if the analytics of the user exist already. 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({ data, where diff --git a/apps/api/src/helper/user-agent.helper.spec.ts b/apps/api/src/helper/user-agent.helper.spec.ts new file mode 100644 index 0000000000..31d8a4d2cb --- /dev/null +++ b/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); + }); +}); diff --git a/apps/api/src/helper/user-agent.helper.ts b/apps/api/src/helper/user-agent.helper.ts new file mode 100644 index 0000000000..8dd166a4a5 --- /dev/null +++ b/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) + ); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 72be15b9d9..cb1734baea 100644 --- a/apps/api/src/main.ts +++ b/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 { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.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 app.use( MCP_ENDPOINT, - createMcpAuthorizationMiddleware(app.get(ImpersonationService)) + createMcpAuthorizationMiddleware({ + configurationService, + impersonationService: app.get(ImpersonationService), + userService: app.get(UserService) + }) ); const mcpStrategy = app.get(MCP_STRATEGY); diff --git a/apps/api/src/middlewares/mcp-authorization.middleware.ts b/apps/api/src/middlewares/mcp-authorization.middleware.ts index ba3a960535..47c4332b08 100644 --- a/apps/api/src/middlewares/mcp-authorization.middleware.ts +++ b/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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { HEADER_KEY_TOKEN, MCP_REALM } from '@ghostfolio/common/config'; import type { ImpersonationContext } from '@ghostfolio/common/types'; +import { RequestClient } from '@prisma/client'; import { NextFunction, Request, Response } from 'express'; 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 * 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. + * + * 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( - impersonationService: ImpersonationService -) { +export function createMcpAuthorizationMiddleware({ + configurationService, + impersonationService, + userService +}: { + configurationService: ConfigurationService; + impersonationService: ImpersonationService; + userService: UserService; +}) { return async ( request: Request & { impersonationOfBearerToken?: ImpersonationContext }, 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, // hence the access is looked up once request.impersonationOfBearerToken = impersonation; diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html index c54318f856..599b5cde71 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -169,6 +169,15 @@ >Last Request +
+ Client +
@if (subscriptionsDataSource.data.length > 0) { diff --git a/libs/common/src/lib/interfaces/admin-user.interface.ts b/libs/common/src/lib/interfaces/admin-user.interface.ts index a5c46d92f8..2df87dc256 100644 --- a/libs/common/src/lib/interfaces/admin-user.interface.ts +++ b/libs/common/src/lib/interfaces/admin-user.interface.ts @@ -1,5 +1,5 @@ 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 { accountCount: number; @@ -10,6 +10,7 @@ export interface AdminUser { engagement: number; id: string; lastActivity: Date; + lastRequestClient: RequestClient; provider: Provider; role: Role; subscription?: Subscription; diff --git a/prisma/migrations/20261011000000_added_last_request_client_to_analytics/migration.sql b/prisma/migrations/20261011000000_added_last_request_client_to_analytics/migration.sql new file mode 100644 index 0000000000..f2649e8b76 --- /dev/null +++ b/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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9690fbe47f..e21fc10e8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,13 +71,14 @@ model AccountBalance { } model Analytics { - activityCount Int @default(0) + activityCount Int @default(0) country String? - dataProviderGhostfolioDailyRequests Int @default(0) - lastRequestAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], onDelete: Cascade, references: [id]) - userId String @id + dataProviderGhostfolioDailyRequests Int @default(0) + lastRequestAt DateTime @default(now()) + lastRequestClient RequestClient? + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], onDelete: Cascade, references: [id]) + userId String @id @@index([lastRequestAt]) @@index([updatedAt]) @@ -395,6 +396,13 @@ enum Provider { OIDC } +enum RequestClient { + API + MCP + SELF_HOSTING + WEB +} + enum Role { ADMIN DEMO