Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion cloudflare/api-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,24 @@ export default {
return blocked(502, "Bad Gateway");
}

// Allowlist response headers: the worker exists to hide the execute-api
// origin, so nothing it emits (e.g. a Location or debug header naming the
// origin host) may pass through. Content-Encoding is deliberately absent —
// the Workers runtime hands us an already-decoded body.
const out = new Response(resp.body, { status: resp.status, statusText: resp.statusText });
for (const h of [
"content-type",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-expose-headers",
"access-control-max-age",
"vary",
]) {
const v = resp.headers.get(h);
if (v) out.headers.set(h, v);
}
// Live means live: never let the edge cache observations.
const out = new Response(resp.body, resp);
out.headers.set("Cache-Control", "no-store");
return out;
},
Expand Down
3 changes: 3 additions & 0 deletions cloudflare/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export default {
const headers = new Headers(request.headers);
headers.set("X-Origin-Secret", env.ORIGIN_SECRET);
headers.delete("host"); // IMPORTANT: let the Function URL receive its own host
// The site is credential-free: never forward browser credentials to the origin.
headers.delete("cookie");
headers.delete("authorization");

const modified = new Request(url.toString(), { method: request.method, headers });
try {
Expand Down
52 changes: 36 additions & 16 deletions frontend/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@
}

function fetchJson(url) {
return fetch(url, { mode: "cors" }).then(function (r) {
// Timeout guarantees the snapshot fallback runs even if the origin hangs
// (a dead origin fails fast, but a slow one would stall "Loading…" forever).
// 8s covers a cold Lambda + RDS connect on the live path.
var opts = { mode: "cors" };
if (typeof AbortSignal !== "undefined" && AbortSignal.timeout) {
opts.signal = AbortSignal.timeout(8000);
}
return fetch(url, opts).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
Expand All @@ -55,22 +62,35 @@
return payload.observations || payload.data || [];
}

// Two-argument .then keeps the error channels separate: only a FETCH failure
// falls back to the snapshot; an exception thrown by render() (a display bug)
// must not masquerade as "backend offline" — it lands in the trailing catch
// with its own message, and every failure is logged for diagnosis.
setStatus("Loading live data…");
fetchJson(cfg.apiBase + "/series/" + encodeURIComponent(code))
.then(function (p) {
render(observationsOf(p), "live API");
})
.catch(function () {
setStatus("Live API unavailable — trying snapshot fallback…");
return fetchJson(cfg.snapshotBase + "/" + encodeURIComponent(code) + ".json")
.then(function (p) {
render(observationsOf(p), "S3 snapshot (fallback)");
})
.catch(function () {
setStatus(
"Backend is offline. This is an ephemeral demo backend — it is stood up for live demos and torn down after. The security architecture above stands on its own.",
"warn"
);
});
.then(
function (p) {
render(observationsOf(p), "live API");
},
function (err) {
console.warn("Live API fetch failed; trying snapshot fallback:", err);
setStatus("Live API unavailable — trying snapshot fallback…");
return fetchJson(cfg.snapshotBase + "/" + encodeURIComponent(code) + ".json").then(
function (p) {
render(observationsOf(p), "S3 snapshot (fallback)");
},
function (err2) {
console.warn("Snapshot fallback fetch failed:", err2);
setStatus(
"Backend is offline. This is an ephemeral demo backend — it is stood up for live demos and torn down after. The security architecture above stands on its own.",
"warn"
);
}
);
}
)
.catch(function (err) {
console.error("Indicator panel render failed:", err);
setStatus("Could not display the data (see console) — the backend itself is fine.", "warn");
});
})();
13 changes: 11 additions & 2 deletions frontend/assets/md-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
var u = url.trim().replace(/&(amp|lt|gt|quot|#39);/g, function (_, e) {
return { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" }[e];
});
if (/^(https?:\/\/|\/|#|\.\/|\.\.\/|[\w./?=&%-]+\.md)/i.test(u) && !/^\s*javascript:/i.test(u)) {
// Root-relative must not be protocol-relative (//host) or backslash (/\host,
// which browsers normalize to //host) — those navigate off-site.
if (/^(https?:\/\/|\/(?![/\\])|#|\.\/|\.\.\/|[\w./?=&%-]+\.md)/i.test(u) && !/^\s*javascript:/i.test(u)) {
return escapeHtml(u);
}
return "#";
Expand All @@ -42,15 +44,22 @@
codes.push(c);
return "\u0000" + (codes.length - 1) + "\u0000";
});
// Stash the opening <a> tag so the bold/italic passes below cannot rewrite
// hrefs containing * — link TEXT stays in the stream so it still formats.
var links = [];
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, t, u) {
return '<a href="' + safeHref(u) + '">' + t + "</a>";
links.push('<a href="' + safeHref(u) + '">');
return "\u0001" + (links.length - 1) + "\u0001" + t + "</a>";
});
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// Italic without lookbehind (older Safari lacks lookbehind support).
text = text.replace(/(^|[^*\w])\*([^*\s](?:[^*]*?[^*\s])?)\*(?!\w)/g, "$1<em>$2</em>");
text = text.replace(/\u0000(\d+)\u0000/g, function (_, i) {
return "<code>" + codes[+i] + "</code>";
});
text = text.replace(/\u0001(\d+)\u0001/g, function (_, i) {
return links[+i];
});
return text;
}

Expand Down
4 changes: 3 additions & 1 deletion frontend/assets/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
}

function buildSidebar(active) {
var groups = {};
// Null prototype: a group named "__proto__"/"constructor" must be a plain
// key, not a prototype hit (which would crash the sidebar build).
var groups = Object.create(null);
docs.forEach(function (d) {
(groups[d.group] = groups[d.group] || []).push(d);
});
Expand Down