The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
84 lines
3.6 KiB
TypeScript
84 lines
3.6 KiB
TypeScript
// The single server-side gate. Runs for EVERY request to the deployed Bun/Nitro server
|
|
// (pages, the /api proxy, everything) before routing. Unauthenticated requests are
|
|
// redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if
|
|
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
|
|
import {
|
|
defineEventHandler,
|
|
getRequestHeader,
|
|
getRequestURL,
|
|
sendRedirect,
|
|
setResponseHeader,
|
|
setResponseStatus,
|
|
useSession,
|
|
} from "h3";
|
|
import {
|
|
isPublicPath,
|
|
type SessionData,
|
|
sessionConfig,
|
|
sessionEpoch,
|
|
uiPassword,
|
|
} from "../util/auth";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const { pathname } = getRequestURL(event);
|
|
|
|
// Baseline response headers for everything this server emits. Deliberately modest: a plugin's
|
|
// own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
|
|
// worth having would break third-party plugin pages we don't control. What is safe to assert
|
|
// unconditionally still closes the cheap holes:
|
|
// nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
|
|
// frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin)
|
|
// object-src — no Flash/applet embedding anywhere
|
|
// base-uri — a stray <base> can't repoint every relative URL on the page
|
|
// Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
|
|
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
|
|
setResponseHeader(event, "Referrer-Policy", "no-referrer");
|
|
setResponseHeader(
|
|
event,
|
|
"Content-Security-Policy",
|
|
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
|
|
);
|
|
|
|
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
|
|
// added with the update-apply route where CSRF ≈ code execution — design
|
|
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
|
|
// from a page; absent (curl, very old browsers) ⇒ allowed — the console's threat here is
|
|
// a BROWSER being ridden cross-site, and every riding browser sends the header.
|
|
// `same-site` is rejected too: with an IP-address origin, another port on the same box
|
|
// counts as same-site, and nothing on another port has business mutating the console.
|
|
// Applies to public paths as well (login CSRF), before any session logic.
|
|
const method = event.method?.toUpperCase?.() ?? "GET";
|
|
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
const site = getRequestHeader(event, "sec-fetch-site")?.toLowerCase();
|
|
if (site && site !== "same-origin" && site !== "none") {
|
|
setResponseStatus(event, 403);
|
|
return { error: "cross-site request refused" };
|
|
}
|
|
}
|
|
|
|
if (isPublicPath(pathname)) return;
|
|
|
|
// Misconfigured: refuse everything rather than serve open on the LAN.
|
|
if (!uiPassword()) {
|
|
setResponseStatus(event, 503);
|
|
return { error: "auth not configured: set PUNKTFUNK_UI_PASSWORD" };
|
|
}
|
|
|
|
const session = await useSession<SessionData>(event, sessionConfig());
|
|
// The epoch check is what makes logout mean something: a cookie sealed before the last
|
|
// revocation unseals fine but no longer matches, so it is refused like any other bad session.
|
|
if (session.data.authenticated && session.data.epoch === sessionEpoch())
|
|
return; // authenticated — let it through
|
|
|
|
if (pathname.startsWith("/api")) {
|
|
setResponseStatus(event, 401);
|
|
return { error: "unauthorized" };
|
|
}
|
|
// Page navigation → bounce to the login screen, remembering where they were headed.
|
|
return sendRedirect(
|
|
event,
|
|
`/login?next=${encodeURIComponent(pathname)}`,
|
|
302,
|
|
);
|
|
});
|