feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-02 06:37:33 -08:00
parent 9debd1c47d
commit 3c70861463
44 changed files with 4354 additions and 333 deletions
@@ -2,6 +2,8 @@
"""Persistent mobile control plane for recording intake and human review."""
from __future__ import annotations
import hashlib
import hmac
import html
import json
import os
@@ -14,7 +16,7 @@ from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from . import review
from . import review, webauth
from .config import CFG
from .log import log
@@ -166,6 +168,7 @@ 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>
@@ -236,6 +239,7 @@ document.querySelector('#upload').addEventListener('submit',e=>{
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));
@@ -293,6 +297,7 @@ def _review_page(action: str, job: dict | None = None) -> bytes:
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>
@@ -342,12 +347,43 @@ class Handler(BaseHTTPRequestHandler):
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 == "/":
return self._send(_home())
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:
@@ -372,9 +408,9 @@ class Handler(BaseHTTPRequestHandler):
def _upload(self):
try:
length = int(self.headers.get("Content-Length", "0"))
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError:
length = 0
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)
@@ -408,7 +444,7 @@ class Handler(BaseHTTPRequestHandler):
while remaining:
data = self.rfile.read(min(CHUNK, remaining))
if not data:
raise EOFError("upload ended early")
raise EOFError("incomplete upload")
f.write(data)
remaining -= len(data)
f.flush()
@@ -426,16 +462,28 @@ class Handler(BaseHTTPRequestHandler):
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", f"上传失败:{e}")
return self._json({"error": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR)
update_job(job_id, "failed", "上传失败")
log(f"upload failed: {e}")
return self._json({"error": "upload failed"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def _answer(self):
try:
length = min(int(self.headers.get("Content-Length", "0")), 4096)
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())
@@ -460,7 +508,31 @@ class Handler(BaseHTTPRequestHandler):
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()