diff --git a/app/api/admin/runs/[id]/route.ts b/app/api/admin/runs/[id]/route.ts index c2fa3724..2f1acd3d 100644 --- a/app/api/admin/runs/[id]/route.ts +++ b/app/api/admin/runs/[id]/route.ts @@ -1,6 +1,7 @@ import { getAdminPrincipal } from "@/lib/admin/auth"; import { getAdminRun } from "@/lib/runs/store"; import { runError, RUN_HEADERS } from "@/lib/runs/http"; +import { publicRunDetail } from "@/lib/assets/public"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET( @@ -17,7 +18,7 @@ export async function GET( const { id } = await context.params; const result = await getAdminRun(actor, id); if (!result) throw new Error("run_not_found"); - return Response.json(result, { headers: RUN_HEADERS }); + return Response.json(publicRunDetail(result), { headers: RUN_HEADERS }); } catch (error) { return runError(error); } diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts new file mode 100644 index 00000000..fb18b2f0 --- /dev/null +++ b/app/api/assets/[id]/route.ts @@ -0,0 +1,127 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import { getAssetSource } from "@/lib/mcp/store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const FORWARDED_HEADERS = [ + "accept-ranges", + "content-length", + "content-range", + "content-type", + "etag", + "last-modified", +] as const; + +function isPrivateIp(address: string): boolean { + const normalized = address.toLowerCase().replace(/^::ffff:/, ""); + if (isIP(normalized) === 4) { + const [a, b] = normalized.split(".").map(Number); + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a >= 224 + ); + } + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + /^fe[89ab]/.test(normalized) + ); +} + +async function assertPublicHttps(raw: string): Promise { + const url = new URL(raw); + if ( + url.protocol !== "https:" || + url.port || + url.username || + url.password || + url.hostname === "localhost" || + url.hostname.endsWith(".local") + ) { + throw new Error("unsafe_asset_origin"); + } + const addresses = await lookup(url.hostname, { all: true, verbatim: true }); + if ( + !addresses.length || + addresses.some(({ address }) => isPrivateIp(address)) + ) { + throw new Error("unsafe_asset_origin"); + } + return url; +} + +async function proxy(request: Request, id: string): Promise { + if (!/^[A-Za-z0-9_-]{1,160}$/.test(id)) { + return new Response("Not found", { status: 404 }); + } + const asset = await getAssetSource(id); + if (!asset) return new Response("Not found", { status: 404 }); + + try { + let target = await assertPublicHttps(asset.url); + let upstream: Response | undefined; + for (let redirects = 0; redirects <= 3; redirects += 1) { + upstream = await fetch(target, { + method: request.method, + redirect: "manual", + credentials: "omit", + cache: "no-store", + signal: AbortSignal.timeout(30_000), + headers: { + Accept: request.headers.get("accept") ?? "*/*", + "Accept-Encoding": "identity", + ...(request.headers.get("range") + ? { Range: request.headers.get("range")! } + : {}), + }, + }); + if (![301, 302, 303, 307, 308].includes(upstream.status)) break; + const location = upstream.headers.get("location"); + await upstream.body?.cancel(); + if (!location || redirects === 3) throw new Error("asset_redirect"); + target = await assertPublicHttps(new URL(location, target).href); + } + if (!upstream) throw new Error("asset_unavailable"); + const headers = new Headers({ + "cache-control": "public, max-age=300, stale-while-revalidate=86400", + "content-security-policy": "default-src 'none'; sandbox", + "x-content-type-options": "nosniff", + }); + for (const name of FORWARDED_HEADERS) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + return new Response(request.method === "HEAD" ? null : upstream.body, { + status: upstream.status, + headers, + }); + } catch { + return new Response("Asset unavailable", { + status: 502, + headers: { "cache-control": "no-store" }, + }); + } +} + +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> } +) { + return proxy(request, (await context.params).id); +} + +export async function HEAD( + request: Request, + context: { params: Promise<{ id: string }> } +) { + return proxy(request, (await context.params).id); +} diff --git a/app/api/console/runs/[id]/route.ts b/app/api/console/runs/[id]/route.ts index 10b55901..ea00e203 100644 --- a/app/api/console/runs/[id]/route.ts +++ b/app/api/console/runs/[id]/route.ts @@ -1,5 +1,10 @@ import { getOwnRun } from "@/lib/runs/store"; import { requireRunOwner, runError, RUN_HEADERS } from "@/lib/runs/http"; +import { publicRunDetail } from "@/lib/assets/public"; +import { + loadFalInputSchema, + resolveFalCatalogEntry, +} from "@/lib/mcp/fal-input-schema"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET( @@ -11,7 +16,12 @@ export async function GET( const { id } = await context.params; const result = await getOwnRun(owner, id); if (!result) throw new Error("run_not_found"); - return Response.json(result, { headers: RUN_HEADERS }); + const catalog = resolveFalCatalogEntry(result); + const inputSchema = catalog ? await loadFalInputSchema(catalog) : null; + return Response.json( + { ...publicRunDetail(result), inputSchema }, + { headers: RUN_HEADERS } + ); } catch (error) { return runError(error); } diff --git a/app/layout.tsx b/app/layout.tsx index cd0eed2c..d613f9b9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import type { CSSProperties } from "react"; import { Toaster } from "sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; const SITE_TITLE = "Livepeer Early Access"; @@ -62,7 +63,7 @@ export default function RootLayout({