fuzzyworld-tools / files / lib / admin.py
admin.py
| 1 | #!/usr/bin/env python3 |
| 2 | """fw admin: a LOCAL web page for the server's reports (bug-report issues), blog comments and deploy state. Python standard library only. |
| 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 | |
| 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: |
| 214 | return '<span class="meta">not on the server (unpushed commit, or unknown repo)</span>' |
| 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 | |
| 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>' |
| 325 | for p, t in (("reports", "Reports"), ("comments", "Comments"), ("deploy", "Deploy"))) |
| 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() |
| 369 | if url.path == "/deploy": |
| 370 | return self.deploy_page() |
| 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 | |
| 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>' |
| 507 | '<table><tr><th>Target</th><th>State</th><th>Deployed from</th><th>When (laptop time)</th>' |
| 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 | |
| 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:])) |