"""Serve one local review run through a loopback-only read-only HTTP API.""" from __future__ import annotations import argparse import json import mimetypes from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any from urllib.parse import unquote, urlsplit from reviewer.server.artifacts import JsonObject, ReviewArtifactError, ReviewArtifacts _SECURITY_HEADERS = { "Cache-Control": "no-store", "Content-Security-Policy": ( "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'none'; " "font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" ), "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", } class ReviewerHttpServer(ThreadingHTTPServer): """Threaded local server whose workers never keep process shutdown alive.""" daemon_threads = True def _valid_local_request(handler: BaseHTTPRequestHandler) -> bool: host = handler.headers.get("Host") if host is None: return False try: parsed_host = urlsplit(f"//{host}") if parsed_host.username is not None or parsed_host.password is not None: return False if parsed_host.hostname not in {"127.0.0.1", "localhost"}: return False if parsed_host.port is not None and not 0 < parsed_host.port < 65536: return False except ValueError: return False origin = handler.headers.get("Origin") if origin is None: return True try: parsed_origin = urlsplit(origin) return parsed_origin.scheme == "http" and parsed_origin.netloc == host except ValueError: return False def _api_response(repository: ReviewArtifacts, path: str) -> JsonObject: if path == "/api/v1/run": return repository.run_summary() parts = [part for part in path.split("/") if part] try: if len(parts) == 4 and parts[:3] == ["api", "v1", "documents"]: return repository.document_comparison(unquote(parts[3], encoding="utf-8", errors="strict")) if len(parts) == 6 and parts[:3] == ["api", "v1", "documents"] and parts[4] == "components": document_id = unquote(parts[3], encoding="utf-8", errors="strict") try: component_position = int(parts[5]) except ValueError: raise ReviewArtifactError("unknown_component", "组件位置不存在。", 404) from None return repository.component_stage(document_id, component_position) except UnicodeDecodeError: raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) from None raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) def _handler_factory(repository: ReviewArtifacts, static_root: Path) -> type[BaseHTTPRequestHandler]: class ReviewRequestHandler(BaseHTTPRequestHandler): server_version = "mdpolish-reviewer" sys_version = "" def log_message(self, format_: str, *args: Any) -> None: del format_, args def _headers(self, status: int, content_type: str, content_length: int) -> None: self.send_response(status) for name, value in _SECURITY_HEADERS.items(): self.send_header(name, value) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(content_length)) self.end_headers() def _json(self, status: int, payload: JsonObject, *, head_only: bool) -> None: content = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode() self._headers(status, "application/json; charset=utf-8", len(content)) if not head_only: self.wfile.write(content) def _error(self, error: Exception, *, head_only: bool) -> None: if isinstance(error, ReviewArtifactError): status = error.http_status code = error.code message = str(error) else: status = 500 code = "internal_error" message = "评审器无法完成该请求。" self._json(status, {"error": {"code": code, "message": message}}, head_only=head_only) def _static(self, path: str, *, head_only: bool) -> None: requested = "index.html" if path == "/" else unquote(path[1:], encoding="utf-8", errors="strict") if "\0" in requested: raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) candidate = static_root / requested try: if candidate.is_symlink(): raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) resolved = candidate.resolve(strict=True) if not resolved.is_relative_to(static_root) or not resolved.is_file(): raise FileNotFoundError except (FileNotFoundError, OSError): if Path(requested).suffix: raise ReviewArtifactError("not_found", "请求的资源不存在。", 404) from None resolved = (static_root / "index.html").resolve(strict=True) content = resolved.read_bytes() content_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream" if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}: content_type += "; charset=utf-8" self._headers(200, content_type, len(content)) if not head_only: self.wfile.write(content) def _handle(self, method: str) -> None: head_only = method == "HEAD" if method not in {"GET", "HEAD"}: self.send_response(405) for name, value in _SECURITY_HEADERS.items(): self.send_header(name, value) self.send_header("Allow", "GET, HEAD") payload: JsonObject = { "error": {"code": "method_not_allowed", "message": "只允许 GET 和 HEAD。"} } content = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode() self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) return try: if not _valid_local_request(self): raise ReviewArtifactError("invalid_origin", "只接受本机同源请求。", 403) request_path = urlsplit(self.path).path if request_path.startswith("/api/"): self._json(200, _api_response(repository, request_path), head_only=head_only) else: self._static(request_path, head_only=head_only) except Exception as error: # the response intentionally hides unexpected implementation details self._error(error, head_only=head_only) def do_GET(self) -> None: self._handle("GET") def do_HEAD(self) -> None: self._handle("HEAD") def do_POST(self) -> None: self._handle("POST") def do_PUT(self) -> None: self._handle("PUT") def do_PATCH(self) -> None: self._handle("PATCH") def do_DELETE(self) -> None: self._handle("DELETE") def do_OPTIONS(self) -> None: self._handle("OPTIONS") return ReviewRequestHandler def create_server( repository: ReviewArtifacts, static_root: Path, *, port: int = 0, ) -> ReviewerHttpServer: """Create, but do not start, the loopback reviewer server.""" index = static_root / "index.html" if not index.is_file(): raise ReviewArtifactError("missing_build", "未找到前端构建结果,请先运行 npm run build。", 400) return ReviewerHttpServer(("127.0.0.1", port), _handler_factory(repository, static_root.resolve())) def _arguments(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="只读查看一次 mdpolish 本地清洗运行。") parser.add_argument("--run-dir", required=True, help="一次已发布运行目录的绝对或相对路径") parser.add_argument("--port", type=int, default=0, help="本机端口;默认 0 表示自动选择") arguments = parser.parse_args(argv) if arguments.port < 0 or arguments.port > 65535: parser.error("--port 必须在 0 到 65535 之间") return arguments def main(argv: list[str] | None = None) -> None: arguments = _arguments(argv) repository = ReviewArtifacts(arguments.run_dir) static_root = Path(__file__).resolve().parents[1] / "dist" try: server = create_server(repository, static_root, port=arguments.port) except OSError as error: raise ReviewArtifactError("server_error", "无法启动本地评审服务。", 500) from error port = server.server_address[1] print( f"mdpolish 评审器已启动:http://127.0.0.1:{port}({repository.run_id}," f"{len(repository.documents)} 份文档)", flush=True, ) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": try: main() except ReviewArtifactError as error: raise SystemExit(f"评审器启动失败:{error}") from None