91 lines
3.3 KiB
Python
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()
|