dev #55

Merged
JoeKung merged 4 commits from dev into main 2026-03-25 11:57:50 +01:00
3 changed files with 221 additions and 3 deletions
Showing only changes of commit d70423fcc0 - Show all commits

View File

@@ -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

View File

@@ -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({});
});
});

View File

@@ -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<string, string | undefined>;
};
};
const explicitOverride =
typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string'
? globalObject.__SSR_INTERNAL_API_ORIGIN__
: null;
const env = (
globalObject as {
process?: {
env?: Record<string, string | undefined>;
};
}
).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<string, string>
>((headers, name) => {