219 lines
11 KiB
Python
219 lines
11 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. A `_headers` file
|
|
at a site's root adds response headers, Cloudflare Pages style (a path line, exact or ending in `*`, then indented
|
|
`Name: value` lines; a later rule wins for the same header). No listing, no execution, nothing writes except git.
|
|
stdlib only.
|
|
"""
|
|
import http.server
|
|
import json
|
|
import os
|
|
import re
|
|
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",
|
|
".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav",
|
|
".zip": "application/zip"}
|
|
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)
|
|
|
|
|
|
def extra_headers(root: Path, path: str) -> dict[str, str]:
|
|
"""Headers from `<root>/_headers` whose rule matches `path`."""
|
|
try:
|
|
lines = (root / "_headers").read_text().splitlines()
|
|
except OSError:
|
|
return {}
|
|
rules: list[tuple[str, list[tuple[str, str]]]] = []
|
|
for line in lines:
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
if not line[0].isspace():
|
|
rules.append((line.strip(), []))
|
|
elif rules and ":" in line:
|
|
name, _, value = line.strip().partition(":")
|
|
rules[-1][1].append((name.strip(), value.strip()))
|
|
out: dict[str, tuple[str, str]] = {}
|
|
for pattern, headers in rules:
|
|
if pattern == path or (pattern.endswith("*") and path.startswith(pattern[:-1])):
|
|
for name, value in headers:
|
|
out[name.lower()] = (name, value)
|
|
return dict(out.values())
|
|
|
|
|
|
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 or target.name == "_headers":
|
|
status, target = 404, root / "404.html"
|
|
if not target.is_file():
|
|
self.send_error(404)
|
|
return
|
|
data = target.read_bytes()
|
|
ctype = TYPES.get(target.suffix.lower(), "application/octet-stream")
|
|
lead = data[:64].lstrip().lower() # not `head`: that name is the HEAD-request flag
|
|
if not target.suffix and (lead.startswith(b"<!doctype html") or lead.startswith(b"<html")):
|
|
ctype = TYPES[".html"] # extensionless pages (e.g. /signin) are HTML, not downloads
|
|
extra = extra_headers(root, path)
|
|
size, span = len(data), None
|
|
m = re.fullmatch(r"bytes=(\d*)-(\d*)", (self.headers.get("range") or "").strip()) if status == 200 else None
|
|
if m and (m.group(1) or m.group(2)): # one byte range, so video can seek (Safari won't play without it)
|
|
lo, hi = (int(m.group(1)), int(m.group(2) or size - 1)) if m.group(1) else (max(0, size - int(m.group(2))), size - 1)
|
|
if lo >= size or lo > hi:
|
|
self.send_response(416); self.send_header("content-range", f"bytes */{size}"); self.send_header("content-length", "0"); self.end_headers()
|
|
return
|
|
hi = min(hi, size - 1)
|
|
status, span, data = 206, f"bytes {lo}-{hi}/{size}", data[lo:hi + 1]
|
|
self.send_response(status)
|
|
self.send_header("content-type", ctype)
|
|
self.send_header("content-length", str(len(data)))
|
|
self.send_header("accept-ranges", "bytes")
|
|
if span:
|
|
self.send_header("content-range", span)
|
|
if not any(n.lower() == "cache-control" for n in extra):
|
|
self.send_header("cache-control", "public, max-age=60")
|
|
self.send_header("x-content-type-options", "nosniff")
|
|
for name, value in extra.items():
|
|
if name.lower() not in ("content-type", "content-length", "x-content-type-options"):
|
|
self.send_header(name, value)
|
|
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()
|
|
bind = os.environ.get("PAGES_HOST") or "0.0.0.0" # 127.0.0.1 for a private copy (hb-5)
|
|
http.server.ThreadingHTTPServer((bind, int(os.environ.get("PORT") or 8080)), Handler).serve_forever()
|