Files
mdpolish/reviewer/server/__main__.py
T

234 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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