#!/usr/bin/env python3 """Same-origin static web server with a narrow internal API reverse proxy.""" from __future__ import annotations import http.client import json import os from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlsplit MAX_PROXY_BODY = 5 * 1024 * 1024 UPSTREAM_HOST = os.environ.get("API_UPSTREAM_HOST", "api") UPSTREAM_PORT = int(os.environ.get("API_UPSTREAM_PORT", "8000")) class ProxyStaticHandler(SimpleHTTPRequestHandler): proxy_api = True def _send_json_error(self, status: int, code: str) -> None: payload = json.dumps({"error": code}, separators=(",", ":")).encode() self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store, private") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def _proxy_request(self) -> None: parsed = urlsplit(self.path) if parsed.path == "/api" or parsed.path.startswith("/api/"): target = self.path else: self._send_json_error(404, "not_found") return try: length = int(self.headers.get("Content-Length", "0")) except ValueError: self._send_json_error(400, "invalid_content_length") return if length > MAX_PROXY_BODY: self._send_json_error(413, "request_body_too_large") return body = self.rfile.read(length) if length else None headers = { key: value for key, value in self.headers.items() if key.lower() in {"accept", "content-type", "cookie", "user-agent", "x-request-id"} } headers["Host"] = f"{UPSTREAM_HOST}:{UPSTREAM_PORT}" connection = None try: connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=15) connection.request(self.command, target, body=body, headers=headers) response = connection.getresponse() payload = response.read(MAX_PROXY_BODY + 1) if len(payload) > MAX_PROXY_BODY: self._send_json_error(502, "upstream_response_too_large") return self.send_response(response.status, response.reason) for key, value in response.getheaders(): if key.lower() in {"content-type", "content-length", "cache-control", "location", "set-cookie"}: self.send_header(key, value) self.end_headers() self.wfile.write(payload) except (OSError, http.client.HTTPException) as exc: self._send_json_error(502, "api_upstream_unavailable") finally: if connection is not None: connection.close() def do_GET(self): if self.path.startswith("/api"): return self._proxy_request() return super().do_GET() def do_POST(self): if self.path.startswith("/api"): return self._proxy_request() return self.send_error(405) def do_PATCH(self): if self.path.startswith("/api"): return self._proxy_request() return self.send_error(405) def do_DELETE(self): if self.path.startswith("/api"): return self._proxy_request() return self.send_error(405) def do_OPTIONS(self): if self.path.startswith("/api"): return self._proxy_request() return super().do_OPTIONS() def log_message(self, format, *args): # Keep request logs useful without echoing cookies or bodies. super().log_message(format, *args) if __name__ == "__main__": port = int(os.environ.get("WEB_PORT", "8080")) server = ThreadingHTTPServer(("0.0.0.0", port), ProxyStaticHandler) print(f"ProspectOS web server listening on http://0.0.0.0:{port}", flush=True) server.serve_forever()