Files
teahub/pages/server.py
T
teahubxyzandClaude Opus 5.5 e4937e3c7f pages: apex redirects /<dir>/ only for venture pages (dir has index.html); asset folders and .well-known stay on the apex
Fixes the shot of a venture whose subdomain is not ours (aicharts.yolopreneur.lol is a Worker) and the
NIP-05 path at the apex. Also: HEAD flag no longer shadowed by the path segment (that bug emptied every GET body).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYHXFvWMCs1GUmRmpySqQ6
2026-10-14 21:39:17 +00:00

169 lines
8.1 KiB
Python

"""Teahub pages: serve each org's public `site` repo by hostname.
SITES maps hostnames to `org/repo`, or `org/repo#branch/subdir`, on GITEA (public repos only; cloned over https, no
token). A clone lives under /data/<org>__<repo>__<branch>; `POST /_hook/<org>/<repo>` (a Gitea webhook) or the
periodic loop pulls it. WILDCARDS maps a zone to a site whose top-level directories are its subdomains:
`{"yolopreneur.lol": "teahubxyz/time#gh-pages/ventures"}` serves `<sub>.yolopreneur.lol` from `ventures/<sub>/`, and
`yolopreneur.lol/<sub>/` redirects there. 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)
WILDCARDS = json.loads(os.environ.get("PAGES_WILDCARDS") or "{}") # {"zone": "org/repo#branch/dir"}: <sub>.zone -> dir/<sub>
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 parse(site: str) -> tuple[str, str, str]:
"""'org/repo#branch/sub/dir' -> (repo, branch, subdir); branch '' means the default branch."""
repo, _, rest = site.partition("#")
branch, _, sub = rest.partition("/")
return repo, branch, sub.strip("/")
def clone_dir(site: str) -> Path:
repo, branch, _ = parse(site)
return DATA / (repo.replace("/", "__") + (f"__{branch}" if branch else ""))
def site_root(site: str) -> Path:
return clone_dir(site) / parse(site)[2]
def sync(site: str) -> str:
"""Clone or fast-forward one site repo. Returns the short HEAD or an error line."""
repo, branch, _ = parse(site)
d = clone_dir(site)
lock = LOCKS.setdefault(site, 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", f"origin/{branch}" if branch else "origin/HEAD"], check=True, capture_output=True, timeout=60)
else:
subprocess.run(["git", "clone", "-q", "--depth", "1", *(["--branch", branch] if branch else []), 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()) | set(WILDCARDS.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 host(self) -> str:
return (self.headers.get("host") or "").split(":")[0].lower()
def site(self) -> tuple[str, str] | None:
"""(site, subdir) for this request's host: an exact site, its www, or a wildcard subdomain."""
host = self.host()
s = SITES.get(host) or SITES.get(host[4:] if host.startswith("www.") else "")
if s:
return s, ""
sub, _, zone = host.partition(".")
w = WILDCARDS.get(zone)
if w and sub and sub != "www" and "/" not in sub and ".." not in sub:
return w, sub
return None
def do_POST(self):
parts = urlsplit(self.path).path.strip("/").split("/")
hit = [s for s in [*SITES.values(), *WILDCARDS.values()] if len(parts) == 3 and parts[0] == "_hook" and parse(s)[0] == f"{parts[1]}/{parts[2]}"]
if hit:
self.rfile.read(int(self.headers.get("content-length") or 0))
outs = [sync(s) for s in dict.fromkeys(hit)] # every site on that repo (any branch)
body = "\n".join(outs).encode()
self.send_response(200 if not any(o.startswith("error") for o in outs) 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: site_root(r).is_dir() 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
found = self.site()
if not found:
self.send_error(404, "no site for this host")
return
repo, sub = found
root = site_root(repo).resolve()
rel = unquote(path).lstrip("/")
# on a wildcard zone's apex, /<sub>/... lives at <sub>.zone: send people there
first = rel.split("/", 1)[0]
# only folders that are a venture page (have an index.html); assets folders, .well-known etc. stay on the apex
if not sub and self.host() in WILDCARDS and first and (root / first / "index.html").is_file() and ".." not in rel:
self.send_response(301); self.send_header("location", f"https://{first}.{self.host()}/" + rel[len(first):].lstrip("/")); self.end_headers()
return
if sub:
root = (root / sub).resolve()
if not root.is_dir():
self.send_error(404, "no such venture")
return
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()