Files
MarketingTool/apps/web/server.py
T
Marco0300 0a1de45586
CI / compose (push) Failing after 5m52s
add s-maxage=0 to cache-control header
2026-09-04 23:09:46 +02:00

160 lines
6.2 KiB
Python

#!/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 end_headers(self):
# Always revalidate HTML and JS assets so new releases are picked up.
content_type = self.headers.get("Content-Type", "")
if "text/html" in content_type or "text/javascript" in content_type or "application/javascript" in content_type:
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
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.send_header("Cache-Control", "no-store, private")
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 send_head(self):
import os
from urllib.parse import unquote
from http import HTTPStatus
path = self.translate_path(self.path)
f = None
if os.path.isdir(path):
parts = self.path.split("?", 1)
if not parts[0].endswith("/"):
self.send_response(HTTPStatus.MOVED_PERMANENTLY)
self.send_header("Location", parts[0] + "/")
self.end_headers()
return None
for index in "index.html", "index.htm":
index = os.path.join(path, index)
if os.path.exists(index):
path = index
break
else:
return self.list_directory(path)
ctype = self.guess_type(path)
try:
f = open(path, "rb")
except OSError:
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
return None
try:
fs = os.fstat(f.fileno())
content_length = str(int(fs[6]))
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", content_length)
if "text/html" in ctype or "text/javascript" in ctype or "application/javascript" in ctype:
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate, s-maxage=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
self.end_headers()
return f
except:
f.close()
raise
return result
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()