Files
wyndham-ARR/arr_web/server.py
2026-07-29 16:38:05 +08:00

91 lines
3.3 KiB
Python

"""Threaded standard-library HTTP adapter for the portal application."""
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Type
from arr_web.app import PortalApplication
from arr_web.contracts import MAX_UPLOAD_BYTES, PortalError, Response, failure
SECURITY_HEADERS = {
"Content-Security-Policy": (
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; "
"form-action 'self'"
),
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Cross-Origin-Resource-Policy": "same-origin",
}
def handler_for(application: PortalApplication) -> Type[BaseHTTPRequestHandler]:
class PortalHandler(BaseHTTPRequestHandler):
server_version = "ARRPortal/1.0"
def do_GET(self) -> None: # noqa: N802
self._dispatch(b"")
def do_POST(self) -> None: # noqa: N802
raw_length = self.headers.get("Content-Length")
if raw_length is None:
self._write(
Response.json(
411,
failure(PortalError("CONTENT_LENGTH_REQUIRED", "请求长度缺失", 411)),
)
)
return
try:
length = int(raw_length)
except ValueError:
length = -1
if length < 0 or length > MAX_UPLOAD_BYTES:
self._write(
Response.json(
413,
failure(PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)),
)
)
return
self._dispatch(self.rfile.read(length))
def _dispatch(self, body: bytes) -> None:
response = application.handle(
self.command,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
self._write(response)
def _write(self, response: Response) -> None:
self.send_response(response.status)
self.send_header("Content-Type", response.content_type)
self.send_header("Content-Length", str(len(response.body)))
for key, value in SECURITY_HEADERS.items():
self.send_header(key, value)
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(response.body)
def log_message(self, format: str, *args: object) -> None:
# Keep request logs path-only; never print request bodies, cookies, or upload names.
super().log_message("%s %s", self.command, self.path.split("?", 1)[0])
return PortalHandler
def serve(application: PortalApplication, host: str, port: int) -> None:
server = ThreadingHTTPServer((host, port), handler_for(application))
try:
server.serve_forever(poll_interval=0.25)
finally:
server.server_close()