dev #55
@@ -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
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user