diff --git a/pages/server.py b/pages/server.py new file mode 100644 index 0000000..4c311c7 --- /dev/null +++ b/pages/server.py @@ -0,0 +1,126 @@ +"""Teahub pages: serve each org's public `site` repo by hostname. + +SITES maps hostnames to `org/repo` on GITEA (public repos only; cloned over https, no token). A clone lives under +/data/__; `POST /_hook//` (a Gitea webhook) or the periodic loop pulls it. Static files only: +a path maps to the file, a directory to its index.html, anything missing to 404.html or a plain 404. No listing, no +execution, nothing writes except git. stdlib only. +""" +import http.server +import json +import os +import subprocess +import threading +import time +from pathlib import Path +from urllib.parse import unquote, urlsplit + +GITEA = os.environ.get("GITEA_URL", "https://teahub.xyz").rstrip("/") +DATA = Path(os.environ.get("PAGES_DATA", "/data")) +SITES = json.loads(os.environ.get("PAGES_SITES") or "{}") # {"persimmons.app": "persimmons/site", ...} +PULL_EVERY = int(os.environ.get("PAGES_PULL_SECONDS") or 300) +TYPES = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon", + ".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8", ".xml": "application/xml", + ".woff2": "font/woff2", ".woff": "font/woff", ".pdf": "application/pdf", ".webmanifest": "application/manifest+json"} +LOCKS: dict[str, threading.Lock] = {} + + +def clone_dir(repo: str) -> Path: + return DATA / repo.replace("/", "__") + + +def sync(repo: str) -> str: + """Clone or fast-forward one site repo. Returns the short HEAD or an error line.""" + d = clone_dir(repo) + lock = LOCKS.setdefault(repo, threading.Lock()) + with lock: + try: + if (d / ".git").exists(): + subprocess.run(["git", "-C", str(d), "fetch", "-q", "--depth", "1", "origin"], check=True, capture_output=True, timeout=120) + subprocess.run(["git", "-C", str(d), "reset", "-q", "--hard", "origin/HEAD"], check=True, capture_output=True, timeout=60) + else: + subprocess.run(["git", "clone", "-q", "--depth", "1", f"{GITEA}/{repo}.git", str(d)], check=True, capture_output=True, timeout=180) + return subprocess.run(["git", "-C", str(d), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip() + except subprocess.CalledProcessError as e: + return f"error: {e.stderr.decode(errors='replace')[-200:]}" + except (OSError, subprocess.TimeoutExpired) as e: + return f"error: {e}" + + +def loop() -> None: + while True: + for repo in set(SITES.values()): + print(repo, sync(repo), flush=True) + time.sleep(PULL_EVERY) + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = "teahub-pages" + + def log_message(self, fmt, *args): # one line per request, no client address + print(f"{self.headers.get('host', '-')} {self.command} {self.path.split('?')[0]} {args[1] if len(args) > 1 else ''}", flush=True) + + def site(self) -> str | None: + host = (self.headers.get("host") or "").split(":")[0].lower() + return SITES.get(host) or SITES.get(host[4:] if host.startswith("www.") else "") + + def do_POST(self): + parts = urlsplit(self.path).path.strip("/").split("/") + if len(parts) == 3 and parts[0] == "_hook" and f"{parts[1]}/{parts[2]}" in SITES.values(): + self.rfile.read(int(self.headers.get("content-length") or 0)) + out = sync(f"{parts[1]}/{parts[2]}") + body = out.encode() + self.send_response(200 if not out.startswith("error") else 502) + self.send_header("content-type", "text/plain"); self.send_header("content-length", str(len(body))); self.end_headers() + self.wfile.write(body) + return + self.send_error(404) + + def do_HEAD(self): + self.do_GET(head=True) + + def do_GET(self, head: bool = False): + path = urlsplit(self.path).path + if path == "/healthz": + body = json.dumps({"ok": True, "sites": {h: (clone_dir(r) / ".git").exists() for h, r in SITES.items()}}).encode() + self.send_response(200); self.send_header("content-type", "application/json"); self.send_header("content-length", str(len(body))); self.end_headers() + if not head: + self.wfile.write(body) + return + repo = self.site() + if not repo: + self.send_error(404, "no site for this host") + return + root = clone_dir(repo).resolve() + rel = unquote(path).lstrip("/") + target = (root / rel).resolve() if rel else root + if root not in target.parents and target != root: + self.send_error(404) + return + if target.is_dir(): + if rel and not path.endswith("/"): + self.send_response(301); self.send_header("location", path + "/"); self.end_headers() + return + target = target / "index.html" + status = 200 + if not target.is_file() or ".git" in target.parts: + status, target = 404, root / "404.html" + if not target.is_file(): + self.send_error(404) + return + data = target.read_bytes() + self.send_response(status) + self.send_header("content-type", TYPES.get(target.suffix.lower(), "application/octet-stream")) + self.send_header("content-length", str(len(data))) + self.send_header("cache-control", "public, max-age=60") + self.send_header("x-content-type-options", "nosniff") + self.end_headers() + if not head: + self.wfile.write(data) + + +if __name__ == "__main__": + DATA.mkdir(parents=True, exist_ok=True) + threading.Thread(target=loop, daemon=True).start() + http.server.ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("PORT") or 8080)), Handler).serve_forever()