Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrpipe/control_web.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

562 lines
26 KiB
Python

#!/usr/bin/env python3
"""Persistent mobile control plane for recording intake and human review."""
from __future__ import annotations
import hashlib
import hmac
import html
import json
import os
import re
import secrets
import sqlite3
import time
import urllib.parse
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from . import review, webauth
from .config import CFG
from .log import log
STATE = CFG.state_dir / "review-web.json"
DB = CFG.state_dir / "intake.db"
RECORDINGS = Path(os.environ.get(
"ASR_RECORDINGS_ROOT", str(CFG.obsidian / "recordings")))
INBOX = RECORDINGS / "inbox"
META = RECORDINGS / "state" / "uploads"
MAX_UPLOAD = int(os.environ.get("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3)))
CHUNK = 1024 * 1024
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg",
".opus", ".webm", ".flac"}
STATUS_LABEL = {
"uploading": "上传中",
"queued": "等待转写",
"transcribing": "正在转写",
"refining": "正在整理",
"review": "等待确认",
"publishing": "正在发布",
"complete": "已完成",
"failed": "处理失败",
}
def _connect() -> sqlite3.Connection:
CFG.state_dir.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB, timeout=20)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("""
CREATE TABLE IF NOT EXISTS intake_job(
id TEXT PRIMARY KEY,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
transcript TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
""")
columns = {row["name"] for row in con.execute("PRAGMA table_info(intake_job)")}
for name in ("draft", "result"):
if name not in columns:
con.execute(
f"ALTER TABLE intake_job ADD COLUMN {name} TEXT NOT NULL DEFAULT ''")
return con
def update_job(job_id: str, status: str, detail: str = "",
transcript: str = "", draft: str = "",
result: str = "") -> bool:
if os.environ.get("ASR_ROLE"):
from asrservice.db import update_job as service_update_job
return service_update_job(job_id, status, detail, transcript=transcript,
draft=draft, result=result)
if not job_id:
return False
con = _connect()
fields = ["status=?", "updated_at=?"]
values: list[object] = [status, time.time()]
if detail:
fields.append("detail=?")
values.append(detail[:1000])
if transcript:
fields.append("transcript=?")
values.append(transcript)
if draft:
fields.append("draft=?")
values.append(draft)
if result:
fields.append("result=?")
values.append(result)
values.append(job_id)
cur = con.execute(
f"UPDATE intake_job SET {','.join(fields)} WHERE id=?", values)
con.commit()
con.close()
return cur.rowcount > 0
def _jobs(limit: int = 30) -> list[dict]:
con = _connect()
rows = con.execute(
"SELECT * FROM intake_job ORDER BY created_at DESC LIMIT ?",
(limit,)).fetchall()
con.close()
return [dict(r) for r in rows]
def _job(job_id: str) -> dict | None:
con = _connect()
row = con.execute("SELECT * FROM intake_job WHERE id=?", (job_id,)).fetchone()
con.close()
return dict(row) if row else None
def _review_url(job_id: str = "") -> str:
base = f"http://{CFG.review_public_host}:{CFG.review_port}"
return f"{base}/jobs/{job_id}/review/" if job_id else f"{base}/review/"
def _pending_for_job(job: dict, limit: int = 200) -> list[dict]:
transcript = str(job.get("transcript") or "")
draft = str(job.get("draft") or "")
rows = []
for row in review.pending(limit):
payload = json.loads(row["payload"] or "{}")
if ((transcript and row["recording"] == transcript)
or (draft and payload.get("draft_path") == draft)
or payload.get("job_id") == job["id"]):
rows.append(row)
return rows
def _safe_text(value: str, limit: int) -> str:
value = re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
return re.sub(r"\s+", " ", value).strip()[:limit]
def _safe_stem(value: str) -> str:
value = _safe_text(value, 80)
value = re.sub(r"[^\w\u3400-\u9fff.-]+", "-", value, flags=re.UNICODE)
return value.strip("-.") or "录音"
def _write_state() -> None:
CFG.state_dir.mkdir(parents=True, exist_ok=True)
data = {
"pid": os.getpid(),
"url": _review_url(),
"home": f"http://{CFG.review_public_host}:{CFG.review_port}/",
"port": CFG.review_port,
"started_at": time.time(),
}
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8")
os.chmod(tmp, 0o600)
tmp.replace(STATE)
def _shell(title: str, body: str, script: str = "") -> bytes:
return f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf" content="{html.escape(webauth.csrf_token(), quote=True)}">
<meta name="color-scheme" content="light"><meta http-equiv="Cache-Control" content="no-store">
<title>{html.escape(title)}</title>
<style>
:root{{--ink:#17202a;--muted:#637083;--line:#d9dee5;--paper:#f5f7f8;
--accent:#176b5b;--danger:#b33a2b;--white:#fff;--warn:#986b12}}
*{{box-sizing:border-box}} body{{margin:0;background:var(--paper);color:var(--ink);
font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
font-size:16px;line-height:1.55;letter-spacing:0}}
header{{background:var(--white);border-bottom:1px solid var(--line);padding:15px 18px}}
header .inner{{max-width:760px;margin:auto;display:flex;align-items:center;justify-content:space-between}}
header strong{{font-size:18px}} nav a{{color:var(--accent);text-decoration:none;margin-left:16px}}
main{{max-width:760px;margin:0 auto;padding:18px 14px 44px}}
.panel{{background:var(--white);border:1px solid var(--line);border-radius:8px;padding:18px;
margin-bottom:14px}} h1{{font-size:22px;margin:0 0 8px}} h2{{font-size:18px;margin:0 0 12px}}
.muted,.meta{{color:var(--muted);font-size:14px}} label{{display:block;font-weight:650;margin:12px 0 5px}}
input,textarea{{width:100%;border:1px solid #98a4b0;border-radius:6px;background:white;
padding:11px 12px;font:inherit}} textarea{{min-height:92px;resize:vertical}}
button{{min-height:46px;border:0;border-radius:6px;padding:10px 15px;background:var(--accent);
color:white;font:inherit;font-weight:650}} button:disabled{{opacity:.55}}
button.secondary{{background:#e9edf1;color:var(--ink);border:1px solid var(--line)}}
.actions{{display:flex;gap:10px;align-items:center;margin-top:14px}} .actions button{{flex:1}}
progress{{width:100%;height:12px;margin-top:12px}} .job{{padding:12px 0;border-top:1px solid var(--line)}}
.job:first-child{{border-top:0}} .jobtop{{display:flex;gap:10px;justify-content:space-between}}
.joblink{{display:inline-block;margin-top:7px;color:var(--accent);font-weight:650;text-decoration:none}}
.badge{{white-space:nowrap;font-size:13px;color:var(--accent);font-weight:700}}
.badge.failed{{color:var(--danger)}} .badge.review{{color:var(--warn)}}
.source{{margin:14px 0;padding:12px;border-left:4px solid #d4a72c;background:#fff9e8}}
.choice{{width:100%;margin:5px 0}} .custom{{display:flex;gap:8px;margin-top:8px}}
.custom input{{min-width:0;flex:1}} .custom button{{width:auto}}
.draft{{margin:14px 0;border:1px solid var(--line);border-radius:6px;background:#fbfcfd}}
.draft summary{{padding:12px 14px;font-weight:650;cursor:pointer}}
.draft pre{{margin:0;padding:14px;border-top:1px solid var(--line);max-height:58vh;
overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.55 ui-monospace,monospace}}
@media(max-width:480px){{main{{padding:12px 10px 28px}}.panel{{padding:15px}}
.custom,.actions{{display:grid}}.custom button{{width:100%}}}}
</style></head><body><header><div class="inner"><strong>录音工作台</strong>
<nav><a href="/">上传</a><a href="/review/">确认</a></nav></div></header>
<main>{body}</main>{script}</body></html>""".encode("utf-8")
def _home() -> bytes:
body = """<section class="panel"><h1>上传录音</h1>
<div class="muted">支持 mp3、m4a、wav、mp4、aac、ogg、opus、webm、flac。上传完成后自动进入现有 ASR 流程。</div>
<form id="upload">
<label for="audio">录音文件</label><input id="audio" type="file" accept="audio/*,video/mp4" required>
<label for="title">标题</label><input id="title" maxlength="80" placeholder="例如:洛启项目通话">
<label for="context">已知背景(可选)</label><textarea id="context" maxlength="2000"
placeholder="参与人、公司、项目、希望重点确认的事项。该信息会单独标记为用户提供,不会混入转写原文。"></textarea>
<div class="actions"><button id="send" type="submit">上传并开始处理</button></div>
<progress id="progress" max="100" value="0" hidden></progress>
<div id="result" class="meta"></div></form></section>
<section class="panel"><h2>最近任务</h2><div id="jobs"><div class="muted">正在读取…</div></div></section>"""
script = """<script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function jobs(){
try{const r=await fetch('/api/jobs',{cache:'no-store'}), d=await r.json();
const box=document.querySelector('#jobs');
box.innerHTML=d.jobs.length?d.jobs.map(j=>`<div class="job"><div class="jobtop">
<strong>${esc(j.title||j.original_name)}</strong><span class="badge ${esc(j.status)}">${esc(j.status_label)}</span>
</div><div class="meta">${new Date(j.created_at*1000).toLocaleString()} · ${esc(j.original_name)}
${j.detail?'<br>'+esc(j.detail):''}</div>
<a class="joblink" href="${esc(j.review_url)}">${j.status==='complete'?'查看任务':'查看 / 继续确认'}</a>
</div>`).join(''):'<div class="muted">还没有任务</div>';
}catch(e){document.querySelector('#jobs').textContent='任务状态暂时不可用';}}
jobs(); setInterval(jobs,10000);
document.querySelector('#upload').addEventListener('submit',e=>{
e.preventDefault(); const f=document.querySelector('#audio').files[0]; if(!f)return;
const x=new XMLHttpRequest(), b=document.querySelector('#send'), p=document.querySelector('#progress'), out=document.querySelector('#result');
b.disabled=true;p.hidden=false;out.textContent='正在上传…';
x.open('POST','/api/upload');x.setRequestHeader('Content-Type','application/octet-stream');
x.setRequestHeader('X-CSRF-Token', document.querySelector('meta[name=csrf]')?.content || '');
x.setRequestHeader('X-Filename',encodeURIComponent(f.name));
x.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
x.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
x.upload.onprogress=v=>{if(v.lengthComputable)p.value=Math.round(v.loaded/v.total*100)};
x.onload=()=>{b.disabled=false;if(x.status===201){out.textContent='上传完成,已进入处理队列。';e.target.reset();p.value=0;p.hidden=true;jobs()}
else out.textContent='上传失败:'+x.responseText};
x.onerror=()=>{b.disabled=false;out.textContent='网络中断,请重试'};x.send(f);
});
</script>"""
return _shell("录音工作台", body, script)
def _review_page(action: str, job: dict | None = None) -> bytes:
rows = _pending_for_job(job) if job else review.pending(100)
if not rows:
if job:
label = STATUS_LABEL.get(job["status"], job["status"])
detail = html.escape(str(job.get("detail") or ""))
return _shell("任务状态",
f'<section class="panel"><div class="meta">{html.escape(label)}</div>'
f'<h1>{html.escape(str(job.get("title") or job["original_name"]))}</h1>'
f'<p>{detail or "当前没有待确认问题。"}</p>'
'<p class="muted">页面会随任务阶段更新,可以稍后刷新。</p></section>')
return _shell("确认完成",
'<section class="panel"><h1>当前没有待确认项目</h1>'
'<p class="muted">新草稿生成后,Telegram 会再次发送这里的链接。</p></section>')
row = rows[0]
payload = json.loads(row["payload"] or "{}")
choices = payload.get("candidates") or []
buttons = "".join(
f'<button class="choice" type="submit" name="answer" '
f'value="{html.escape(str(v), quote=True)}">{html.escape(str(v))}</button>'
for v in choices)
buttons += ('<button class="choice secondary" type="submit" name="answer" '
'value="无法确认,保留原文">都不是 / 无法确认</button>')
source = html.escape(str(payload.get("sentence", "")))
source_html = f'<div class="source">{source}</div>' if source else ""
draft_html = ""
if payload.get("draft_path"):
try:
path = Path(payload["draft_path"]).resolve()
path.relative_to(CFG.drafts.resolve())
draft = path.read_text(encoding="utf-8", errors="ignore")
draft_html = ('<details class="draft"><summary>查看完整草稿</summary><pre>'
+ html.escape(draft) + "</pre></details>")
except (OSError, ValueError):
pass
placeholder = ("只输入姓名,例如:高瑶"
if payload.get("kind") == "voiceprint"
else ("背景不准时,写明正确背景并提交重跑"
if payload.get("kind") == "background_check"
else "输入正确名称或补充说明"))
title = (str(job.get("title") or job["original_name"])
if job else "ASR 快速确认")
body = f"""<section class="panel"><div class="meta">{html.escape(title)} · 剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
<form method="post" action="{html.escape(action, quote=True)}">
<input type="hidden" name="csrf" value="{html.escape(webauth.csrf_token(), quote=True)}">
<input type="hidden" name="id" value="{row['id']}">{buttons}
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
</form></section>"""
return _shell("ASR 快速确认", body)
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, addr, handler):
super().__init__(addr, handler)
class Handler(BaseHTTPRequestHandler):
server_version = "ASRControl/1"
def log_message(self, fmt, *args):
status = args[1] if len(args) > 1 else "?"
log(f"control web: {self.command} {urllib.parse.urlsplit(self.path).path} status={status}")
def _headers(self, status=HTTPStatus.OK,
content_type="text/html; charset=utf-8"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
self.send_header("X-Content-Type-Options", "nosniff")
def _send(self, data: bytes, status=HTTPStatus.OK,
content_type="text/html; charset=utf-8"):
self._headers(status, content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _json(self, obj: object, status=HTTPStatus.OK):
self._send(json.dumps(obj, ensure_ascii=False).encode("utf-8"),
status, "application/json; charset=utf-8")
def _review_job(self) -> tuple[bool, dict | None]:
path = urllib.parse.urlsplit(self.path).path
if path == "/review/":
return True, None
match = re.fullmatch(r"/jobs/([0-9a-z-]{8,64})/review/", path)
if match:
return True, _job(match.group(1))
return False, None
def _browser_ok(self) -> bool:
if webauth.allow_anon():
return True
if not webauth.password():
return False
return webauth.cookie_matches(self.headers.get("Cookie", ""))
def _login_page(self, err: str = "") -> bytes:
if not webauth.password() and not webauth.allow_anon():
err = err or "未配置 ASR_WEB_PASSWORD,控制面已关闭。"
token = html.escape(webauth.csrf_token(), quote=True)
alert = f"<p>{html.escape(err)}</p>" if err else ""
body = (
f'<section class="panel"><h1>访问验证</h1>{alert}'
f'<form method="post" action="/login">'
f'<input type="hidden" name="csrf" value="{token}">'
f'<label>访问口令</label><input name="password" type="password" required>'
f'<div class="actions"><button type="submit">登录</button></div></form></section>'
)
return _shell("登录", body)
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
if path == "/health":
return self._json({"ok": True, "pending": len(review.pending(100))})
if path in {"/login", "/logout"}:
if path == "/logout":
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/login")
self.send_header("Set-Cookie", "asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")
self.end_headers()
return
return self._send(self._login_page())
if not self._browser_ok():
return self._send(self._login_page(), HTTPStatus.UNAUTHORIZED)
if path == "/":
return self._send(_home())
if path == "/api/jobs":
jobs = _jobs()
for j in jobs:
j["status_label"] = STATUS_LABEL.get(j["status"], j["status"])
j["review_url"] = f"/jobs/{j['id']}/review/"
j["has_draft"] = bool(j.get("draft"))
j.pop("context", None)
j.pop("transcript", None)
j.pop("draft", None)
j.pop("result", None)
return self._json({"jobs": jobs})
is_review, job = self._review_job()
if is_review:
if path != "/review/" and not job:
return self._send(b"Task not found", HTTPStatus.NOT_FOUND,
"text/plain")
return self._send(_review_page(path, job))
return self._send(b"Not found", HTTPStatus.NOT_FOUND, "text/plain")
def _header(self, name: str, limit: int) -> str:
return _safe_text(urllib.parse.unquote(self.headers.get(name, "")), limit)
def _upload(self):
try:
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError:
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
if length <= 0 or length > MAX_UPLOAD:
return self._json({"error": "invalid upload size"},
HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
original = self._header("X-Filename", 240)
title = self._header("X-Title", 80)
context = self._header("X-Context", 2000)
suffix = Path(original).suffix.lower()
if suffix not in SUPPORTED:
return self._json({"error": f"unsupported file type: {suffix or '(none)'}"},
HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
job_id = secrets.token_hex(12)
stem = _safe_stem(title or Path(original).stem)
stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem}-{job_id[:8]}{suffix}"
INBOX.mkdir(parents=True, exist_ok=True)
staging = INBOX / ".uploading"
staging.mkdir(parents=True, exist_ok=True)
target = INBOX / stored
part = staging / f"{job_id}.part"
con = _connect()
now = time.time()
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,created_at,updated_at) VALUES(?,?,?,?,?,?,?, ?,?)",
(job_id, original, stored, title, context, length,
"uploading", now, now))
con.commit()
con.close()
remaining = length
try:
with part.open("xb") as f:
while remaining:
data = self.rfile.read(min(CHUNK, remaining))
if not data:
raise EOFError("incomplete upload")
f.write(data)
remaining -= len(data)
f.flush()
os.fsync(f.fileno())
META.mkdir(parents=True, exist_ok=True)
meta = {
"job_id": job_id, "title": title, "context": context,
"original_name": original, "stored_name": stored,
}
meta_tmp = META / f".{stored}.tmp"
meta_tmp.write_text(json.dumps(meta, ensure_ascii=False),
encoding="utf-8")
meta_tmp.replace(META / f"{stored}.json")
part.replace(target)
update_job(job_id, "queued", "录音已落盘,等待 watcher 接收")
return self._json({"ok": True, "job_id": job_id,
"stored_name": stored}, HTTPStatus.CREATED)
except EOFError:
part.unlink(missing_ok=True)
update_job(job_id, "failed", "上传未完成")
return self._json({"error": "incomplete upload"}, HTTPStatus.BAD_REQUEST)
except Exception as e:
part.unlink(missing_ok=True)
update_job(job_id, "failed", "上传失败")
log(f"upload failed: {e}")
return self._json({"error": "upload failed"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def _answer(self):
try:
try:
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError as exc:
raise ValueError("bad content-length") from exc
if length < 0 or length > 4096:
raise ValueError("bad content-length")
form = urllib.parse.parse_qs(
self.rfile.read(length).decode("utf-8"))
if not webauth.csrf_matches((form.get("csrf") or [""])[0]):
raise ValueError("csrf")
qid = int(form.get("id", ["0"])[0])
answer = (form.get("custom", [""])[0].strip()
or form.get("answer", [""])[0].strip())
if not qid or not answer or len(answer) > 120:
raise ValueError("invalid answer")
is_review, job = self._review_job()
if not is_review:
raise ValueError("invalid review path")
allowed = (_pending_for_job(job, 1000) if job
else review.pending(1000))
if qid not in {row["id"] for row in allowed}:
raise ValueError("问题不属于当前任务或已处理")
review.answer(qid, answer)
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", urllib.parse.urlsplit(self.path).path)
self.send_header("Cache-Control", "no-store")
self.end_headers()
except Exception as e:
self._send(_shell("提交失败",
f'<section class="panel"><h1>提交失败</h1><p>{html.escape(str(e))}</p></section>'),
HTTPStatus.BAD_REQUEST)
def do_POST(self):
path = urllib.parse.urlsplit(self.path).path
if path == "/login":
try:
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError:
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
if length < 0:
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
length = min(length, 4096)
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", "ignore"))
pw = form.get("password", [""])[0]
if webauth.password() and hmac.compare_digest(pw, webauth.password()):
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400",
)
self.end_headers()
return
return self._send(self._login_page("口令错误或未配置 ASR_WEB_PASSWORD"), HTTPStatus.UNAUTHORIZED)
if not self._browser_ok():
return self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED)
if path in ("/api/upload", "/hook/intake"):
if not webauth.csrf_matches(self.headers.get("X-CSRF-Token", "")):
return self._json({"error": "csrf"}, HTTPStatus.FORBIDDEN)
return self._upload()
if self._review_job()[0]:
return self._answer()
return self._send(b"Not found", HTTPStatus.NOT_FOUND, "text/plain")
def serve() -> int:
try:
server = Server((CFG.review_host, CFG.review_port), Handler)
except OSError as e:
STATE.unlink(missing_ok=True)
log(f"control web bind failed on {CFG.review_host}:{CFG.review_port}: {e}",
level="ERROR")
return 1
_write_state()
log(f"control web listening on {CFG.review_public_host}:{CFG.review_port}")
try:
server.serve_forever(poll_interval=0.5)
finally:
server.server_close()
STATE.unlink(missing_ok=True)
return 0
if __name__ == "__main__":
raise SystemExit(serve())