#!/usr/bin/env python3 """Serve production SPA and proxy only /prod-api to the task-owned loopback backend.""" import argparse from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import urllib.error import urllib.request DIST = Path(__file__).resolve().parents[2] / 'build_ui/dist' class Handler(SimpleHTTPRequestHandler): backend_port = 18096 def proxy(self): path = self.path[len('/prod-api'):] if not path.startswith('/') or path.startswith('//'): self.send_error(400) return headers = {k: v for k, v in self.headers.items() if k.lower() not in ['host', 'connection', 'content-length', 'transfer-encoding']} length = int(self.headers.get('Content-Length', '0')) if length < 0 or length > 10 * 1024 * 1024: self.send_error(413) return request = urllib.request.Request('http://127.0.0.1:' + str(self.backend_port) + path, data=self.rfile.read(length) if length else None, headers=headers, method=self.command) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kwargs): return None # Ignore all system/environment HTTP proxies, never follow a backend redirect. opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) try: response = opener.open(request, timeout=30) except urllib.error.HTTPError as error: response = error except (urllib.error.URLError, TimeoutError): self.send_error(502, 'QA backend unavailable; no substituted API response') return with response: data = response.read() self.send_response(response.code) for key, value in response.headers.items(): if key.lower() not in ['connection', 'transfer-encoding', 'content-length']: self.send_header(key, value) self.send_header('Content-Length', str(len(data))) self.end_headers() self.wfile.write(data) def do_GET(self): if self.path.startswith('/prod-api/'): return self.proxy() requested = self.path.split('?', 1)[0] if requested != '/' and not (DIST / requested.lstrip('/')).is_file() and '.' not in requested.rsplit('/', 1)[-1]: self.path = '/index.html' return super().do_GET() def do_POST(self): return self.proxy() if self.path.startswith('/prod-api/') else self.send_error(405) do_PUT = do_POST do_DELETE = do_POST def log_message(self, *args): # No query strings, headers, payloads, tokens or personal identifiers in access logs. pass if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--port', type=int, default=18088) parser.add_argument('--backend-port', type=int, default=18096) args = parser.parse_args() if not (DIST / 'index.html').is_file(): raise SystemExit('Build frontend before serving') Handler.backend_port = args.backend_port ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(DIST))).serve_forever()