diff options
Diffstat (limited to 'tools/serve.py')
| -rwxr-xr-x | tools/serve.py | 205 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
1 file changed, 205 insertions, 0 deletions
diff --git a/tools/serve.py b/tools/serve.py new file mode 100755 index 0000000..62e29d1 --- /dev/null +++ b/tools/serve.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Local preview server for cgit. + +cgit is a CGI program: it reads the request from environment variables and +writes an HTTP response to stdout. This wraps it in a tiny stdlib-only HTTP +server so the interface can be previewed in a browser during development, +without configuring Apache/nginx. It is a development aid, not a production +server. + +Static assets (cgit.css, cgit.js, images) are served straight from disk; +every other request is handed to the cgit binary as CGI. + +Usage: + python3 tools/serve.py --config path/to/cgitrc [--port 8080] + +If --config is omitted, ./cgitrc in the current directory is used. +""" + +import argparse +import mimetypes +import os +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Bounds so a single request cannot exhaust the dev server. It only ever +# serves a browser on loopback, so these are generous. +MAX_BODY_BYTES = 8 * 1024 * 1024 +CGI_TIMEOUT = 60 + + +def parse_cgi_output(raw): + """Split a CGI response into (status, headers, body).""" + header_blob, _, body = raw.partition(b"\r\n\r\n") + if not body and b"\n\n" in raw: + header_blob, _, body = raw.partition(b"\n\n") + + status = 200 + reason = "OK" + headers = [] + for line in header_blob.replace(b"\r\n", b"\n").split(b"\n"): + if not line.strip(): + continue + name, _, value = line.partition(b":") + name = name.strip().decode("latin-1") + value = value.strip().decode("latin-1") + if name.lower() == "status": + parts = value.split(None, 1) + try: + status = int(parts[0]) + except (IndexError, ValueError): + status = 200 + reason = parts[1] if len(parts) > 1 else "" + else: + headers.append((name, value)) + return status, reason, headers, body + + +class Handler(BaseHTTPRequestHandler): + server_version = "cgit-preview" + + # Only these are served straight from disk; everything else is CGI. + static_suffixes = (".css", ".js", ".png", ".ico", ".gif", ".jpg", + ".jpeg", ".svg", ".webp", ".txt", ".woff", ".woff2") + + def do_GET(self): + self.handle_request() + + def do_HEAD(self): + self.handle_request() + + def do_POST(self): + self.handle_request() + + def static_path(self, url_path): + name = url_path.lstrip("/") + if not name or "/" in name: + return None + if not name.lower().endswith(self.static_suffixes): + return None + candidate = (self.server.data_dir / name).resolve() + if candidate.parent != self.server.data_dir or not candidate.is_file(): + return None + return candidate + + def serve_static(self, path): + ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + data = path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(data) + + def serve_cgi(self, url_path, query): + body = b"" + try: + length = int(self.headers.get("Content-Length", 0)) + except ValueError: + length = 0 + if length < 0: + length = 0 + if length > MAX_BODY_BYTES: + self.send_error(413, "Request body too large") + return + if length: + body = self.rfile.read(length) + + env = dict(os.environ) + env.update( + GATEWAY_INTERFACE="CGI/1.1", + SERVER_PROTOCOL="HTTP/1.1", + SERVER_SOFTWARE=self.server_version, + SERVER_NAME=self.server.server_name, + SERVER_PORT=str(self.server.server_port), + REQUEST_METHOD=self.command, + SCRIPT_NAME="", + PATH_INFO=url_path, + QUERY_STRING=query, + REQUEST_URI=self.path, + HTTP_HOST=self.headers.get("Host", "localhost"), + CGIT_CONFIG=str(self.server.config), + ) + if length: + env["CONTENT_LENGTH"] = str(length) + if self.headers.get("Content-Type"): + env["CONTENT_TYPE"] = self.headers["Content-Type"] + + try: + result = subprocess.run( + [str(self.server.cgit)], input=body, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=CGI_TIMEOUT, + ) + except subprocess.TimeoutExpired: + self.send_error(504, "cgit timed out") + return + if result.returncode != 0: + self.send_error(500, "cgit failed") + sys.stderr.write(result.stderr.decode("latin-1", "replace")) + return + + status, reason, headers, out = parse_cgi_output(result.stdout) + self.send_response(status, reason) + for name, value in headers: + self.send_header(name, value) + if not any(n.lower() == "content-length" for n, _ in headers): + self.send_header("Content-Length", str(len(out))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(out) + + def handle_request(self): + url_path, _, query = self.path.partition("?") + static = self.static_path(url_path) + if static: + self.serve_static(static) + else: + self.serve_cgi(url_path, query) + + def log_message(self, fmt, *args): + sys.stderr.write(" %s\n" % (fmt % args)) + + +def main(): + parser = argparse.ArgumentParser(description="Preview cgit locally.") + parser.add_argument("--config", default="cgitrc", + help="path to cgitrc (default: ./cgitrc)") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--cgit", default=str(REPO_ROOT / "build" / "cgit"), + help="path to the cgit binary") + parser.add_argument("--data", default=str(REPO_ROOT / "assets"), + help="directory holding cgit.css, cgit.js, images") + args = parser.parse_args() + + config = Path(args.config).resolve() + cgit = Path(args.cgit).resolve() + for label, path in (("config", config), ("cgit binary", cgit)): + if not path.exists(): + sys.exit("error: %s not found: %s" % (label, path)) + + mimetypes.add_type("text/css", ".css") + mimetypes.add_type("application/javascript", ".js") + + httpd = ThreadingHTTPServer((args.host, args.port), Handler) + httpd.config = config + httpd.cgit = cgit + httpd.data_dir = Path(args.data).resolve() + + url = "http://%s:%d/" % (args.host, args.port) + sys.stderr.write("cgit preview serving %s\n config: %s\n press Ctrl-C to stop\n" + % (url, config)) + try: + httpd.serve_forever() + except KeyboardInterrupt: + sys.stderr.write("\nstopped\n") + + +if __name__ == "__main__": + main() |
