From d70423fcc0d0bcf38fc1294ad97f3f3aa7e8117c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 24 Mar 2026 16:20:40 +0100 Subject: [PATCH] fix(front-end): improvements in ssr --- docker-compose.deploy.yml | 2 + .../server-origin.interceptor.spec.ts | 119 ++++++++++++++++++ .../interceptors/server-origin.interceptor.ts | 103 ++++++++++++++- 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 1086e1c..5198faf 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -62,6 +62,8 @@ services: container_name: print-calculator-frontend-${ENV} ports: - "${FRONTEND_PORT}:80" + environment: + - SSR_INTERNAL_API_ORIGIN=http://backend:8000 depends_on: - backend restart: always diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts index 403fc8d..f165c08 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -8,11 +8,19 @@ import { REQUEST } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { serverOriginInterceptor } from './server-origin.interceptor'; +type TestGlobal = typeof globalThis & { + __SSR_INTERNAL_API_ORIGIN__?: string; +}; + describe('serverOriginInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; + const testGlobal = globalThis as TestGlobal; + const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__; beforeEach(() => { + delete testGlobal.__SSR_INTERNAL_API_ORIGIN__; + TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([serverOriginInterceptor])), @@ -21,6 +29,7 @@ describe('serverOriginInterceptor', () => { provide: REQUEST, useValue: { protocol: 'https', + url: '/de/shop/p/91823f84-bike-wall-hanger', headers: { host: 'dev.3d-fab.ch', authorization: 'Basic dGVzdDp0ZXN0', @@ -38,6 +47,11 @@ describe('serverOriginInterceptor', () => { afterEach(() => { httpMock.verify(); + if (originalInternalApiOrigin) { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = originalInternalApiOrigin; + return; + } + delete testGlobal.__SSR_INTERNAL_API_ORIGIN__; }); it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => { @@ -74,4 +88,109 @@ describe('serverOriginInterceptor', () => { expect(request.request.headers.get('cookie')).toBe('session=abc123'); request.flush({}); }); + + it('uses the internal SSR API origin for public shop discovery calls', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'http://backend:8000/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Basic dGVzdDp0ZXN0', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + expect(request.request.headers.get('accept-language')).toBe( + 'de-CH,de;q=0.9,en;q=0.8', + ); + request.flush({}); + }); + + it('bypasses the public origin even when the proxy strips authorization on shop SSR requests', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + url: '/de/shop/p/91823f84-bike-wall-hanger', + headers: { + host: 'dev.3d-fab.ch', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'http://backend:8000/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBeNull(); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + expect(request.request.headers.get('accept-language')).toBe( + 'de-CH,de;q=0.9,en;q=0.8', + ); + request.flush({}); + }); + + it('keeps transactional shop API calls on the public origin', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + http.get('/api/shop/cart').subscribe(); + + const request = httpMock.expectOne('https://dev.3d-fab.ch/api/shop/cart'); + expect(request.request.headers.get('authorization')).toBe( + 'Basic dGVzdDp0ZXN0', + ); + request.flush({}); + }); + + it('keeps non-shop pages on the public origin even for public shop APIs', () => { + testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/'; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + url: '/de/checkout?session=abc', + headers: { + host: 'dev.3d-fab.ch', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBeNull(); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + request.flush({}); + }); }); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 836debb..59b7ff7 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -5,12 +5,26 @@ import { resolveRequestOrigin, } from '../../../core/request-origin'; +type ServerRequestLike = RequestLike & { + originalUrl?: string; + url?: string; +}; + const FORWARDED_REQUEST_HEADERS = [ 'authorization', 'cookie', 'accept-language', ] as const; +const SHOP_DISCOVERY_API_PATTERNS = [ + /^\/api\/shop\/categories(?:\/[^/?#]+)?$/i, + /^\/api\/shop\/products$/i, + /^\/api\/shop\/products\/by-path\/[^/?#]+$/i, + /^\/api\/shop\/products\/[^/?#]+$/i, +] as const; + +const SHOP_PAGE_PATH_PATTERN = /^\/(?:it|en|de|fr)\/shop(?:\/.*)?$/i; + function isAbsoluteUrl(url: string): boolean { return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); } @@ -20,6 +34,14 @@ function normalizeRelativePath(url: string): string { return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; } +function stripQueryAndHash(url: string): string { + return String(url ?? '').split(/[?#]/, 1)[0] || '/'; +} + +function normalizeOrigin(origin: string): string { + return origin.replace(/\/+$/, ''); +} + function readRequestHeader( request: RequestLike | null, name: (typeof FORWARDED_REQUEST_HEADERS)[number], @@ -34,18 +56,93 @@ function readRequestHeader( return typeof headerValue === 'string' ? headerValue : null; } +function readRequestPath(request: ServerRequestLike | null): string | null { + const rawPath = + (typeof request?.originalUrl === 'string' && request.originalUrl) || + (typeof request?.url === 'string' && request.url) || + null; + if (!rawPath) { + return null; + } + + if (isAbsoluteUrl(rawPath)) { + try { + return stripQueryAndHash(new URL(rawPath).pathname || '/'); + } catch { + return null; + } + } + + return stripQueryAndHash(rawPath.startsWith('/') ? rawPath : `/${rawPath}`); +} + +function isPublicShopPageRequest(request: ServerRequestLike | null): boolean { + const requestPath = readRequestPath(request); + return !!requestPath && SHOP_PAGE_PATH_PATTERN.test(requestPath); +} + +function isPublicShopDiscoveryApi(url: string): boolean { + const normalizedPath = stripQueryAndHash(normalizeRelativePath(url)); + return SHOP_DISCOVERY_API_PATTERNS.some((pattern) => + pattern.test(normalizedPath), + ); +} + +function readInternalApiOrigin(): string | null { + const globalObject = globalThis as { + __SSR_INTERNAL_API_ORIGIN__?: string; + process?: { + env?: Record; + }; + }; + const explicitOverride = + typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string' + ? globalObject.__SSR_INTERNAL_API_ORIGIN__ + : null; + const env = ( + globalObject as { + process?: { + env?: Record; + }; + } + ).process?.env; + const rawValue = explicitOverride ?? env?.['SSR_INTERNAL_API_ORIGIN']; + if (typeof rawValue !== 'string') { + return null; + } + + const normalized = rawValue.trim(); + return normalized ? normalizeOrigin(normalized) : null; +} + +function resolveApiOrigin( + request: ServerRequestLike | null, + relativeUrl: string, +): string | null { + const internalOrigin = readInternalApiOrigin(); + if ( + internalOrigin && + isPublicShopPageRequest(request) && + isPublicShopDiscoveryApi(relativeUrl) + ) { + return internalOrigin; + } + + return resolveRequestOrigin(request); +} + export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { if (isAbsoluteUrl(req.url)) { return next(req); } - const request = inject(REQUEST, { optional: true }) as RequestLike | null; - const origin = resolveRequestOrigin(request); + const request = inject(REQUEST, { optional: true }) as ServerRequestLike | null; + const origin = resolveApiOrigin(request, req.url); if (!origin) { return next(req); } - const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`; + const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`; const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce< Record >((headers, name) => {