96ba03b909
Verhindert MIME-Type-Fehler im Browser wenn upstream-Server für JS/CSS-Dateien eine HTML-Fehlerseite zurückgibt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
|
|
const blockedHeaders = new Set([
|
|
'x-frame-options',
|
|
'content-security-policy',
|
|
'content-security-policy-report-only',
|
|
'frame-options',
|
|
'content-encoding',
|
|
'content-length',
|
|
'transfer-encoding',
|
|
]);
|
|
|
|
function buildProxyTarget(req: Request, slug?: string[]) {
|
|
const upstreamUrl = process.env.STATISTIK_GRAFIK_URL;
|
|
if (!upstreamUrl) {
|
|
throw new Error('STATISTIK_GRAFIK_URL is not configured');
|
|
}
|
|
|
|
const target = new URL(upstreamUrl);
|
|
const pathSuffix = slug?.filter(Boolean).join('/') || '';
|
|
if (pathSuffix) {
|
|
target.pathname = `${target.pathname.replace(/\/$/, '')}/${pathSuffix}`;
|
|
}
|
|
target.search = new URL(req.url).search;
|
|
return target.toString();
|
|
}
|
|
|
|
function buildAuthHeaders() {
|
|
const headers: Record<string, string> = {};
|
|
const user = process.env.STATISTIK_GRAFIK_USER;
|
|
const pass = process.env.STATISTIK_GRAFIK_PASS;
|
|
if (user && pass) {
|
|
const token = Buffer.from(`${user}:${pass}`).toString('base64');
|
|
headers.Authorization = `Basic ${token}`;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function rewriteHtml(body: string) {
|
|
const proxyBase = '/api/statistik/grafik';
|
|
const upstreamOrigin = new URL(process.env.STATISTIK_GRAFIK_URL!).origin;
|
|
const escapedProxyBase = escapeRegExp(proxyBase);
|
|
const proxyBasePattern = escapedProxyBase.replace(/\//g, '\\/');
|
|
|
|
return body
|
|
.replace(/<meta[^>]*?(?:http-equiv|httpEquiv)\s*=\s*['"]?[^'">\s]+['"]?[^>]*>/ig, (m) => {
|
|
if (/x-frame-options/i.test(m)) return '';
|
|
if (/content-security-policy/i.test(m)) return '';
|
|
if (/frame-ancestors/i.test(m)) return '';
|
|
return m;
|
|
})
|
|
.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBase}/">`)
|
|
.replace(new RegExp(`(href|src|action)=(["'])(?!${proxyBasePattern}\\/)\/`, 'gi'), `$1=$2${proxyBase}/`)
|
|
.replace(new RegExp(`url\\((['"]?)(?!${proxyBasePattern}\\/)\/`, 'gi'), `url($1${proxyBase}/`)
|
|
.replace(new RegExp(escapeRegExp(upstreamOrigin), 'g'), proxyBase);
|
|
}
|
|
|
|
export async function grafikProxy(req: Request, slug?: string[]) {
|
|
let targetUrl: string;
|
|
try {
|
|
targetUrl = buildProxyTarget(req, slug);
|
|
} catch (error) {
|
|
return new NextResponse((error as Error).message, { status: 500 });
|
|
}
|
|
|
|
try {
|
|
const requestHeaders: Record<string, string> = {};
|
|
req.headers.forEach((value, key) => {
|
|
const lower = key.toLowerCase();
|
|
if (lower === 'host' || lower === 'content-length') return;
|
|
requestHeaders[key] = value;
|
|
});
|
|
Object.assign(requestHeaders, buildAuthHeaders());
|
|
|
|
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.arrayBuffer();
|
|
const upstream = await fetch(targetUrl, {
|
|
method: req.method,
|
|
headers: requestHeaders,
|
|
body,
|
|
cache: 'no-store',
|
|
});
|
|
const bodyBuf = await upstream.arrayBuffer();
|
|
|
|
const outHeaders: Record<string, string> = {};
|
|
upstream.headers.forEach((value, key) => {
|
|
if (!blockedHeaders.has(key.toLowerCase())) {
|
|
outHeaders[key] = value;
|
|
}
|
|
});
|
|
|
|
const contentType = upstream.headers.get('content-type') || outHeaders['content-type'] || '';
|
|
const isHtmlResponse = contentType.toLowerCase().includes('text/html');
|
|
|
|
// JS/CSS/font assets that come back as HTML are upstream errors (404, redirect pages).
|
|
// Return a 404 so the browser doesn't refuse to execute them as wrong MIME type.
|
|
const assetExtension = /\.(js|css|woff2?|ttf|eot|png|jpg|gif|svg|ico)(\?|$)/i;
|
|
const requestPath = new URL(req.url).pathname;
|
|
if (isHtmlResponse && slug?.length && assetExtension.test(requestPath)) {
|
|
return new NextResponse(null, { status: 404 });
|
|
}
|
|
|
|
if (isHtmlResponse) {
|
|
const text = Buffer.from(bodyBuf).toString('utf8');
|
|
const cleaned = rewriteHtml(text);
|
|
outHeaders['content-type'] = 'text/html; charset=utf-8';
|
|
outHeaders['x-frame-options'] = 'ALLOWALL';
|
|
return new NextResponse(cleaned, { status: upstream.status, headers: outHeaders });
|
|
}
|
|
|
|
outHeaders['x-frame-options'] = 'ALLOWALL';
|
|
return new NextResponse(Buffer.from(bodyBuf), { status: upstream.status, headers: outHeaders });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new NextResponse(`grafikProxy error: ${message}`, { status: 500 });
|
|
}
|
|
}
|