This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/app/api/preview/embed/route.ts

123 lines
4.0 KiB
TypeScript

/**
* GET /api/preview/embed?u=<encoded https URL>
*
* Experimental: authenticated HTML proxy that injects vibn-preview-bridge.js + `<base>`.
* Serving tunnel markup under the Vibn origin breaks Next.js and similar SPAs
* (History/replaceState + asset CORS). Use direct iframe tunnel URLs by default;
* enable via NEXT_PUBLIC_USE_PREVIEW_EMBED_PROXY only for simple static previews.
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { serverPreviewHostnameAllowed } from "@/lib/preview-embed-allowlist";
export const runtime = "nodejs";
const MAX_HTML_CHARS = 2_500_000;
function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function requestOrigin(req: Request): string {
const self = new URL(req.url);
const xfHost = req.headers.get("x-forwarded-host");
const xfProto = req.headers.get("x-forwarded-proto");
if (xfHost) {
return `${xfProto ?? "https"}://${xfHost.split(",")[0]?.trim() ?? xfHost}`;
}
return self.origin;
}
function directoryBasePath(pathname: string): string {
if (pathname.endsWith("/")) return pathname || "/";
const i = pathname.lastIndexOf("/");
if (i <= 0) return "/";
return pathname.slice(0, i + 1);
}
/**
* GET /api/preview/embed?u=<encoded https URL>
* Authenticated HTML proxy that injects vibn-preview-bridge.js + <base> so element-pick works same-origin.
*/
export async function GET(req: Request) {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let target: URL;
try {
const { searchParams } = new URL(req.url);
const raw = searchParams.get("u");
if (!raw?.trim()) {
return NextResponse.json({ error: "Missing u" }, { status: 400 });
}
target = new URL(raw);
} catch {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
}
if (!serverPreviewHostnameAllowed(target.hostname, target.protocol)) {
return NextResponse.json({ error: "Host not allowed for preview embed" }, { status: 403 });
}
let res: Response;
try {
res = await fetch(target.toString(), {
redirect: "follow",
signal: AbortSignal.timeout(25_000),
headers: {
Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"User-Agent": "VibnPreviewEmbed/1.0",
},
});
} catch {
return NextResponse.json({ error: "Failed to fetch preview" }, { status: 502 });
}
const finalUrl = new URL(res.url);
if (!serverPreviewHostnameAllowed(finalUrl.hostname, finalUrl.protocol)) {
return NextResponse.json({ error: "Redirect led to disallowed host" }, { status: 403 });
}
const ct = res.headers.get("content-type") ?? "";
if (!/html/i.test(ct)) {
return NextResponse.json({ error: "Not HTML" }, { status: 415 });
}
let html = await res.text();
if (html.length > MAX_HTML_CHARS) {
return NextResponse.json({ error: "HTML too large" }, { status: 413 });
}
const appOrigin = requestOrigin(req);
const bridgeSrc = `${appOrigin}/vibn-preview-bridge.js`;
const baseHref = `${finalUrl.origin}${directoryBasePath(finalUrl.pathname)}`;
html = html.replace(/<meta[^>]*http-equiv\s*=\s*["']Content-Security-Policy["'][^>]*>/gi, "");
html = html.replace(/<base\s[^>]*>/gi, "");
const baseTag = `<base href="${escapeHtmlAttr(baseHref)}">`;
if (/<head[^>]*>/i.test(html)) {
html = html.replace(/<head[^>]*>/i, `$&${baseTag}`);
} else {
html = `<!DOCTYPE html><html><head>${baseTag}<meta charset="utf-8"/></head><body>${html}</body></html>`;
}
const scriptTag = `<script src="${escapeHtmlAttr(bridgeSrc)}" defer><\/script>`;
if (/<\/body>/i.test(html)) {
html = html.replace(/<\/body>/i, `${scriptTag}</body>`);
} else {
html += scriptTag;
}
return new NextResponse(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "private, no-store",
"X-Robots-Tag": "noindex, nofollow",
},
});
}