fuzzyworld

fuzzyworld-tools / files / lib / admin.py

admin.py

Blame: the commit that last changed each line · file · history

85702b14 2026-11-04 Pavel Safronov1#!/usr/bin/env python3
258a9da8 2026-11-06 Pavel Safronov2"""fw admin: a LOCAL web page for the server's reports (bug-report issues), blog comments and deploy state. Python standard library only.
85702b14 2026-11-04 Pavel Safronov3
4 fw admin [--port N] [--no-open] [--code-dir DIR]
5
6Starts a small server on 127.0.0.1 (never another address), prints a one-time login URL and opens it. Every page is
7server-rendered HTML; every button is a POST form; there is NO JavaScript. Each action runs the matching `~/bin/fw ...`
8command on the server over ssh (host from FW_HOST, default `dh`), exactly what you would type. The panel holds no state
9of its own and never shows a password. Mail text never goes into a command line: only a validated repo name, issue
10number, comment id and a fixed set of action words.
11
12Access. The login URL carries a random key that works once and then sets a session cookie (HttpOnly, SameSite=Strict).
13Every request needs that cookie and Host 127.0.0.1:<port> (DNS rebinding); every POST also needs the random token in its
14form, and an Origin / Sec-Fetch-Site that says "same site". Destructive actions (hide, reject, untrust, remove, a mode
15change) ask first.
16
17"Prepare dev replay" needs the app's working copy: <code dir>/<repo>/package.json with a "report-replay" script
18(FW_CODE_DIR or --code-dir, default ~/code). It saves the report's run record to a temporary folder and runs
19`npm run --silent report-replay -- <record.json> [--rules <commit>]` there.
20"""
21
22import argparse
23import hmac
24import html
25import http.cookies
26import json
27import os
28import re
29import secrets
30import shlex
31import subprocess
32import sys
33import tempfile
34import webbrowser
35from datetime import datetime, timezone
36import threading
37from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38from urllib.parse import parse_qs, quote, urlsplit
39
40HOST_RE = re.compile(r"^[A-Za-z0-9._-]+\Z") # \Z, not $: "$" also matches before a trailing newline
41SAFE_ARG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z")
42FLAG_ARG = re.compile(r"^--(json|trust)\Z")
43REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z") # as server/issues.py check_repo
44ID_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{6}\Z") # as server/comments.py
45COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}\Z")
46REPLAY_URL_RE = re.compile(r"http://localhost:5173/\?replay=[A-Za-z0-9._~%&=/+:,-]+")
47RESERVED_REPOS = {"list", "show", "record", "approve", "hide", "close", "reopen", "publish", "publish-all", "ingest",
48 "imap-check", "help"} # `fw issues <word>` would run that command instead of setting a mode
49MODES = ("public", "reviewed", "private")
50REPORT_ACTIONS = ("approve", "hide", "close", "reopen", "replay")
51COMMENT_ACTIONS = ("approve", "trust", "reject", "untrust", "remove")
52CONFIRM = {"hide", "reject", "untrust", "remove"} # these ask first; so does every mode change
53MAX_BODY = 32 * 1024
54COOKIE = "fwadmin" # the cookie is named fwadmin-<port>, so two instances (or another local server) do not clash
55HEADERS = {
56 "Content-Security-Policy": "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
57 "X-Content-Type-Options": "nosniff",
58 "Referrer-Policy": "no-referrer",
59 "Cache-Control": "no-store",
60}
61
62
63class FwError(Exception):
64 def __init__(self, message, code=None):
65 super().__init__(message)
66 self.code = code
67
68
69# ---- running fw on the server ----
70
71class SshRunner:
72 """Runs `~/bin/fw <args>` over ssh. The arguments are checked first: nothing odd is ever sent."""
73
74 def __init__(self, host, timeout=60):
75 if not (HOST_RE.match(host) and not host.startswith("-")):
76 raise FwError(f"invalid FW_HOST: {host!r}")
77 self.host, self.timeout = host, timeout
78
79 def run(self, args):
80 for a in args:
81 if not (isinstance(a, str) and (SAFE_ARG.match(a) or FLAG_ARG.match(a))):
82 raise FwError(f"refused unsafe argument {a!r}")
83 remote = "~/bin/fw " + " ".join(shlex.quote(a) for a in args)
84 try:
85 p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", self.host, remote],
86 stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=self.timeout)
87 except subprocess.TimeoutExpired:
88 raise FwError(f"timed out after {self.timeout} s")
89 except OSError as e:
90 raise FwError(f"could not run ssh: {e}")
91 if p.returncode == 255:
92 raise FwError(f"could not reach {self.host} (try `ssh {self.host} true`)", 255)
93 if p.returncode != 0:
94 raise FwError(p.stderr.strip() or f"fw exited with code {p.returncode}", p.returncode)
95 return p.stdout
96
97 def json(self, args):
98 out = self.run(args)
99 try:
100 data = json.loads(out)
101 except ValueError:
102 raise FwError("the server did not answer with JSON (is `fw` on the server up to date?)")
103 if not isinstance(data, dict):
104 raise FwError("the server answered with JSON of the wrong shape")
105 return data
106
107
108# ---- checks on what the browser sends ----
109
110def valid_repo(repo):
111 return bool(REPO_RE.match(repo)) and not repo.endswith(".git")
112
113
114def valid_n(n):
115 return n.isascii() and n.isdigit() and 0 < int(n) < 10**9
116
117
118def esc(value):
119 return html.escape(str(value), quote=True)
120
121
122def https(url):
123 """Links from the server are shown only when they are https://."""
124 return isinstance(url, str) and url.startswith("https://")
125
126
127def ext_link(url, text):
128 return f'<a href="{esc(url)}" rel="noreferrer noopener">{esc(text)}</a>' if https(url) else esc(text)
129
130
131# ---- look: copied by hand from server/site/style.css (this file ships without server/) ----
132
133CSS = """
134:root { color-scheme: light dark; --bg: #fff; --fg: #1f2328; --muted: #656d76; --line: #d0d7de; --soft: #f6f8fa;
135 --link: #0969da; --ok: #1a7f37; --ok-bg: #dafbe1; --bad: #cf222e; --bad-bg: #ffebe9; --warn: #9a6700; --warn-bg: #fff8c5; }
136@media (prefers-color-scheme: dark) {
137 :root { --bg: #0d1117; --fg: #e6edf3; --muted: #8d96a0; --line: #30363d; --soft: #161b22; --link: #4493f8;
138 --ok: #3fb950; --ok-bg: #12261e; --bad: #f85149; --bad-bg: #2d1214; --warn: #d29922; --warn-bg: #272115; }
139}
140body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, sans-serif; }
141main { max-width: 64rem; margin: 0 auto; padding: 0 1rem 3rem; }
142a { color: var(--link); text-decoration: none; }
143a:hover { text-decoration: underline; }
144nav.top { background: var(--soft); border-bottom: 1px solid var(--line); padding: .6rem 1rem; }
145nav.top a { margin-right: 1rem; color: var(--muted); }
146nav.top a[aria-current] { color: var(--fg); font-weight: 600; }
147h1 { font-size: 1.5rem; }
148h2 { font-size: 1.15rem; margin-top: 2rem; }
149table { border-collapse: collapse; width: 100%; }
150th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
151th { color: var(--muted); font-weight: normal; font-size: .85rem; }
152.badge { display: inline-block; padding: .1rem .5rem; background: var(--soft); color: var(--muted); border: 1px solid var(--line);
153 border-radius: 999px; font-size: .75rem; }
154.badge-success { background: var(--ok-bg); color: var(--ok); border-color: var(--ok); }
155.badge-error { background: var(--bad-bg); color: var(--bad); border-color: var(--bad); }
156.badge-warning { background: var(--warn-bg); color: var(--warn); border-color: var(--warn); }
157.meta { color: var(--muted); font-size: .9rem; }
158pre.issue-text { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; box-sizing: border-box; overflow-x: hidden;
159 background: var(--soft); border: 1px solid var(--line); border-radius: 6px; padding: .6rem .8rem; font-size: .9rem; }
160table.issue-details { width: auto; max-width: 100%; font-size: .8rem; margin: .5rem 0; }
161table.issue-details th { padding-right: 1rem; }
162.card { border: 1px solid var(--line); border-radius: 6px; padding: .2rem 1rem 1rem; margin: 1rem 0; }
163.actions form, form.inline { display: inline-block; margin: 0 .4rem .4rem 0; }
164button { font: inherit; padding: .25rem .8rem; border: 1px solid var(--line); border-radius: 6px; background: var(--soft);
165 color: var(--fg); cursor: pointer; }
166button.danger { color: var(--bad); border-color: var(--bad); }
167.flash { border: 1px solid var(--ok); background: var(--ok-bg); border-radius: 6px; padding: .4rem .8rem; margin: 1rem 0;
168 white-space: pre-wrap; overflow-wrap: anywhere; }
169.flash.err { border-color: var(--bad); background: var(--bad-bg); }
170.filters a { margin-right: 1rem; }
171.filters a[aria-current] { color: var(--fg); font-weight: 600; }
172.pager { display: flex; justify-content: space-between; margin-top: 1.5rem; }
173label.mode { margin-right: 1rem; }
174"""
175
176
177def age(iso):
178 """"3 d" / "5 h" / "12 min" since an ISO date; "" when it cannot be read."""
179 try:
180 then = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
181 except (ValueError, TypeError):
182 return ""
183 s = max(0, int((datetime.now(timezone.utc) - then).total_seconds()))
184 return f"{s // 86400} d" if s >= 86400 else f"{s // 3600} h" if s >= 3600 else f"{s // 60} min"
185
186
187def badge(state):
188 return f'<span class="badge{" badge-success" if state == "open" else ""}">{esc(state)}</span>'
189
190
191def yes(flag):
192 return "yes" if flag else "no"
193
194
258a9da8 2026-11-06 Pavel Safronov195STATE_BADGES = {"current": ("up to date", " badge-success"), "pending": ("pending", " badge-warning"),
196 "never": ("never deployed", ""), "unknown": ("unknown", "")}
197
198
199def state_badge(state):
200 label, cls = STATE_BADGES.get(state, (str(state), ""))
201 return f'<span class="badge{cls}">{esc(label)}</span>'
202
203
204def commit_cell(commit):
205 """The deployed commit as shown: "abc1234", "abc1234+uncommitted", or a dash."""
206 return f"<code>{esc(commit)}</code>" if commit else "&ndash;"
207
208
209def behind_cell(behind, state):
210 """The commits since the deployed one: none, a short list, or why the answer is unknown."""
211 if state == "never":
212 return ""
213 if behind is None:
ab35fc22 2026-11-07 Pavel Safronov214 return '<span class="meta">not on the server (unpushed commit, or unknown repo)</span>'
258a9da8 2026-11-06 Pavel Safronov215 if not behind["count"]:
216 return '<span class="meta">none</span>'
217 items = "<br>".join(f"<code>{esc(sha)}</code> {esc(subject)}" for sha, subject in behind["commits"])
218 more = behind["count"] - len(behind["commits"])
219 return items + (f'<br><span class="meta">and {int(more)} more</span>' if more > 0 else "")
220
221
85702b14 2026-11-04 Pavel Safronov222# ---- the server ----
223
224class AdminServer(ThreadingHTTPServer):
225 daemon_threads = True
226
227 def __init__(self, runner, port=0, code_dir=None):
228 super().__init__(("127.0.0.1", port), Handler)
229 self.runner = runner
230 self.code_dir = code_dir or os.environ.get("FW_CODE_DIR") or os.path.expanduser("~/code")
231 self.session = secrets.token_urlsafe(32) # the cookie's value
232 self.csrf = secrets.token_urlsafe(32) # the token in every form: a different secret from the cookie
233 self.lock = threading.Lock() # one request is handled at a time; an idle socket cannot stall the others
234 self.login_key = secrets.token_urlsafe(32) # works once
235 self.flash = [] # (ok, html) shown on the next page and then dropped
236
237 @property
238 def port(self):
239 return self.server_address[1]
240
241 def login_url(self):
242 return f"http://127.0.0.1:{self.port}/login?k={self.login_key}"
243
244
245def make_server(runner, port=0, code_dir=None):
246 return AdminServer(runner, port, code_dir)
247
248
249def same(a, b):
250 return hmac.compare_digest(str(a).encode(), str(b).encode())
251
252
253class Handler(BaseHTTPRequestHandler):
254 timeout = 15 # an idle connection gives up after this long (it holds only its own thread)
255
256 # never log query strings (the login key sits in one)
257 def log_message(self, *args):
258 pass
259
260 def log_request(self, code="-", size="-"):
261 sys.stderr.write(f"{self.command} {urlsplit(self.path).path} {code}\n")
262
263 # -- sending --
264
265 def send(self, status, body="", ctype="text/html; charset=utf-8", headers=()):
266 data = body.encode("utf-8")
267 self.send_response(status)
268 self.send_header("Content-Type", ctype)
269 self.send_header("Content-Length", str(len(data)))
270 for k, v in (*HEADERS.items(), *headers):
271 self.send_header(k, v)
272 self.end_headers()
273 if self.command != "HEAD":
274 self.wfile.write(data)
275
276 def redirect(self, where, headers=()):
277 self.send(303, "", headers=(("Location", where), *headers))
278
279 def text(self, status, message):
280 self.send(status, self.page("fw admin", f"<h1>{esc(message)}</h1>", nav=False))
281
282 def flash_error(self, e):
283 self.server.flash.append((False, esc(e)))
284
285 # -- access --
286
287 def check_host(self):
288 if self.headers.get("Host") != f"127.0.0.1:{self.server.port}":
289 self.text(421, "Wrong host")
290 return False
291 return True
292
293 def has_session(self):
294 jar = http.cookies.SimpleCookie()
295 try:
296 jar.load(self.headers.get("Cookie", ""))
297 except http.cookies.CookieError:
298 return False
299 morsel = jar.get(f"{COOKIE}-{self.server.port}")
300 return bool(morsel) and same(morsel.value, self.server.session)
301
302 def form(self):
303 """The POST body as {name: value}, after the CSRF checks; None when the request was refused."""
304 origin, site = self.headers.get("Origin"), self.headers.get("Sec-Fetch-Site")
305 if origin is not None and origin != f"http://127.0.0.1:{self.server.port}":
306 self.text(403, "Forbidden")
307 return None
308 if site is not None and site not in ("same-origin", "none"):
309 self.text(403, "Forbidden")
310 return None
311 if self.body is None:
312 self.text(400, "Bad request")
313 return None
314 fields = {k: v[0] for k, v in parse_qs(self.body.decode("utf-8", "replace"), keep_blank_values=True).items()}
315 if not same(fields.get("token", ""), self.server.csrf):
316 self.text(403, "Forbidden")
317 return None
318 return fields
319
320 # -- pages --
321
322 def page(self, title, body, nav=True, current=""):
323 mark = " aria-current=page"
324 links = "".join(f'<a href="/{p}"{mark if current == p else ""}>{t}</a>'
258a9da8 2026-11-06 Pavel Safronov325 for p, t in (("reports", "Reports"), ("comments", "Comments"), ("deploy", "Deploy")))
85702b14 2026-11-04 Pavel Safronov326 flash = ""
327 if nav and self.server.flash:
328 flash = "".join(f'<div class="flash{"" if ok else " err"}">{h}</div>' for ok, h in self.server.flash)
329 self.server.flash = []
330 return ('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>fw admin</title>'
331 '<meta name="viewport" content="width=device-width, initial-scale=1">'
332 '<link rel="stylesheet" href="/style.css"></head><body>'
333 f'{f"<nav class=top>{links}</nav>" if nav else ""}<main>{flash}{body}</main></body></html>')
334
335 def button(self, action_path, fields, label, danger=False):
336 hidden = "".join(f'<input type="hidden" name="{esc(k)}" value="{esc(v)}">' for k, v in fields.items())
337 return (f'<form method="post" action="{esc(action_path)}"><input type="hidden" name="token" value="{esc(self.server.csrf)}">'
338 f'{hidden}<button{" class=danger" if danger else ""}>{esc(label)}</button></form>')
339
340 def confirm_page(self, action_path, fields, question, back):
341 form = self.button(action_path, {**fields, "confirm": "1"}, "Yes, do it", danger=True)
342 return self.send(200, self.page("fw admin", f'<h1>{esc(question)}</h1><div class="actions">{form}'
343 f'<a href="{esc(back)}">Cancel</a></div>'))
344
345 # -- GET --
346
347 def do_GET(self):
348 with self.server.lock:
349 self.handle_get()
350
351 def handle_get(self):
352 if not self.check_host():
353 return
354 url = urlsplit(self.path)
355 parts = url.path.split("/")
356 if url.path == "/login":
357 return self.login(parse_qs(url.query).get("k", [""])[0])
358 if not self.has_session():
359 return self.text(403, "Forbidden")
360 try:
361 if url.path == "/style.css":
362 return self.send(200, CSS, "text/css; charset=utf-8")
363 if url.path == "/":
364 return self.redirect("/reports")
365 if url.path == "/reports":
366 return self.reports(parse_qs(url.query))
367 if url.path == "/comments":
368 return self.comments_page()
258a9da8 2026-11-06 Pavel Safronov369 if url.path == "/deploy":
370 return self.deploy_page()
85702b14 2026-11-04 Pavel Safronov371 if len(parts) in (4, 5) and parts[1] == "reports" and (len(parts) == 4 or parts[4] == "record.json"):
372 if not (valid_repo(parts[2]) and valid_n(parts[3])):
373 return self.text(400, "Bad request")
374 if len(parts) == 4:
375 return self.report_page(parts[2], parts[3])
376 return self.download(parts[2], parts[3])
377 except FwError as e:
378 return self.send(502, self.page("fw admin", f'<div class="flash err">{esc(e)}</div>'))
379 except (KeyError, TypeError, ValueError, AttributeError):
380 return self.send(502, self.page("fw admin", '<div class="flash err">The server answered in an unexpected shape.</div>'))
381 self.text(404, "Not found")
382
383 def login(self, key):
384 server = self.server
385 if server.login_key is None or not same(key, server.login_key):
386 return self.text(403, "Forbidden")
387 server.login_key = None # single use
388 self.redirect("/reports", headers=(("Set-Cookie", f"{COOKIE}-{server.port}={server.session}; HttpOnly; SameSite=Strict; Path=/"),))
389
390 def reports(self, query):
391 show, repo = query.get("show", [""])[0], query.get("repo", [""])[0]
392 if show not in ("", "waiting", "replay", "all") or (repo and not valid_repo(repo)):
393 return self.text(400, "Bad request")
394 data = self.server.runner.json(["issues", "list", "--json"])
395 rows = data["issues"]
396 repos = data.get("repos", {})
397 if repo:
398 rows = [i for i in rows if i["repo"] == repo]
399 show = show or ("waiting" if any(i["waiting"] for i in rows) else "all")
400 shown = [i for i in rows if show == "all" or (show == "waiting" and i["waiting"]) or (show == "replay" and i["record"])]
401 shown.sort(key=lambda i: (i["updated"], i["repo"], i["n"]), reverse=True)
402 suffix = f"&repo={quote(repo)}" if repo else ""
403 filters = "".join(f'<a href="/reports?show={k}{suffix}"{" aria-current=page" if show == k else ""}>{t}</a>'
404 for k, t in (("waiting", "Waiting"), ("replay", "Has replay"), ("all", "All")))
405 body = [f'<h1>Reports{f" in {esc(repo)}" if repo else ""}</h1><p class="filters">{filters}</p>']
406 if shown:
407 trs = []
408 for i in shown:
409 link = f'/reports/{quote(i["repo"])}/{int(i["n"])}'
410 wait = ' <span class="badge badge-warning">waiting</span>' if i["waiting"] else ""
411 trs.append(f'<tr><td><a href="/reports?show={show}&amp;repo={quote(i["repo"])}">{esc(i["repo"])}</a></td>'
412 f'<td><a href="{link}">#{int(i["n"])}</a></td><td><a href="{link}">{esc(i["title"])}</a>{wait}</td>'
413 f'<td>{badge(i["state"])}</td><td>{yes(i["public"])}</td>'
414 f'<td>{esc(repos.get(i["repo"], {}).get("mode", ""))}</td><td>{yes(i["record"])}</td>'
415 f'<td>{int(i["shown"])}/{int(i["messages"])}</td><td>{esc(age(i["created"]))}</td></tr>')
416 body.append("<table><tr><th>Repo</th><th>#</th><th>Title</th><th>State</th><th>Public</th><th>Mode</th>"
417 f'<th>Record</th><th>Shown</th><th>Age</th></tr>{"".join(trs)}</table>')
418 else:
419 body.append("<p>Nothing here.</p>")
420 self.send(200, self.page("fw admin", "".join(body), current="reports"))
421
422 def replay_available(self, repo):
423 try:
424 with open(os.path.join(self.server.code_dir, repo, "package.json")) as f:
425 pkg = json.load(f)
426 return isinstance(pkg, dict) and isinstance(pkg.get("scripts"), dict) and "report-replay" in pkg["scripts"]
427 except (OSError, ValueError):
428 return False
429
430 def report_page(self, repo, n):
431 d = self.server.runner.json(["issues", "show", repo, n, "--json"])
432 path = f"/reports/{quote(repo)}/{int(n)}"
433 meta = [f'{esc(repo)}', f'opened {esc(str(d["created"])[:10])}', f'mode {esc(d["mode"])}',
434 f'page public: {yes(d["public"])}' + (f' ({ext_link(d["page_url"], "open")})' if d.get("page_url") else "")]
435 out = [f'<h1>#{int(n)} {esc(d["title"])} {badge(d["state"])}</h1><p class="meta">{" · ".join(meta)}</p>']
436 if d.get("details"):
437 out.append('<table class="issue-details">' + "".join(f"<tr><th>{esc(k)}</th><td>{esc(v)}</td></tr>" for k, v in d["details"]) + "</table>")
438 for m in d["messages"]:
439 out.append(f'<div class="card"><p class="meta">{esc(m["label"])} · {esc(m["date"])} · authenticated: {yes(m["authenticated"])}'
440 f' · {"shown" if m["shown"] else "not shown"}</p><pre class="issue-text">{esc(m["text"])}</pre></div>')
441 toggle = ("reopen", "Reopen") if d["state"] == "closed" else ("close", "Close")
442 buttons = [self.button(path, {"action": "approve"}, "Approve"), self.button(path, {"action": "hide"}, "Hide", danger=True),
443 self.button(path, {"action": toggle[0]}, toggle[1])]
444 out.append(f'<h2>Actions</h2><div class="actions">{"".join(buttons)}</div>')
445 radios = "".join(f'<label class="mode"><input type="radio" name="mode" value="{m}"{" checked" if m == d["mode"] else ""}> {m}</label>'
446 for m in MODES)
447 out.append(f'<form method="post" action="/repos/{quote(repo)}/mode"><input type="hidden" name="token" value="{esc(self.server.csrf)}">'
448 f'<input type="hidden" name="n" value="{int(n)}"><p>Who sees {esc(repo)}\'s reports: {radios} <button>Set</button></p></form>')
449 watch = []
450 if d.get("watch_url"):
451 watch.append(ext_link(d["watch_url"], "Watch what happened"))
452 if d.get("record"):
453 watch.append(f'<a href="{path}/record.json">Download record</a>')
454 if self.replay_available(repo):
455 watch.append(self.button(path, {"action": "replay"}, "Prepare dev replay"))
456 out.append(f'<h2>Watch</h2><div class="actions">{" ".join(watch) if watch else "<p>This report has no run record.</p>"}</div>')
457 nav = (f'<a href="/reports/{quote(repo)}/{int(d["prev"])}">← #{int(d["prev"])}</a>' if d.get("prev") else "<span></span>") + \
458 (f'<a href="/reports/{quote(repo)}/{int(d["next"])}">#{int(d["next"])} →</a>' if d.get("next") else "<span></span>")
459 out.append(f'<p class="pager">{nav}</p>')
460 self.send(200, self.page("fw admin", "".join(out), current="reports"))
461
462 def download(self, repo, n):
463 try:
464 record = self.server.runner.run(["issues", "record", repo, n])
465 except FwError as e:
466 self.flash_error(e)
467 return self.redirect(f"/reports/{quote(repo)}/{int(n)}")
468 self.send(200, record, "application/json", headers=(("Content-Disposition", f'attachment; filename="{repo}-{int(n)}-record.json"'),))
469
470 def comments_page(self):
471 data = self.server.runner.json(["comments", "list", "--json"])
472 out = ["<h1>Comments</h1>"]
473 if not data["pending"]:
474 out.append("<p>No pending comments.</p>")
475 for c in data["pending"]:
476 buttons = [self.button("/comments", {"id": c["id"], "action": "approve"}, "Approve")]
477 if c["source"] == "email":
478 buttons.append(self.button("/comments", {"id": c["id"], "action": "trust"}, "Approve + trust"))
479 buttons.append(self.button("/comments", {"id": c["id"], "action": "reject"}, "Reject", danger=True))
480 if c["trusted"]:
481 buttons.append(self.button("/comments", {"id": c["id"], "action": "untrust"}, "Untrust", danger=True))
482 out.append(f'<div class="card"><p class="meta">{self.post_link(c)} · {esc(c["author"])} · {esc(c["date"])} · {esc(c["source"])}'
483 f'{" · trusted sender" if c["trusted"] else ""}</p><pre class="issue-text">{esc(c["text"])}</pre>'
484 f'<div class="actions">{"".join(buttons)}</div></div>')
485 out.append("<h2>Recently approved</h2>")
486 if not data["approved"]:
487 out.append("<p>None.</p>")
488 for c in data["approved"]:
489 out.append(f'<div class="card"><p class="meta">{self.post_link(c)} · {esc(c["author"])} · {esc(c["date"])}</p>'
490 f'<pre class="issue-text">{esc(c["text"])}</pre><div class="actions">'
491 f'{self.button("/comments", {"id": c["id"], "action": "remove"}, "Remove", danger=True)}</div></div>')
492 self.send(200, self.page("fw admin", "".join(out), current="comments"))
493
258a9da8 2026-11-06 Pavel Safronov494 def deploy_page(self):
495 data = self.server.runner.json(["deploys", "--json"])
496 out = ['<h1>Deploy</h1><p class="meta">What is deployed where, against what is on the server\'s default branch. '
497 'Read-only: deploy from the laptop with <code>npm run deploy</code>.</p>']
498 for r in data["repos"]:
499 rows = []
500 for t in r["targets"]:
501 for e in t["entries"]:
502 name = esc(t["target"]) + (f' <span class="meta">on {esc(e["machine"])}</span>' if e["machine"] else "")
503 rows.append(f'<tr><td>{name}<br><span class="meta">{esc(t["description"])}</span></td>'
504 f'<td>{state_badge(e["state"])}</td><td>{commit_cell(e["commit"])}</td><td>{esc(e["at"])}</td>'
505 f'<td>{behind_cell(e["behind"], e["state"])}</td></tr>')
506 out.append(f'<h2>{esc(r["repo"])} <span class="meta">(default branch at {esc(r["head"])})</span></h2>'
ab35fc22 2026-11-07 Pavel Safronov507 '<table><tr><th>Target</th><th>State</th><th>Deployed from</th><th>When (laptop time)</th>'
258a9da8 2026-11-06 Pavel Safronov508 f'<th>Changes since</th></tr>{"".join(rows)}</table>')
509 out.append("<h2>Apps</h2>")
510 if data["apps"]:
511 apps = sorted(data["apps"], key=lambda a: (a["state"] != "pending", a["app"]))
512 rows = [f'<tr><td><a href="https://apps.fuzzyworld.net/{quote(a["app"])}/" rel="noreferrer noopener">{esc(a["title"])}</a></td>'
513 f'<td>{esc(a["repo"])}</td><td>{state_badge(a["state"])}</td><td>{commit_cell(a["commit"])}</td>'
514 f'<td>{esc(a["deployed"][:16].replace("T", " "))}</td><td>{behind_cell(a["behind"], a["state"])}</td></tr>'
515 for a in apps]
516 out.append('<table><tr><th>App</th><th>Repo</th><th>State</th><th>Deployed from</th><th>When (UTC)</th>'
517 f'<th>Commits since</th></tr>{"".join(rows)}</table>')
518 else:
519 out.append("<p>No deployed apps.</p>")
520 self.send(200, self.page("fw admin", "".join(out), current="deploy"))
521
85702b14 2026-11-04 Pavel Safronov522 @staticmethod
523 def post_link(c):
524 label = f'{c["site"]}:{c["path"]}'
525 return ext_link(c.get("post_url"), label)
526
527 # -- POST --
528
529 def do_POST(self):
530 try:
531 length = int(self.headers.get("Content-Length") or 0)
532 except ValueError:
533 length = -1
534 self.body = self.rfile.read(length) if 0 <= length <= MAX_BODY else None # read before the lock: a client that stalls here stalls only itself
535 with self.server.lock:
536 try:
537 self.handle_post()
538 except Exception as e: # a failure of any kind still ends in a page, not a dropped connection
539 self.server.flash.append((False, esc(f"unexpected error: {type(e).__name__}")))
540 self.redirect("/reports")
541
542 def handle_post(self):
543 if not self.check_host():
544 return
545 if not self.has_session():
546 return self.text(403, "Forbidden")
547 f = self.form()
548 if f is None:
549 return
550 parts = urlsplit(self.path).path.split("/")
551 runner = self.server.runner
552 if len(parts) == 4 and parts[1] == "reports":
553 repo, n, action = parts[2], parts[3], f.get("action", "")
554 if not (valid_repo(repo) and valid_n(n) and action in REPORT_ACTIONS):
555 return self.text(400, "Bad request")
556 back = f"/reports/{quote(repo)}/{int(n)}"
557 if action in CONFIRM and f.get("confirm") != "1":
558 return self.confirm_page(back, {"action": action}, f"Hide {repo}#{n}? It leaves the public site and stays hidden.", back)
559 if action == "replay":
560 self.replay(repo, n)
561 else:
562 self.run_flash(["issues", action, repo, n])
563 return self.redirect(back)
564 if len(parts) == 4 and parts[1] == "repos" and parts[3] == "mode":
565 repo, mode, n = parts[2], f.get("mode", ""), f.get("n", "")
566 if not (valid_repo(repo) and repo not in RESERVED_REPOS and mode in MODES and (n == "" or valid_n(n))):
567 return self.text(400, "Bad request")
568 back = f"/reports/{quote(repo)}/{int(n)}" if n else "/reports"
569 if f.get("confirm") != "1":
570 return self.confirm_page(f"/repos/{quote(repo)}/mode", {"mode": mode, "n": n},
571 f"Set the reports of {repo} to {mode}? This republishes its issue pages.", back)
572 self.run_flash(["issues", repo, mode])
573 return self.redirect(back)
574 if parts == ["", "comments"]:
575 cid, action = f.get("id", ""), f.get("action", "")
576 if not (ID_RE.match(cid) and action in COMMENT_ACTIONS):
577 return self.text(400, "Bad request")
578 if action in CONFIRM and f.get("confirm") != "1":
579 return self.confirm_page("/comments", {"id": cid, "action": action}, f"{action.capitalize()} comment {cid}?", "/comments")
580 args = {"trust": ["comments", "approve", cid, "--trust"]}.get(action, ["comments", action, cid])
581 self.run_flash(args)
582 return self.redirect("/comments")
583 self.text(404, "Not found")
584
585 def run_flash(self, args):
586 try:
587 out = self.server.runner.run(args).strip()
588 self.server.flash.append((True, esc(out or "done")))
589 except FwError as e:
590 self.flash_error(e)
591
592 def replay(self, repo, n):
593 """Save the record to a fresh folder and run the app's `report-replay` script on it (see the module docstring)."""
594 folder = os.path.join(self.server.code_dir, repo)
595 if not self.replay_available(repo):
596 return self.flash_error(f"{folder}/package.json has no \"report-replay\" script")
597 try:
598 record = self.server.runner.run(["issues", "record", repo, n])
599 commit = dict(self.server.runner.json(["issues", "show", repo, n, "--json"]).get("details") or []).get("Commit", "")
600 path = os.path.join(tempfile.mkdtemp(prefix=f"fw-admin-{repo}-{n}-"), "record.json")
601 with open(path, "w") as out:
602 out.write(record)
603 cmd = ["npm", "run", "--silent", "report-replay", "--", path] + (["--rules", commit] if COMMIT_RE.match(commit) else [])
604 p = subprocess.run(cmd, cwd=folder, stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
605 except FwError as e:
606 return self.flash_error(e)
607 except subprocess.TimeoutExpired:
608 return self.flash_error("the replay script timed out after 60 s")
609 except (OSError, ValueError, TypeError) as e:
610 return self.flash_error(f"could not prepare the replay: {e}")
611 text = (p.stdout + p.stderr).strip() or "done"
612 shown, last = "", 0
613 for m in REPLAY_URL_RE.finditer(text): # the dev page's address becomes a link
614 shown += esc(text[last:m.start()]) + f'<a href="{esc(m.group(0))}">{esc(m.group(0))}</a>'
615 last = m.end()
616 shown += esc(text[last:]) + esc(f"\nneeds `npm run dev` running in {repo}")
617 self.server.flash.append((p.returncode == 0, shown))
618
619
620def main(argv):
621 ap = argparse.ArgumentParser(prog="fw admin", description="A local web page for reports and blog comments.")
622 ap.add_argument("--port", type=int, default=0, help="port on 127.0.0.1 (default: any free one)")
623 ap.add_argument("--no-open", action="store_true", help="print the login URL but do not open the browser")
624 ap.add_argument("--code-dir", help="folder with the apps' working copies (default: FW_CODE_DIR or ~/code)")
625 args = ap.parse_args(argv)
626 try:
627 runner = SshRunner(os.environ.get("FW_HOST") or "dh")
628 server = make_server(runner, args.port, args.code_dir)
629 except (FwError, OSError) as e:
630 print(f"fw admin: {e}", file=sys.stderr)
631 return 1
632 url = server.login_url()
633 print(f"fw admin: {url}\n(works once; ctrl-c to stop; server host: {runner.host}; app folders: {server.code_dir})")
634 if not args.no_open:
635 webbrowser.open(url)
636 try:
637 server.serve_forever()
638 except KeyboardInterrupt:
639 pass
640 finally:
641 server.server_close()
642 return 0
643
644
645if __name__ == "__main__":
646 sys.exit(main(sys.argv[1:]))