123 lines
4.0 KiB
TypeScript
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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
}
|
|
|
|
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",
|
|
},
|
|
});
|
|
}
|