fix(front-end): improvements in ssr
This commit is contained in:
@@ -62,6 +62,8 @@ services:
|
|||||||
container_name: print-calculator-frontend-${ENV}
|
container_name: print-calculator-frontend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
|
environment:
|
||||||
|
- SSR_INTERNAL_API_ORIGIN=http://backend:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ import { REQUEST } from '@angular/core';
|
|||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { serverOriginInterceptor } from './server-origin.interceptor';
|
import { serverOriginInterceptor } from './server-origin.interceptor';
|
||||||
|
|
||||||
|
type TestGlobal = typeof globalThis & {
|
||||||
|
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||||
|
};
|
||||||
|
|
||||||
describe('serverOriginInterceptor', () => {
|
describe('serverOriginInterceptor', () => {
|
||||||
let http: HttpClient;
|
let http: HttpClient;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
const testGlobal = globalThis as TestGlobal;
|
||||||
|
const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
@@ -21,6 +29,7 @@ describe('serverOriginInterceptor', () => {
|
|||||||
provide: REQUEST,
|
provide: REQUEST,
|
||||||
useValue: {
|
useValue: {
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
|
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
headers: {
|
headers: {
|
||||||
host: 'dev.3d-fab.ch',
|
host: 'dev.3d-fab.ch',
|
||||||
authorization: 'Basic dGVzdDp0ZXN0',
|
authorization: 'Basic dGVzdDp0ZXN0',
|
||||||
@@ -38,6 +47,11 @@ describe('serverOriginInterceptor', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
httpMock.verify();
|
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', () => {
|
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');
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
request.flush({});
|
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,
|
resolveRequestOrigin,
|
||||||
} from '../../../core/request-origin';
|
} from '../../../core/request-origin';
|
||||||
|
|
||||||
|
type ServerRequestLike = RequestLike & {
|
||||||
|
originalUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const FORWARDED_REQUEST_HEADERS = [
|
const FORWARDED_REQUEST_HEADERS = [
|
||||||
'authorization',
|
'authorization',
|
||||||
'cookie',
|
'cookie',
|
||||||
'accept-language',
|
'accept-language',
|
||||||
] as const;
|
] 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 {
|
function isAbsoluteUrl(url: string): boolean {
|
||||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
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}`;
|
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(
|
function readRequestHeader(
|
||||||
request: RequestLike | null,
|
request: RequestLike | null,
|
||||||
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
||||||
@@ -34,18 +56,93 @@ function readRequestHeader(
|
|||||||
return typeof headerValue === 'string' ? headerValue : null;
|
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) => {
|
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (isAbsoluteUrl(req.url)) {
|
if (isAbsoluteUrl(req.url)) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
const request = inject(REQUEST, { optional: true }) as ServerRequestLike | null;
|
||||||
const origin = resolveRequestOrigin(request);
|
const origin = resolveApiOrigin(request, req.url);
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`;
|
||||||
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
>((headers, name) => {
|
>((headers, name) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user