Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
98b423c28This file is pinned to the selected release. Download the original to open it in your design tool.
import { createReadStream, existsSync, statSync } from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import type { NextRequest } from "next/server";
// Serves a hosted variant firmware image.
//
// Why this exists rather than a file in `public/`: these are binaries, one to
// two megabytes each, and they change every time a maintainer cuts a release.
// Putting them in git means the repository carries every version of every
// variant forever. They live on the Pi instead, in a directory outside the
// checkout, and this route hands them out.
//
// Why this exists rather than linking straight to GitHub: the device cannot
// fetch over the internet — TLS needs tens of KB of heap and the board has
// single digits spare — so the panel's own /update page takes a `?src=` URL
// and the BROWSER downloads it and POSTs it to the board. That fetch is
// cross-origin, from `http://patternflow.local` to wherever the image is, so
// the image has to send CORS headers. **GitHub release assets do not.** They
// were checked; there is no `access-control-allow-origin` on them at all.
//
// So one-click install only works for images served from somewhere we control
// the headers, which is the same boundary as "somebody vouched for this
// build" — see the note in editions-data.ts. Everything else links out to the
// maintainer's own releases and is downloaded by hand.
//
// On Vercel `VARIANT_BIN_DIR` is unset and every request 404s. That is
// correct: the static deployment has no business distributing firmware.
//
// **Nothing on the shelf uses this today.** Official firmwares are built
// here and their images sit under /flash/bin, which already sends CORS. This
// exists for the community tier — somebody else's build, vouched for and
// served from the Pi so it can be installed in one click like the rest. It
// is not dead code; it is an empty shelf slot with the plumbing already in.
export const dynamic = "force-dynamic";
const CORS = {
// The board's page is the caller and its origin is whatever the panel is
// named on that LAN. There is no list to allow.
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
};
function resolveSafe(parts: string[]): string | null {
const root = process.env.VARIANT_BIN_DIR;
if (!root) return null;
// Join, then confirm the result is still inside root. `..` in a path
// segment is the obvious attack and normalize() is what catches it.
const full = path.normalize(path.join(root, ...parts));
const rootAbs = path.resolve(root);
if (!path.resolve(full).startsWith(rootAbs + path.sep)) return null;
return full;
}
export function OPTIONS() {
return new Response(null, { status: 204, headers: CORS });
}
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ path: string[] }> },
) {
const { path: parts } = await ctx.params;
const file = resolveSafe(parts ?? []);
// `file` lives under VARIANT_BIN_DIR, outside the checkout, so there is
// nothing here for the build's file tracer to find — and left to guess, it
// decides a fully dynamic path could be any file in the project and packs
// the whole tree, public/ included, into this route's function ("Dynamic
// filesystem access causes tracing of the whole project"). The
// turbopackIgnore comments opt these four calls out of that.
if (
!file ||
!existsSync(/*turbopackIgnore: true*/ file) ||
!statSync(/*turbopackIgnore: true*/ file).isFile()
) {
return new Response("not found", { status: 404, headers: CORS });
}
const size = statSync(/*turbopackIgnore: true*/ file).size;
const stream = Readable.toWeb(
createReadStream(/*turbopackIgnore: true*/ file),
) as unknown as ReadableStream;
return new Response(stream, {
headers: {
...CORS,
"Content-Type": "application/octet-stream",
"Content-Length": String(size),
// A hosted image is replaced in place when the version it stands for
// changes, so it must not be cached as immutable the way core's
// version-stamped bins are.
"Cache-Control": "public, max-age=300",
},
});
}