Merge pull request #15 from horizon-rust/feat/staff-pages

Staff dashboards: /staff sign-in, password setup, sign-out; the API proxy to Kira
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-30 13:38:03 +01:00
commit a184c1cc0e
8 files changed
+361 -4

No files matched your search

+18
View File
@@ -47,6 +47,24 @@
handle /api/mods/* {
reverse_proxy {$REGISTRY_UPSTREAM:127.0.0.1:9}
}
# The staff dashboards' API: Kira's service (src/staff-http.js in horizon-rust/kira), reached only through here,
# with the key both sides hold and the browser's address. Unset, the pages say the dashboards are closed.
handle /staff/api/* {
reverse_proxy {$STAFF_UPSTREAM:http://127.0.0.1:9} {
header_up Host {upstream_hostport}
header_up X-Staff-Proxy-Key "{$STAFF_PROXY_KEY}"
header_up X-Staff-Client-Ip {http.request.header.X-Real-IP}
}
}
# The staff pages (one page, its script picks the view) and the application form: never indexed or cached.
@staff path /staff /staff/ /staff/team /staff/mod /staff/setup
rewrite @staff /staff.html
@private path /staff /staff/* /staff.html
header @private {
X-Robots-Tag "noindex, nofollow"
Cache-Control "no-store"
Referrer-Policy "no-referrer"
}
# The registry: /mods, /mods/submit and /mods/<id> baked at build time, else the list page, whose script shows a project
# published since.
rewrite /mods /mods.html
+5 -3
View File
@@ -286,9 +286,9 @@ await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32]));
// The page's modules, fingerprinted; each import of a shared module is rewritten to its name.
const names = {};
const rewrite = (code) => code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
const rewrite = (code) => code.replace(/"\.\/([\w-]+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
// Dependencies before what imports them.
const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "hub.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js", "hub-page.js", "mods.js", "mods-page.js", "mods-submit.js"];
const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "hub.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js", "hub-page.js", "mods.js", "mods-page.js", "mods-submit.js", "staff-api.js", "staff-page.js"];
for (const file of modules) {
const code = rewrite(await readFile(join(site, "src", file), "utf8"));
names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`;
@@ -412,6 +412,7 @@ const values = {
CONTRIBUTORS_HIDDEN: contributors.length ? "" : " hidden",
BAKED: JSON.stringify(baked).replace(/</g, "\\u003c"),
HUB_JS: `/assets/${names["hub-page.js"]}`,
STAFF_JS: `/assets/${names["staff-page.js"]}`,
HUB_OVERVIEW: overviewHtml(roadmap, hubLinked),
HUB_ROADMAP: roadmapHtml(roadmap, hubLinked),
HUB_AREAS: areaFilterHtml(roadmap),
@@ -521,7 +522,8 @@ const docPages = renderedDocs.map((d, i) => {
};
});
const pages = ["index.html", "404.html", "privacy.html", "releases.html", "benchmarks.html", "hub.html"];
// staff.html: the staff dashboards (/staff, /staff/team, /staff/mod, /staff/setup), never in the sitemap.
const pages = ["index.html", "404.html", "privacy.html", "releases.html", "benchmarks.html", "hub.html", "staff.html"];
const currentPath = { "releases.html": "/releases", "benchmarks.html": "/benchmarks", "hub.html": "/hub" };
for (const page of pages) {
let html = await readFile(join(site, "src", page), "utf8");
+1
View File
@@ -1,4 +1,5 @@
User-agent: *
Allow: /
Disallow: /staff
Sitemap: https://playhorizon.dev/sitemap.xml
+24 -1
View File
@@ -1,6 +1,6 @@
// A dependency-free static server for dist/, for local checks (`npm run dev`).
// Railway uses Caddy instead (see Dockerfile).
import { createServer } from "node:http";
import { createServer, request } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { join, extname, normalize } from "node:path";
import { fileURLToPath } from "node:url";
@@ -9,8 +9,31 @@ const root = join(fileURLToPath(new URL(".", import.meta.url)), "dist");
const types = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".webp": "image/webp", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".json": "application/json", ".xml": "application/xml" };
const port = Number(process.env.PORT) || 4173;
// The staff dashboards' API, as the Caddyfile proxies it: STAFF_UPSTREAM (Kira, or `node scripts/staff-dev.mjs` in
// horizon-rust/kira for fake data) and STAFF_PROXY_KEY. Unset, the pages say the dashboards are closed.
function staffApi(req, res) {
const upstream = process.env.STAFF_UPSTREAM;
if (!upstream) {
res.writeHead(502, { "Content-Type": "application/json" });
return res.end("{}");
}
const url = new URL(req.url, upstream);
const headers = { ...req.headers, host: url.host, "x-staff-proxy-key": process.env.STAFF_PROXY_KEY || "", "x-staff-client-ip": req.socket.remoteAddress };
const out = request(url, { method: req.method, headers }, (up) => {
res.writeHead(up.statusCode, up.headers);
up.pipe(res);
});
out.on("error", () => {
res.writeHead(502, { "Content-Type": "application/json" });
res.end("{}");
});
req.pipe(out);
}
createServer(async (req, res) => {
if (req.url.startsWith("/staff/api/")) return staffApi(req, res);
let path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, "");
if (/^\/staff(\/(team|mod|setup))?\/?$/.test(path)) path = "/staff.html";
if (path === "/docs/api/") {
res.writeHead(301, { Location: "/docs/api" });
return res.end();
+76
View File
@@ -0,0 +1,76 @@
// What the staff pages share: calls to /staff/api (Kira's API, proxied by the Caddyfile), escaping, dates. Every
// string from the API is text a person wrote: it goes into the page only through escape() or textContent.
import { escape } from "./markdown.js";
export { escape };
let csrf = "";
export const setCsrf = (token) => (csrf = token || "");
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// GET or POST /staff/api/<path>; the CSRF token on writes. Throws ApiError with the API's message.
export async function api(method, path, body) {
let res;
try {
res = await fetch(`/staff/api/${path}`, {
method,
credentials: "same-origin",
cache: "no-store",
headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...(method === "POST" && csrf ? { "X-Staff-Csrf": csrf } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError(0, "The dashboards can't be reached right now. Try again in a minute.");
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const closed = res.status === 404 || res.status === 502 || res.status === 503 && !data.error;
throw new ApiError(res.status, data.error && !(res.status === 404 && data.error === "not found") ? data.error : closed ? "The dashboards are closed right now." : `Error ${res.status}.`);
}
return data;
}
const pad = (n) => String(n).padStart(2, "0");
// "30 Oct 14:05" in the reader's time; with the year when it isn't this one.
export function when(value) {
if (!value) return "";
const d = new Date(value);
const month = d.toLocaleString("en", { month: "short" });
return `${d.getDate()} ${month}${d.getFullYear() !== new Date().getFullYear() ? ` ${d.getFullYear()}` : ""} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// "in 5 h", "3 d ago".
export function relative(value, now = Date.now()) {
const ms = new Date(value).getTime() - now;
const abs = Math.abs(ms);
const text = abs < 3_600_000 ? `${Math.max(1, Math.round(abs / 60_000))} min` : abs < 172_800_000 ? `${Math.round(abs / 3_600_000)} h` : `${Math.round(abs / 86_400_000)} d`;
return ms >= 0 ? `in ${text}` : `${text} ago`;
}
// A link people typed: only https on github.com or playhorizon.dev, "#123" (horizon's issue or pull request) or a path
// in horizon's repository become links; anything else is shown as text.
export function linkHtml(link) {
const s = String(link || "");
if (!s) return "";
if (/^#\d+$/.test(s)) return `<a href="https://github.com/horizon-rust/horizon/issues/${s.slice(1)}">${escape(s)}</a>`;
if (/^https:\/\/(github\.com|playhorizon\.dev)\//.test(s)) return `<a href="${escape(s)}" rel="noreferrer">${escape(s.replace(/^https:\/\//, ""))}</a>`;
if (/^[A-Za-z0-9_.\/-]+$/.test(s) && !s.includes("..")) return `<a href="https://github.com/horizon-rust/horizon/blob/main/${escape(s)}">${escape(s)}</a>`;
return escape(s);
}
// Text with line breaks kept, escaped.
export const paragraphs = (text) => escape(String(text || "")).replace(/\n/g, "<br>");
// Moves the .tab-pill of a .tabs group under its selected tab.
export function placePill(tabs) {
const pill = tabs.querySelector(".tab-pill");
const on = tabs.querySelector('[aria-selected="true"], [aria-current="page"]');
if (!pill || !on) return;
pill.style.width = `${on.offsetWidth}px`;
pill.style.height = `${on.offsetHeight}px`;
pill.style.transform = `translate(${on.offsetLeft}px, ${on.offsetTop}px)`;
}
+157
View File
@@ -0,0 +1,157 @@
// The staff pages (/staff, /staff/setup, /staff/team, /staff/mod): signing in, setting one's password with the link
// Kira DMs, signing out, and the dashboard switch. Each dashboard's content is its own module, loaded when opened.
// The session is an HttpOnly cookie this script never sees; the API says who is signed in (GET me).
import { setUpTheme, setUpMenu } from "./chrome.js";
import { api, setCsrf, escape, placePill } from "./staff-api.js";
const NAMES = { team: "Team", mod: "Moderation" };
const TITLES = { team: "Team dashboard", mod: "Moderation dashboard" };
const LOADERS = {
team: null,
mod: null,
};
const $ = (sel) => document.querySelector(sel);
const show = (sel) => {
for (const s of ["[data-staff-login]", "[data-staff-setup]", "[data-staff-app]"]) $(s).hidden = s !== sel;
};
const message = (text) => {
const m = $("[data-staff-message]");
m.textContent = text || "";
m.hidden = !text;
};
const where = () => location.pathname.replace(/\/+$/, "").split("/")[2] || "";
function busy(form, on, text = "") {
form.querySelector("button[type=submit]").disabled = on;
form.querySelector("[data-status]").textContent = text;
}
async function me() {
try {
const who = await api("GET", "me");
setCsrf(who.csrf);
return who;
} catch (e) {
if (e.status === 401) return null;
throw e;
}
}
function login() {
show("[data-staff-login]");
$("[data-staff-title]").textContent = "Staff dashboards";
$("[data-login-form] input[name=username]").focus();
}
async function open(who) {
const page = where();
if (!NAMES[page]) return location.replace(`/staff/${who.dashboards[0]}`);
show("[data-staff-app]");
$("[data-who]").textContent = `Signed in as ${who.name}`;
const nav = $("[data-dashboards]");
nav.innerHTML = `<span class="tab-pill" aria-hidden="true"></span>` + who.dashboards.map((d) => `<a href="/staff/${d}"${d === page ? ' aria-current="page"' : ""}>${escape(NAMES[d])}</a>`).join("");
nav.hidden = who.dashboards.length < 2;
requestAnimationFrame(() => placePill(nav));
$("[data-staff-kicker]").textContent = NAMES[page];
$("[data-staff-title]").textContent = TITLES[page];
document.title = `${TITLES[page]} · Horizon`;
const content = $("[data-staff-content]");
if (!who.dashboards.includes(page)) {
content.innerHTML = `<p class="lede">This dashboard is for the ${page === "team" ? "🌌 Horizon Team" : "🛡️ Mods"}. ${who.dashboards.map((d) => `<a href="/staff/${d}">Open the ${escape(NAMES[d])} dashboard</a>`).join(" or ")}.</p>`;
return;
}
const load = LOADERS[page];
if (!load) {
content.innerHTML = `<p class="lede">You are signed in. This dashboard's content comes in the next update.</p>`;
return;
}
const mod = await load();
await mod.render(content, who);
}
async function route() {
message("");
try {
if (where() === "setup") return setup();
const who = await me();
if (!who) return login();
await open(who);
} catch (e) {
show(null);
message(e.message);
}
}
async function setup() {
show("[data-staff-setup]");
$("[data-staff-title]").textContent = "Your staff password";
// The token is the link's #fragment: never sent to a server in a URL, and taken out of the address bar at once.
const token = sessionStorage.getItem("staff:setup") || location.hash.slice(1);
if (location.hash) {
sessionStorage.setItem("staff:setup", location.hash.slice(1));
history.replaceState(null, "", "/staff/setup");
}
const who = $("[data-setup-who]");
const form = $("[data-setup-form]");
try {
if (!token) throw new Error("This page needs the link Kira DMs you. Run /staff password on Discord to get one.");
const link = await api("POST", "setup/peek", { token });
who.innerHTML = `For <strong>${escape(link.name)}</strong>: the link works once and expires ${escape(new Date(link.expiresAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))}.`;
form.username.value = link.name;
form.hidden = false;
form.password.focus();
} catch (e) {
sessionStorage.removeItem("staff:setup");
who.textContent = e.message;
}
form.onsubmit = async (event) => {
event.preventDefault();
if (form.password.value !== form.again.value) return busy(form, false, "The two passwords differ.");
busy(form, true, "Saving…");
try {
const signed = await api("POST", "setup", { token, password: form.password.value });
sessionStorage.removeItem("staff:setup");
setCsrf(signed.csrf);
form.reset();
location.replace(`/staff/${signed.dashboards[0]}`);
} catch (e) {
busy(form, false, e.message);
}
};
}
function setUpForms() {
const form = $("[data-login-form]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
busy(form, true, "Signing in…");
try {
const who = await api("POST", "login", { name: form.elements.username.value, password: form.password.value });
setCsrf(who.csrf);
form.reset();
busy(form, false);
if (!NAMES[where()]) return location.replace(`/staff/${who.dashboards[0]}`);
await open(who);
} catch (e) {
busy(form, false, e.message);
form.password.select();
}
});
const out = (all) => async () => {
try {
await api("POST", all ? "logout-all" : "logout");
} catch {}
setCsrf("");
$("[data-staff-content]").innerHTML = "";
message(all ? "Signed out on every device." : "Signed out.");
login();
};
$("[data-logout]").addEventListener("click", out(false));
$("[data-logout-all]").addEventListener("click", out(true));
}
setUpTheme();
setUpMenu();
setUpForms();
route();
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Staff · Horizon</title>
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="theme-color" content="#ece7f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f0d17" media="(prefers-color-scheme: dark)">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>{{STYLE}}</style>
<script>document.documentElement.classList.replace("no-js", "js"); try { const t = localStorage.getItem("theme"); if (t === "light" || t === "dark") document.documentElement.dataset.theme = t } catch {}</script>
<script type="module" src="{{STAFF_JS}}"></script>
</head>
<body class="subpage">
<a class="skip" href="#main">Skip to the content</a>
{{SITE_NAV}}
<main id="main" class="section page-main staff">
<div class="container">
<p class="kicker"><span>Staff</span><span data-staff-kicker>Horizon Team and Mods</span></p>
<h1 class="display page-title" data-staff-title>Staff dashboards</h1>
<noscript><p class="lede">The dashboards need JavaScript.</p></noscript>
<p class="lede staff-message" data-staff-message role="status" hidden></p>
<section class="staff-auth" data-staff-login hidden aria-labelledby="h-login">
<h2 class="sr-only" id="h-login">Sign in</h2>
<p class="hub-intro">For the 🌌 Horizon Team and the 🛡️ Mods. Your username is your Discord username. No password yet, or forgot it? Run <code>/staff password</code> on Discord: Kira DMs you a link to set one.</p>
<form class="hub-form staff-form" data-login-form>
<label class="hub-field"><span>Discord username</span><input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required maxlength="64"></label>
<label class="hub-field"><span>Password</span><input name="password" type="password" autocomplete="current-password" required maxlength="200"></label>
<p class="hub-form-foot"><button type="submit" class="button primary">Sign in</button><span class="meta" data-status role="status"></span></p>
</form>
</section>
<section class="staff-auth" data-staff-setup hidden aria-labelledby="h-setup">
<h2 class="sr-only" id="h-setup">Set your password</h2>
<p class="hub-intro" data-setup-who>Checking your link…</p>
<form class="hub-form staff-form" data-setup-form hidden>
<input name="username" autocomplete="username" hidden>
<label class="hub-field"><span>New password (12 characters or more)</span><input name="password" type="password" autocomplete="new-password" required minlength="12" maxlength="200"></label>
<label class="hub-field"><span>The same again</span><input name="again" type="password" autocomplete="new-password" required minlength="12" maxlength="200"></label>
<p class="hub-form-foot"><button type="submit" class="button primary">Set it and sign in</button><span class="meta" data-status role="status"></span></p>
</form>
</section>
<section class="staff-app" data-staff-app hidden>
<div class="staff-bar">
<nav class="tabs staff-switch" aria-label="Dashboards" data-dashboards></nav>
<div class="staff-who">
<span class="meta" data-who></span>
<button type="button" class="button secondary small" data-logout>Sign out</button>
<button type="button" class="button ghost small" data-logout-all>Sign out everywhere</button>
</div>
</div>
<div data-staff-content></div>
</section>
</div>
</main>
{{SITE_FOOTER}}
</body>
</html>
+15
View File
@@ -945,3 +945,18 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
.mod-icon { width: 40px; height: 40px; }
.mod-head { grid-template-columns: 1fr; }
}
/* The staff dashboards (/staff): the sign-in forms, the bar with the dashboard switch and the account. */
.staff-message { margin-bottom: 24px; color: var(--ink); }
.staff-auth { margin-top: 24px; max-width: 34rem; }
.staff-form { max-width: 26rem; }
.staff-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; margin: 8px 0 32px; }
.staff-who { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
.staff-who .meta { margin: 0 8px 0 0; }
.staff-switch { margin-bottom: 0; }
.staff-switch a {
position: relative; z-index: 1; display: inline-flex; align-items: center; height: var(--segment); padding: 0 14px;
color: var(--muted); font: 500 15px / 1 var(--pixel); transition: color var(--quick) var(--ease-quick);
}
.staff-switch a:hover { color: var(--ink); }
.staff-switch a[aria-current="page"] { color: var(--on-face); }