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 = {}; 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(/]*?(?: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(/]*)>/i, ``) .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 = {}; 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 = {}; 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 }); } }