142 lines
5.2 KiB
Python
142 lines
5.2 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import hmac
|
||
|
|
import json
|
||
|
|
import secrets
|
||
|
|
from http import HTTPStatus
|
||
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
from urllib.parse import urlsplit
|
||
|
|
|
||
|
|
from .domain import Zone
|
||
|
|
|
||
|
|
|
||
|
|
MAX_BODY_BYTES = 64 * 1024
|
||
|
|
|
||
|
|
|
||
|
|
def parse_bind(value: str) -> tuple[str, int]:
|
||
|
|
parsed = urlsplit(f"//{value}")
|
||
|
|
host = parsed.hostname
|
||
|
|
try:
|
||
|
|
port = parsed.port
|
||
|
|
except ValueError as exc:
|
||
|
|
raise ValueError("invalid demo bind address") from exc
|
||
|
|
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
|
||
|
|
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
|
||
|
|
return host, port
|
||
|
|
|
||
|
|
|
||
|
|
def index_path() -> Path:
|
||
|
|
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
|
||
|
|
|
||
|
|
|
||
|
|
class DemoHTTPServer(ThreadingHTTPServer):
|
||
|
|
daemon_threads = True
|
||
|
|
|
||
|
|
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
|
||
|
|
self.engine = engine
|
||
|
|
self.demo_token = token
|
||
|
|
self.html = html
|
||
|
|
super().__init__(address, DemoHandler)
|
||
|
|
|
||
|
|
|
||
|
|
class DemoHandler(BaseHTTPRequestHandler):
|
||
|
|
server: DemoHTTPServer
|
||
|
|
|
||
|
|
def log_message(self, _format: str, *_args: object) -> None:
|
||
|
|
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
|
||
|
|
return
|
||
|
|
|
||
|
|
def _security_headers(self) -> None:
|
||
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||
|
|
self.send_header("X-Frame-Options", "DENY")
|
||
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
||
|
|
self.send_header("Cache-Control", "no-store")
|
||
|
|
self.send_header(
|
||
|
|
"Content-Security-Policy",
|
||
|
|
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
|
||
|
|
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||
|
|
)
|
||
|
|
|
||
|
|
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
|
||
|
|
self.send_response(status)
|
||
|
|
self.send_header("Content-Type", content_type)
|
||
|
|
self.send_header("Content-Length", str(len(body)))
|
||
|
|
self._security_headers()
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body)
|
||
|
|
|
||
|
|
def _send_json(self, status: int, payload: object) -> None:
|
||
|
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
|
|
self._send_bytes(status, "application/json; charset=utf-8", body)
|
||
|
|
|
||
|
|
def do_GET(self) -> None: # noqa: N802
|
||
|
|
path = urlsplit(self.path).path
|
||
|
|
if path in {"/", "/brain-demo"}:
|
||
|
|
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
|
||
|
|
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||
|
|
return
|
||
|
|
if path == "/healthz":
|
||
|
|
self._send_json(HTTPStatus.OK, {"status": "ok"})
|
||
|
|
return
|
||
|
|
if path == "/api/v1/state":
|
||
|
|
self._send_json(HTTPStatus.OK, self.server.engine.state())
|
||
|
|
return
|
||
|
|
if path == "/api/v1/frame.jpg":
|
||
|
|
frame = self.server.engine.frame_jpeg()
|
||
|
|
if frame is None:
|
||
|
|
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
|
||
|
|
return
|
||
|
|
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
|
||
|
|
return
|
||
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
|
||
|
|
|
||
|
|
def do_PUT(self) -> None: # noqa: N802
|
||
|
|
if urlsplit(self.path).path != "/api/v1/zones/active":
|
||
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
|
||
|
|
return
|
||
|
|
raw_length = self.headers.get("Content-Length")
|
||
|
|
try:
|
||
|
|
length = int(raw_length or "-1")
|
||
|
|
except ValueError:
|
||
|
|
length = -1
|
||
|
|
if length < 0 or length > MAX_BODY_BYTES:
|
||
|
|
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
|
||
|
|
return
|
||
|
|
body = self.rfile.read(length)
|
||
|
|
supplied = self.headers.get("X-Brain-Demo-Token", "")
|
||
|
|
if not hmac.compare_digest(supplied, self.server.demo_token):
|
||
|
|
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
payload = json.loads(body.decode("utf-8"))
|
||
|
|
zone: Zone = self.server.engine.update_zone(payload)
|
||
|
|
except (UnicodeError, json.JSONDecodeError, ValueError):
|
||
|
|
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
|
||
|
|
return
|
||
|
|
self._send_json(
|
||
|
|
HTTPStatus.OK,
|
||
|
|
{
|
||
|
|
"id": zone.zone_id,
|
||
|
|
"name": zone.name,
|
||
|
|
"version": zone.version,
|
||
|
|
"points": [{"x": point.x, "y": point.y} for point in zone.points],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
|
||
|
|
html = index_path().read_text(encoding="utf-8")
|
||
|
|
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
|
||
|
|
|
||
|
|
|
||
|
|
def serve(engine: Any, bind: str) -> None:
|
||
|
|
server = build_server(engine, bind)
|
||
|
|
engine.start()
|
||
|
|
try:
|
||
|
|
server.serve_forever(poll_interval=0.25)
|
||
|
|
finally:
|
||
|
|
server.server_close()
|
||
|
|
engine.stop()
|