Files
teahub/pages/server.py
T

127 lines
5.9 KiB
Python

"""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/<org>__<repo>; `POST /_hook/<org>/<repo>` (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()