|
| 1 | +// Serves files from the committed `examples/<brand>/` tree so the gallery |
| 2 | +// brand page can offer Download buttons (DESIGN.md, tokens.json, tailwind |
| 3 | +// .css, shadcn-theme.css) via plain `<a href download>` anchors without |
| 4 | +// having to embed the file contents as React props. |
| 5 | +// |
| 6 | +// Why a separate route from /api/output? |
| 7 | +// /api/output TRANSIENT files written by /api/extract at request |
| 8 | +// time. Filesystem source is `output/<slug>/`. No-store |
| 9 | +// caching because each extraction overwrites the dir. |
| 10 | +// /api/example COMMITTED files in the repo. Filesystem source is |
| 11 | +// `examples/<slug>/`. Cacheable (the bytes don't |
| 12 | +// change between commits) so browser + CDN can keep |
| 13 | +// the file warm across page loads. |
| 14 | +// Separating the two surfaces means each can have its own caching policy |
| 15 | +// and access pattern without one accidentally serving the other's data. |
| 16 | +// |
| 17 | +// Security: the route must reject anything that escapes the examples |
| 18 | +// root. Symlinks, `..`, NUL bytes, and absolute paths are blocked via |
| 19 | +// realpath comparison (same defense-in-depth as /api/output). |
| 20 | + |
| 21 | +import * as fs from "fs"; |
| 22 | +import * as path from "path"; |
| 23 | + |
| 24 | +export const runtime = "nodejs"; |
| 25 | +// `dynamic = 'force-static'` is wrong here because we serve different |
| 26 | +// files at different sub-paths; let Next.js cache based on the URL. |
| 27 | +// Pages that link to a specific brand's tailwind.css get cached per URL. |
| 28 | + |
| 29 | +const EXAMPLES_ROOT = path.resolve(process.cwd(), "examples"); |
| 30 | + |
| 31 | +const CONTENT_TYPES: Record<string, string> = { |
| 32 | + ".html": "text/html; charset=utf-8", |
| 33 | + ".json": "application/json; charset=utf-8", |
| 34 | + ".css": "text/css; charset=utf-8", |
| 35 | + ".md": "text/markdown; charset=utf-8", |
| 36 | + ".png": "image/png", |
| 37 | + ".jpg": "image/jpeg", |
| 38 | + ".jpeg": "image/jpeg", |
| 39 | + ".svg": "image/svg+xml", |
| 40 | + ".txt": "text/plain; charset=utf-8", |
| 41 | +}; |
| 42 | + |
| 43 | +export async function GET( |
| 44 | + _req: Request, |
| 45 | + { params }: { params: Promise<{ path: string[] }> }, |
| 46 | +) { |
| 47 | + const { path: segments } = await params; |
| 48 | + |
| 49 | + // Defense in depth: reject traversal, NUL, and backslash before |
| 50 | + // resolving; then verify the resolved path is inside EXAMPLES_ROOT. |
| 51 | + for (const seg of segments) { |
| 52 | + if (seg.includes("..") || seg.includes("\0") || seg.includes("\\")) { |
| 53 | + return new Response("Bad path", { status: 400 }); |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + const requested = path.resolve(EXAMPLES_ROOT, ...segments); |
| 58 | + if ( |
| 59 | + !requested.startsWith(EXAMPLES_ROOT + path.sep) && |
| 60 | + requested !== EXAMPLES_ROOT |
| 61 | + ) { |
| 62 | + return new Response("Forbidden", { status: 403 }); |
| 63 | + } |
| 64 | + |
| 65 | + if (!fs.existsSync(requested)) { |
| 66 | + return new Response("Not found", { status: 404 }); |
| 67 | + } |
| 68 | + |
| 69 | + const stat = fs.statSync(requested); |
| 70 | + if (!stat.isFile()) { |
| 71 | + return new Response("Not a file", { status: 400 }); |
| 72 | + } |
| 73 | + |
| 74 | + const ext = path.extname(requested).toLowerCase(); |
| 75 | + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; |
| 76 | + |
| 77 | + const buf = fs.readFileSync(requested); |
| 78 | + return new Response(new Uint8Array(buf), { |
| 79 | + headers: { |
| 80 | + "content-type": contentType, |
| 81 | + "content-length": String(buf.length), |
| 82 | + // Committed files; immutable between deploys. Hour-long browser |
| 83 | + // cache + day-long CDN cache. If a brand's files change, the |
| 84 | + // deploy rolls a new build hash and clients re-fetch. |
| 85 | + "cache-control": "public, max-age=3600, s-maxage=86400", |
| 86 | + }, |
| 87 | + }); |
| 88 | +} |
0 commit comments