fuzzyworld-tools / files / lib / admin.py
admin.py
85702b14 2026-11-04 Pavel Safronov | 1 | #!/usr/bin/env python3 |
258a9da8 2026-11-06 Pavel Safronov | 2 | """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 Safronov | 3 | |
| 4 | fw admin [--port N] [--no-open] [--code-dir DIR] | |
| 5 | ||
| 6 | Starts a small server on 127.0.0.1 (never another address), prints a one-time login URL and opens it. Every page is | |
| 7 | server-rendered HTML; every button is a POST form; there is NO JavaScript. Each action runs the matching `~/bin/fw ...` | |
| 8 | command on the server over ssh (host from FW_HOST, default `dh`), exactly what you would type. The panel holds no state | |
| 9 | of its own and never shows a password. Mail text never goes into a command line: only a validated repo name, issue | |
| 10 | number, comment id and a fixed set of action words. | |
| 11 | ||
| 12 | Access. The login URL carries a random key that works once and then sets a session cookie (HttpOnly, SameSite=Strict). | |
| 13 | Every request needs that cookie and Host 127.0.0.1:<port> (DNS rebinding); every POST also needs the random token in its | |
| 14 | form, and an Origin / Sec-Fetch-Site that says "same site". Destructive actions (hide, reject, untrust, remove, a mode | |
| 15 | change) 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 | ||
| 22 | import argparse | |
| 23 | import hmac | |
| 24 | import html | |
| 25 | import http.cookies | |
| 26 | import json | |
| 27 | import os | |
| 28 | import re | |
| 29 | import secrets | |
| 30 | import shlex | |
| 31 | import subprocess | |
| 32 | import sys | |
| 33 | import tempfile | |
| 34 | import webbrowser | |
| 35 | from datetime import datetime, timezone | |
| 36 | import threading | |
| 37 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 38 | from urllib.parse import parse_qs, quote, urlsplit | |
| 39 | ||
| 40 | HOST_RE = re.compile(r"^[A-Za-z0-9._-]+\Z") # \Z, not $: "$" also matches before a trailing newline | |
| 41 | SAFE_ARG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z") | |
| 42 | FLAG_ARG = re.compile(r"^--(json|trust)\Z") | |
| 43 | REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z") # as server/issues.py check_repo | |
| 44 | ID_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{6}\Z") # as server/comments.py | |
| 45 | COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}\Z") | |
| 46 | REPLAY_URL_RE = re.compile(r"http://localhost:5173/\?replay=[A-Za-z0-9._~%&=/+:,-]+") | |
| 47 | RESERVED_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 | |
| 49 | MODES = ("public", "reviewed", "private") | |
| 50 | REPORT_ACTIONS = ("approve", "hide", "close", "reopen", "replay") | |
| 51 | COMMENT_ACTIONS = ("approve", "trust", "reject", "untrust", "remove") | |
| 52 | CONFIRM = {"hide", "reject", "untrust", "remove"} # these ask first; so does every mode change | |
| 53 | MAX_BODY = 32 * 1024 | |
| 54 | COOKIE = "fwadmin" # the cookie is named fwadmin-<port>, so two instances (or another local server) do not clash | |
| 55 | HEADERS = { | |
| 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 | ||
| 63 | class 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 | ||
| 71 | class 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 | ||
| 110 | def valid_repo(repo): | |
| 111 | return bool(REPO_RE.match(repo)) and not repo.endswith(".git") | |
| 112 | ||
| 113 | ||
| 114 | def valid_n(n): | |
| 115 | return n.isascii() and n.isdigit() and 0 < int(n) < 10**9 | |
| 116 | ||
| 117 | ||
| 118 | def esc(value): | |
| 119 | return html.escape(str(value), quote=True) | |
| 120 | ||
| 121 | ||
| 122 | def 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 | ||
| 127 | def 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 | ||
| 133 | CSS = """ | |
| 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 | } | |
| 140 | body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, sans-serif; } | |
| 141 | main { max-width: 64rem; margin: 0 auto; padding: 0 1rem 3rem; } | |
| 142 | a { color: var(--link); text-decoration: none; } | |
| 143 | a:hover { text-decoration: underline; } | |
| 144 | nav.top { background: var(--soft); border-bottom: 1px solid var(--line); padding: .6rem 1rem; } | |
| 145 | nav.top a { margin-right: 1rem; color: var(--muted); } | |
| 146 | nav.top a[aria-current] { color: var(--fg); font-weight: 600; } | |
| 147 | h1 { font-size: 1.5rem; } | |
| 148 | h2 { font-size: 1.15rem; margin-top: 2rem; } | |
| 149 | table { border-collapse: collapse; width: 100%; } | |
| 150 | th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| 151 | th { 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; } | |
| 158 | pre.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; } | |
| 160 | table.issue-details { width: auto; max-width: 100%; font-size: .8rem; margin: .5rem 0; } | |
| 161 | table.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; } | |
| 164 | button { font: inherit; padding: .25rem .8rem; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); | |
| 165 | color: var(--fg); cursor: pointer; } | |
| 166 | button.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; } | |
| 173 | label.mode { margin-right: 1rem; } | |
| 174 | """ | |
| 175 | ||
| 176 | ||
| 177 | def 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 | ||
| 187 | def badge(state): | |
| 188 | return f'<span class="badge{" badge-success" if state == "open" else ""}">{esc(state)}</span>' | |
| 189 | ||
| 190 | ||
| 191 | def yes(flag): | |
| 192 | return "yes" if flag else "no" | |
| 193 | ||
| 194 | ||
258a9da8 2026-11-06 Pavel Safronov | 195 | STATE_BADGES = {"current": ("up to date", " badge-success"), "pending": ("pending", " badge-warning"), |
| 196 | "never": ("never deployed", ""), "unknown": ("unknown", "")} | |
| 197 | ||
| 198 | ||
| 199 | def state_badge(state): | |
| 200 | label, cls = STATE_BADGES.get(state, (str(state), "")) | |
| 201 | return f'<span class="badge{cls}">{esc(label)}</span>' | |
| 202 | ||
| 203 | ||
| 204 | def 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 "–" | |
| 207 | ||
| 208 | ||
| 209 | def 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 Safronov | 214 | return '<span class="meta">not on the server (unpushed commit, or unknown repo)</span>' |
258a9da8 2026-11-06 Pavel Safronov | 215 | 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 Safronov | 222 | # ---- the server ---- |
| 223 | ||
| 224 | class 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 | ||
| 245 | def make_server(runner, port=0, code_dir=None): | |
| 246 | return AdminServer(runner, port, code_dir) | |
| 247 | ||
| 248 | ||
| 249 | def same(a, b): | |
| 250 | return hmac.compare_digest(str(a).encode(), str(b).encode()) | |
| 251 | ||
| 252 | ||
| 253 | class 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 Safronov | 325 | for p, t in (("reports", "Reports"), ("comments", "Comments"), ("deploy", "Deploy"))) |
85702b14 2026-11-04 Pavel Safronov | 326 | 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 Safronov | 369 | if url.path == "/deploy": |
| 370 | return self.deploy_page() | |
85702b14 2026-11-04 Pavel Safronov | 371 | 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}&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 Safronov | 494 | 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 Safronov | 507 | '<table><tr><th>Target</th><th>State</th><th>Deployed from</th><th>When (laptop time)</th>' |
258a9da8 2026-11-06 Pavel Safronov | 508 | 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 Safronov | 522 | @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 | ||
| 620 | def 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 | ||
| 645 | if __name__ == "__main__": | |
| 646 | sys.exit(main(sys.argv[1:])) |