feat: add Docker image, static server and GitLab CI

The server is a placeholder for the one in docs/ARCHITECTURE.md — it serves
public/ and a health endpoint, nothing more. Reads are confined to public/ by
resolving the path and checking it stays inside the root; string-matching
".." is not enough, since encodings and URL normalisation both change what
the string looks like and only the resolved path says which file would open.

The image runs typecheck and tests during build, ships no source or
toolchain, and runs unprivileged.

CI's feed job fails if the event count collapses. A source that quietly stops
yielding events is what a parser-only pipeline is most prone to, and nothing
else would surface it. Everything is offline, so a red pipeline always means
the code changed rather than a wiki being down.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:20:42 +02:00
co-authored by Claude Opus 5
parent 71bff72ee9
commit 517066dc65
7 changed files with 379 additions and 4 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* Static file server for the built app.
*
* A placeholder for the Bun server in docs/ARCHITECTURE.md: it serves what is
* in public/ and nothing else. When the real server lands it will add /api/*
* and the ingest scheduler, and this file goes away.
*
* Deliberately minimal — no framework, no dependencies beyond Bun.
*/
import { resolve } from "node:path";
const PORT = Number(process.env.PORT ?? 3000);
const ROOT = "public";
const ROOT_DIR = resolve(ROOT);
/** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */
const CACHE: Record<string, string> = {
".html": "public, max-age=0, must-revalidate",
".json": "public, max-age=300",
".js": "public, max-age=3600",
".css": "public, max-age=3600",
".svg": "public, max-age=86400",
".webmanifest": "public, max-age=3600",
};
function extname(path: string): string {
const i = path.lastIndexOf(".");
return i === -1 ? "" : path.slice(i);
}
const server = Bun.serve({
port: PORT,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
const feed = Bun.file(resolve(ROOT_DIR, "data/events.v1.json"));
const ok = await feed.exists();
return Response.json(
{ status: ok ? "ok" : "no-feed", generatedAt: new Date().toISOString() },
{ status: ok ? 200 : 503 },
);
}
let decoded: string;
try {
decoded = decodeURIComponent(url.pathname);
} catch {
return new Response("Bad request", { status: 400 });
}
if (decoded.includes("\0")) {
return new Response("Bad request", { status: 400 });
}
const path = decoded === "/" ? "/index.html" : decoded;
// Confine every read to public/ by resolving the path and checking it is
// still inside the root. String-matching ".." is not enough: encodings and
// URL normalisation both change what the string looks like, and only the
// resolved path tells the truth about which file would be opened.
const resolved = resolve(ROOT_DIR, `.${path}`);
if (resolved !== ROOT_DIR && !resolved.startsWith(`${ROOT_DIR}/`)) {
return new Response("Bad request", { status: 400 });
}
const file = Bun.file(resolved);
if (await file.exists()) {
return new Response(file, {
headers: {
"cache-control": CACHE[extname(path)] ?? "public, max-age=600",
// The service worker must never be served stale, or a deploy can be
// pinned by an old worker indefinitely.
...(path === "/sw.js"
? { "cache-control": "no-cache", "service-worker-allowed": "/" }
: {}),
},
});
}
// Single-page app: unknown paths fall back to the shell so client routing
// and deep links work. Anything under /data or /api is a genuine 404.
if (!path.startsWith("/data") && !path.startsWith("/api")) {
const shell = Bun.file(resolve(ROOT_DIR, "index.html"));
if (await shell.exists()) {
return new Response(shell, {
headers: { "cache-control": "public, max-age=0, must-revalidate" },
});
}
}
return new Response("Not found", { status: 404 });
},
});
console.log(`Event Clock serving ${ROOT} on http://localhost:${server.port}`);