Переглянути джерело

web: dashboard token in the browser (?token= seed -> localStorage, auth() on all data URLs, 401 prompt; recipes iframe shares it)

Alexander Korolev 1 місяць тому
батько
коміт
d1e9c05c35
5 змінених файлів з 56 додано та 13 видалено
  1. 11 1
      src/report/page.html
  2. 3 3
      src/report/ui_tests.rs
  3. 2 2
      src/web/tests.rs
  4. 39 7
      src/web/ui.html
  5. 1 0
      src/web/ui_js_tests.rs

+ 11 - 1
src/report/page.html

@@ -103,6 +103,16 @@
 <table id="tbl"><thead></thead><tbody></tbody></table>
 <script>
 "use strict";
+// Auth (dashboard embedding): the same-origin dashboard page stores the
+// `sm121 web --token` value in localStorage — the SSE stream needs it as a
+// query param (EventSource cannot set headers). `sm121 report --serve`
+// (standalone) never sets a token, so auth() is a no-op there.
+const TOKEN = (() => {
+  const q = new URLSearchParams(location.search).get("token");
+  if (q) localStorage.setItem("sm121token", q);
+  return localStorage.getItem("sm121token") || "";
+})();
+const auth = (p) => TOKEN ? p + (p.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN) : p;
 let DATA = {{DATA}};
 const COLS = [
   ["name", "Recipe"], ["state", "State"], ["uptime", "Uptime"], ["runs", "Launches"],
@@ -275,7 +285,7 @@ notifBox.addEventListener("click", e => {
 let es = null;
 function connect() {
   if (es) es.close();
-  es = new EventSource("/events");
+  es = new EventSource(auth("/events"));
   es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
   es.addEventListener("error", () => {
     liveEl.textContent = "● off"; liveEl.classList.add("dim");

+ 3 - 3
src/report/ui_tests.rs

@@ -15,7 +15,7 @@ fn page_has_bell_and_notif_panel() {
         "id=\"bellcount\"",
         "id=\"notifbox\"",
         "notifDiff(DATA, rows)",
-        "new EventSource(\"/events\")",
+        "auth(\"/events\")",
         "NOTIFDIFF:START",
     ] {
         assert!(page.contains(needle), "page missing {needle}");
@@ -193,10 +193,10 @@ fn markdown_lists_rows_and_scores() {
 fn html_page_at_rewrites_own_paths() {
     let page = super::html_page_at(&[], "/recipes");
     assert!(
-        page.contains("new EventSource(\"/recipes/events\")"),
+        page.contains("auth(\"/recipes/events\")"),
         "SSE path not rewritten"
     );
-    assert!(!page.contains("new EventSource(\"/events\")"));
+    assert!(!page.contains("auth(\"/events\")"));
     assert!(
         page.contains("href=\"/recipes/r/"),
         "viewer link not rewritten"

+ 2 - 2
src/web/tests.rs

@@ -117,10 +117,10 @@ async fn recipes_panel_is_mounted_under_prefix() {
         .unwrap();
     assert_eq!(sse.status(), 200);
     assert!(
-        page.contains("new EventSource(\"/recipes/events\")"),
+        page.contains("auth(\"/recipes/events\")"),
         "prefix not applied"
     );
-    assert!(!page.contains("new EventSource(\"/events\")"));
+    assert!(!page.contains("auth(\"/events\")"));
     // the dashboard's own routes still resolve
     let ui = reqwest::get(format!("http://{addr}/"))
         .await

+ 39 - 7
src/web/ui.html

@@ -394,6 +394,36 @@
 </div>
 <script>
 "use strict";
+// Auth: the server may gate data endpoints with `--token` (required off
+// loopback). The token travels as `?token=...` (SSE EventSource cannot set
+// headers) — a one-shot URL param is promoted to localStorage, and every
+// data URL is wrapped with auth(). The /recipes iframe is same-origin, so
+// the embedded report page reads the same localStorage.
+const TOKEN = (() => {
+  const q = new URLSearchParams(location.search).get("token");
+  if (q) localStorage.setItem("sm121token", q);
+  return localStorage.getItem("sm121token") || "";
+})();
+const auth = (p) => TOKEN ? p + (p.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN) : p;
+function showTokenPrompt() {
+  const d = document.createElement("div");
+  d.style.cssText = "position:fixed;inset:0;z-index:100;background:var(--shade);display:flex;align-items:center;justify-content:center";
+  const card = document.createElement("div");
+  card.style.cssText = "background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:24px;max-width:360px";
+  card.innerHTML = "<h3 style='margin:0 0 8px'>Dashboard token</h3>" +
+    "<p style='color:var(--muted);font-size:13px'>Data endpoints require the token passed to <code>sm121 web --token</code>. It is kept in this browser only (localStorage).</p>" +
+    "<input id='tok' type='password' placeholder='token' autocomplete='off' style='width:100%;box-sizing:border-box;padding:8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px'>";
+  const btn = document.createElement("button");
+  btn.textContent = "Connect";
+  btn.style.cssText = "margin-top:10px;padding:8px 16px;background:var(--accent);color:#fff;border:0;border-radius:6px;cursor:pointer";
+  const apply = () => { const v = document.getElementById("tok").value.trim(); if (v) { localStorage.setItem("sm121token", v); location.reload(); } };
+  btn.onclick = apply;
+  card.querySelector("input").addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); });
+  card.appendChild(btn);
+  d.appendChild(card);
+  document.body.appendChild(d);
+  document.getElementById("tok").focus();
+}
 const $ = (s) => document.querySelector(s);
 const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
 const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set(), flags: {} };
@@ -783,7 +813,7 @@ function connectSSE() {
   const bounce = (txt) => {
     if (es) es.close();
     sse("re", txt);
-    es = new EventSource("/events");
+    es = new EventSource(auth("/events"));
     // connection is up and a snapshot is already on its way — don't sit on
     // "connecting…" for a whole poll interval
     es.onopen = () => { last = Date.now(); sse("ok", "live"); };
@@ -1646,7 +1676,7 @@ function detachLog(attachId, hide = true) {
   const w = logWins.get(attachId);
   if (!w) return;
   logWins.delete(attachId);
-  if (w.attach) fetch("/api/logs", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
+  if (w.attach) fetch(auth("/api/logs"), { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
   if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
 }
 
@@ -1731,7 +1761,7 @@ async function openRaw(g) {
     if (!kind) return;
     const path = kind === "config" ? "/api/raw/config" : kind === "effective" ? "/api/raw/effective" : "/api/raw/recipe/" + encodeURIComponent(kind.slice("recipe:".length));
     try {
-      const res = await fetch(path);
+      const res = await fetch(auth(path));
       const text = await res.text();
       if (mine !== seq) return; // a newer selection already answered
       if (!res.ok) { note.textContent = "error: " + text; return; }
@@ -1758,8 +1788,9 @@ function dryOf(cluster) {
   return d ? d.checked : false;
 }
 async function api(path, body) {
-  const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
+  const res = await fetch(auth(path), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
   if (!res.ok) {
+    if (res.status === 401) { showTokenPrompt(); throw new Error("unauthorized"); }
     let m = res.status;
     try { m = (await res.json()).error || m; } catch {}
     throw new Error(m);
@@ -1937,7 +1968,8 @@ function initManagedWindows() {
 async function boot() {
   initManagedWindows();
   try {
-    const res = await fetch("/api/state");
+    const res = await fetch(auth("/api/state"));
+    if (res.status === 401) { showTokenPrompt(); }
     if (res.ok) {
       const snap = await res.json();
       onState(snap);
@@ -1951,7 +1983,7 @@ async function boot() {
     }
   } catch {}
   try {
-    const res = await fetch("/api/recipes");
+    const res = await fetch(auth("/api/recipes"));
     if (res.ok) {
       const d = await res.json();
       state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
@@ -1967,7 +1999,7 @@ async function boot() {
   restoreWindow("raw", openRaw);
   setInterval(async () => {
     try {
-      const res = await fetch("/api/state");
+      const res = await fetch(auth("/api/state"));
       if (res.ok) onState(await res.json());
     } catch {}
   }, 30000);

+ 1 - 0
src/web/ui_js_tests.rs

@@ -94,6 +94,7 @@ const handlers = {}, timers = [], sseLog = [];
 global.document = { visibilityState: "visible", addEventListener: (t, f) => { handlers[t] = f; } };
 global.addEventListener = (t, f) => { handlers["w:" + t] = f; };
 global.EventSource = FakeES;
+global.auth = (p) => p; // token wrapper (no-op in the harness)
 global.setTimeout = (f, ms) => { timers.push({ f, ms }); return 0; };
 global.setInterval = (f) => { timers.push({ f, every: true }); return 0; };
 global.$ = () => ({ set className(v) {}, set textContent(v) { sseLog.push(v); } });