diff --git a/CHANGELOG.md b/CHANGELOG.md index 2628efca80..79183fb41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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 - Updated the _Privacy Policy_ - Extended the import of activities to support CSV files of other brokers, images and PDF files with _Ghostfolio AI_ (experimental) - Extended the access to share the portfolio with the Model Context Protocol (MCP) to support the _View_ and the _View and manage_ permissions (experimental) @@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the dates of the benchmark in the benchmark comparator on the analysis page for instances in time zones with a negative UTC offset - Fixed the value of the accounts by excluding the cash balance when filtering by an asset class other than liquidity on the accounts page and the allocations page - Fixed the value of the accounts by excluding the cash balance when filtering by an asset class other than liquidity in the server of the Model Context Protocol (MCP) (experimental) +- Fixed the missing check of the inactive role and of the daily request limit in the server of the Model Context Protocol (MCP) (experimental) ## 3.83.0 - 2026-10-10 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/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts index 5858cf0b65..992ed353f3 100644 --- a/apps/api/src/app/import/import.service.spec.ts +++ b/apps/api/src/app/import/import.service.spec.ts @@ -1,5 +1,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -101,6 +103,56 @@ describe('ImportService', () => { ); }); + describe('getDividends', () => { + it('returns no dividends if another request fails while the dividends are loading', async () => { + const service = new ImportService( + null, + { + getActivities: jest.fn().mockRejectedValue(new Error()) + } as unknown as ActivitiesService, + { + buildFiltersFromQueryParams: jest.fn().mockReturnValue([]) + } as unknown as ApiService, + null, + null, + { + getDividends: jest.fn(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({}); + }, 10); + }); + }) + } as unknown as DataProviderService, + null, + null, + null, + { + getAccountsWithAggregations: jest + .fn() + .mockResolvedValue({ accounts: [] }), + getHolding: jest.fn().mockResolvedValue({ + dateOfFirstActivity: '2024-01-01', + historicalData: [] + }) + } as unknown as PortfolioService, + { + getSymbolProfiles: jest.fn().mockResolvedValue([]) + } as unknown as SymbolProfileService, + null + ); + + await expect( + service.getDividends({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + userCurrency: 'USD', + userId: 'user-id' + }) + ).resolves.toEqual([]); + }); + }); + describe('import', () => { it('keeps the asset profile of an investment activity after a non-investment activity', async () => { mockCreatedAssetProfiles([ diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 0f44fc2855..7616cd927b 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -120,7 +120,7 @@ export class ImportService { symbol } ]), - await this.dataProviderService.getDividends({ + this.dataProviderService.getDividends({ dataSource, symbol, from: parseDate(dateOfFirstActivity), diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4c3796dc8b..0e927dff09 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'; @@ -686,6 +686,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..416816f298 100644 --- a/apps/api/src/middlewares/mcp-authorization.middleware.ts +++ b/apps/api/src/middlewares/mcp-authorization.middleware.ts @@ -1,8 +1,12 @@ +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 { hasRole } from '@ghostfolio/common/permissions'; 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 +20,23 @@ 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. + * + * With the subscription, the user who granted the access has the same limits + * as with the other clients: an inactive user and a user who exceeds the daily + * request limit get the status 429, which also has to come from here. + * + * 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, @@ -35,20 +52,31 @@ export function createMcpAuthorizationMiddleware( }); if (!impersonation.isActive) { - const parameters = [`realm="${MCP_REALM}"`]; + return sendUnauthorizedResponse({ accessId, response }); + } - if (accessId) { - parameters.push( - 'error="invalid_token"', - 'error_description="The access cannot be resolved"' - ); + if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + const user = await userService.user({ id: impersonation.userId }); + + if (!user) { + return sendUnauthorizedResponse({ accessId, response }); } - response.setHeader('WWW-Authenticate', `Bearer ${parameters.join(', ')}`); + const isRequestLimitExceeded = + hasRole(user, 'INACTIVE') || + (await userService.isDailyRequestLimitExceeded({ user })); + + if (isRequestLimitExceeded) { + return response.status(StatusCodes.TOO_MANY_REQUESTS).json({ + error: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + message: + 'The request limit of the user who granted the access is exceeded' + }); + } - return response.status(StatusCodes.UNAUTHORIZED).json({ - error: getReasonPhrase(StatusCodes.UNAUTHORIZED), - message: 'A valid access is required as a bearer token' + await userService.updateAnalytics({ + requestClient: RequestClient.MCP, + userId: impersonation.userId }); } @@ -59,3 +87,27 @@ export function createMcpAuthorizationMiddleware( return next(); }; } + +function sendUnauthorizedResponse({ + accessId, + response +}: { + accessId: string; + response: Response; +}) { + const parameters = [`realm="${MCP_REALM}"`]; + + if (accessId) { + parameters.push( + 'error="invalid_token"', + 'error_description="The access cannot be resolved"' + ); + } + + response.setHeader('WWW-Authenticate', `Bearer ${parameters.join(', ')}`); + + return response.status(StatusCodes.UNAUTHORIZED).json({ + error: getReasonPhrase(StatusCodes.UNAUTHORIZED), + message: 'A valid access is required as a bearer token' + }); +} 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