feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user