Browse Source

Merge branch 'main' into feature/move-total-return-from-experimental-to-general-availability

pull/7990/head
Thomas Kaul 2 weeks ago
committed by GitHub
parent
commit
ddce75527b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 172
      apps/api/src/app/app.module.spec.ts
  3. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  4. 4
      package-lock.json
  5. 2
      package.json

8
CHANGELOG.md

@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Moved the performance calculation including dividends (total return) from experimental to general availability
## 3.79.0 - 2026-10-04
### Added ### Added
- Extended the _Public API_ with the liveness probe endpoint (`GET api/v1/health/liveness`) (experimental) - Extended the _Public API_ with the liveness probe endpoint (`GET api/v1/health/liveness`) (experimental)
@ -16,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Localized the number formatting in the chart of the holdings tab on the home page - Localized the number formatting in the chart of the holdings tab on the home page
- Moved the performance calculation including dividends (total return) from experimental to general availability - Moved the dividend and the dividend yield in the holding detail dialog from experimental to general availability
- Changed the installation of the dependencies from `npm install` to `npm ci` in the `Dockerfile` - Changed the installation of the dependencies from `npm install` to `npm ci` in the `Dockerfile`
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0` - Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0`
- Upgraded `nestjs` from version `11.2.3` to `11.2.6` - Upgraded `nestjs` from version `11.2.3` to `11.2.6`

172
apps/api/src/app/app.module.spec.ts

@ -0,0 +1,172 @@
import {
DynamicModule,
ForwardReference,
RequestMethod,
Type
} from '@nestjs/common';
import {
GUARDS_METADATA,
METHOD_METADATA,
MODULE_METADATA,
PATH_METADATA
} from '@nestjs/common/constants';
import { MetadataScanner } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AppModule } from './app.module';
// The packages are ECMAScript modules, which Jest cannot load
jest.mock('@openrouter/ai-sdk-provider', () => {
return {};
});
jest.mock('ai', () => {
return {};
});
type ModuleDefinition =
DynamicModule | ForwardReference<() => Type> | Promise<DynamicModule> | Type;
/**
* The routes of the controllers which answer a request without the
* authentication of a user or of an API key, sorted by the request method and
* the path. A new route has to apply AuthGuard('api-key') or AuthGuard('jwt')
* (e.g. via the decorator RequiresScope) or has to be added here.
*
* The MCP transport, Bull Board and the static files are not covered, as they
* are not served by a controller
*/
const PUBLIC_ROUTES = [
'GET /asset/:dataSource/:symbol',
'GET /assets/:languageCode/site.webmanifest',
'GET /auth/google',
'GET /auth/google/callback',
'GET /auth/oidc',
'GET /auth/oidc/callback',
'GET /benchmarks',
'GET /health',
'GET /health/ai',
'GET /health/data-enhancer/:name',
'GET /health/data-provider/:dataSource',
'GET /health/liveness',
'GET /info',
'GET /logo',
'GET /logo/:dataSource/:symbol',
'GET /public/:accessId/portfolio',
'GET /sitemap.xml',
'GET /subscription/stripe/callback',
'POST /auth/anonymous',
'POST /auth/webauthn/generate-authentication-options',
'POST /auth/webauthn/verify-authentication',
'POST /user'
];
/**
* Gives the controllers of the module and of each module which it imports
*/
async function getControllers(
moduleDefinition: ModuleDefinition,
visitedModules = new Set<DynamicModule | Type>()
): Promise<Type[]> {
const resolvedModule = await ('forwardRef' in moduleDefinition
? moduleDefinition.forwardRef()
: moduleDefinition);
if (visitedModules.has(resolvedModule)) {
return [];
}
visitedModules.add(resolvedModule);
const isDynamicModule = 'module' in resolvedModule;
const controllers = [
...(isDynamicModule
? (resolvedModule.controllers ?? [])
: ((Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, resolvedModule) ??
[]) as Type[]))
];
const importedModules = isDynamicModule
? [resolvedModule.module, ...(resolvedModule.imports ?? [])]
: ((Reflect.getMetadata(MODULE_METADATA.IMPORTS, resolvedModule) ??
[]) as ModuleDefinition[]);
for (const importedModule of importedModules) {
controllers.push(...(await getControllers(importedModule, visitedModules)));
}
return [...new Set(controllers)];
}
/**
* Gives the paths which the decorator of a controller or of a route sets
*/
function getPaths(target: object) {
return [
(Reflect.getMetadata(PATH_METADATA, target) ?? '') as string | string[]
].flat();
}
/**
* Gives the routes of the controller which apply neither AuthGuard('api-key')
* nor AuthGuard('jwt'), each as the request method and the path
*/
function getRoutesWithoutAuthentication(controller: Type) {
const authenticationGuards: unknown[] = [
AuthGuard('api-key'),
AuthGuard('jwt')
];
const routeHandlersByName = controller.prototype as Record<string, object>;
return new MetadataScanner()
.getAllMethodNames(routeHandlersByName)
.flatMap((methodName) => {
const routeHandler = routeHandlersByName[methodName];
const requestMethod = Reflect.getMetadata(
METHOD_METADATA,
routeHandler
) as RequestMethod | undefined;
if (requestMethod === undefined) {
return [];
}
const guards = [controller, routeHandler].flatMap((target) => {
return (Reflect.getMetadata(GUARDS_METADATA, target) ??
[]) as unknown[];
});
const hasAuthentication = guards.some((guard) => {
return authenticationGuards.includes(guard);
});
if (hasAuthentication) {
return [];
}
return getPaths(controller).flatMap((controllerPath) => {
return getPaths(routeHandler).map((routePath) => {
const path = `${controllerPath}/${routePath}`
.split('/')
.filter(Boolean)
.join('/');
return `${RequestMethod[requestMethod]} /${path}`;
});
});
});
}
describe('AppModule', () => {
it('should require the authentication for each route which is not public', async () => {
const controllers = await getControllers(AppModule);
const routesWithoutAuthentication = controllers
.flatMap((controller) => {
return getRoutesWithoutAuthentication(controller);
})
.sort();
expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES);
});
});

4
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -183,9 +183,7 @@
>Invested Capital</gf-value >Invested Capital</gf-value
> >
</div> </div>
@if ( @if (dividendInBaseCurrency) {
dividendInBaseCurrency && user?.settings?.isExperimentalFeatures
) {
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.78.0", "version": "3.79.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.78.0", "version": "3.79.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.78.0", "version": "3.79.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save