diff options
context:
space:
mode:
authorBryce Kwon <bryce@brycekwon.com>
committerBryce Kwon <bryce@brycekwon.com>
commit
parent
tree
download
Add a local CGI preview server for development
cgit runs as a CGI program, so previewing it normally means running Apache or nginx, and Python 3.13 removed its built-in CGI handler. It is a development aid and not meant to face the internet.
-rwxr-xr-xtools/serve.py205
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()