#!/usr/bin/env python3 """Quill server: a read-only wiki over Git repositories on a Gitea server. python3 server.py [--config config.json] Standard library only (Python 3.10+). People read; agents and people write by pushing to the space's repository (branch -> pull request -> merge). Quill never writes anywhere. """ from __future__ import annotations import argparse import hashlib import hmac import html import json import os import secrets import sys import urllib.error import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import quill_core HERE = Path(__file__).resolve().parent CONFIG: dict = {} COOKIE = "quill_view" _SECRET = b"" def load_config(path: str | os.PathLike) -> dict: cfg = json.loads(Path(path).read_text(encoding="utf-8")) cfg.setdefault("name", "Quill") cfg.setdefault("host", "127.0.0.1") cfg.setdefault("port", 8782) cfg.setdefault("access", "public") cfg.setdefault("nav", []) if cfg["access"] not in ("public", "code"): raise ValueError('access must be "public" or "code"') if cfg["access"] == "code" and not cfg.get("view_code_sha256"): raise ValueError('access "code" needs view_code_sha256 (python3 server.py --hash-code CODE)') gitea = str(cfg.get("gitea_url") or "").rstrip("/") if not gitea.startswith(("http://", "https://")): raise ValueError("gitea_url must be an http(s) URL") cfg["gitea_url"] = gitea quill_core.WIKI_NAME = str(cfg["name"]) quill_core.configure(cfg.get("spaces") or [], cfg.get("git_web_base") or gitea) return cfg def _token() -> str: env = os.environ.get("QUILL_GITEA_TOKEN", "").strip() if env: return env f = CONFIG.get("gitea_token_file") or "" if f and Path(f).expanduser().is_file(): return Path(f).expanduser().read_text(encoding="utf-8").strip() return "" def gitea_get(path: str, raw: bool = False): """Read-only GET against the Gitea API; a token is optional (public repos need none).""" req = urllib.request.Request(CONFIG["gitea_url"] + "/api/v1" + path, headers={"User-Agent": "quill/1"}) tok = _token() if tok: req.add_header("Authorization", "token " + tok) with urllib.request.urlopen(req, timeout=15) as res: body = res.read().decode("utf-8", "replace") return body if raw else json.loads(body) def hash_code(code: str) -> str: return hashlib.sha256(code.encode("utf-8")).hexdigest() def _view_cookie_value() -> str: return hmac.new(_SECRET, b"quill-view", hashlib.sha256).hexdigest() def _page() -> bytes: nav = "".join( ' ' + html.escape(str(item.get("label") or "")) + "\n" for item in CONFIG.get("nav") or [] if isinstance(item, dict) ) page = (HERE / "quill.html").read_text(encoding="utf-8") return page.replace("__WIKI_NAME__", html.escape(CONFIG["name"])).replace("__NAV_LINKS__", nav).encode("utf-8") VIEW_FORM = """ %s

%s

Enter the view code to read this wiki.

%s""" class Handler(BaseHTTPRequestHandler): server_version = "Quill/1.0" def log_message(self, fmt, *args): sys.stderr.write("%s - %s %s\n" % (self.address_string(), self.command, urllib.parse.urlsplit(self.path).path)) def _send(self, status: int, body: bytes, ctype: str, extra: dict | None = None) -> None: self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "same-origin") for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def _json(self, status: int, data: dict) -> None: self._send(status, json.dumps(data).encode("utf-8"), "application/json") def _can_read(self) -> bool: if CONFIG["access"] == "public": return True for part in (self.headers.get("Cookie") or "").split(";"): k, _, v = part.strip().partition("=") if k == COOKIE and hmac.compare_digest(v, _view_cookie_value()): return True code = self.headers.get("X-View-Code") or "" return bool(code) and hmac.compare_digest(hash_code(code), CONFIG["view_code_sha256"]) def do_HEAD(self): # noqa: N802 self.do_GET() def do_GET(self): # noqa: N802 parts = urllib.parse.urlsplit(self.path) path = parts.path.rstrip("/") or "/" if path == "/health": return self._json(200, {"ok": True}) if path == "/": return self._send(302, b"", "text/plain", {"Location": "/quill"}) if path == "/view": nxt = urllib.parse.parse_qs(parts.query).get("next", ["/quill"])[0] if not nxt.startswith("/") or nxt.startswith("//"): nxt = "/quill" name = html.escape(CONFIG["name"]) return self._send(200, (VIEW_FORM % (name, name, html.escape(nxt, quote=True), "")).encode(), "text/html; charset=utf-8") if quill_core.is_wiki_path(path): if not self._can_read(): return self._send(302, b"", "text/plain", {"Location": "/view?next=" + urllib.parse.quote(path, safe="")}) return self._send(200, _page(), "text/html; charset=utf-8") if path == "/v1/quill" or path.startswith("/v1/quill/"): if not self._can_read(): return self._json(401, {"error": "view_code_required"}) status, body = quill_core.api(path, gitea_get) return self._json(status, body) return self._json(404, {"error": "not_found"}) def do_POST(self): # noqa: N802 path = urllib.parse.urlsplit(self.path).path if path != "/view" or CONFIG["access"] != "code": return self._json(405, {"error": "read_only", "message": "Quill is read-only. Write by pushing to the space's Git repo."}) try: n = min(int(self.headers.get("Content-Length") or 0), 4096) except ValueError: n = 0 form = urllib.parse.parse_qs(self.rfile.read(n).decode("utf-8", "replace")) code = (form.get("code") or [""])[0] nxt = (form.get("next") or ["/quill"])[0] if not nxt.startswith("/") or nxt.startswith("//"): nxt = "/quill" if code and hmac.compare_digest(hash_code(code), CONFIG["view_code_sha256"]): cookie = f"{COOKIE}={_view_cookie_value()}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000" if CONFIG.get("secure_cookie", True) and not self.headers.get("Host", "").startswith(("127.0.0.1", "localhost")): cookie += "; Secure" return self._send(303, b"", "text/plain", {"Location": nxt, "Set-Cookie": cookie}) name = html.escape(CONFIG["name"]) return self._send(401, (VIEW_FORM % (name, name, html.escape(nxt, quote=True), "

Wrong code.

")).encode(), "text/html; charset=utf-8") def do_PUT(self): # noqa: N802 self._json(405, {"error": "read_only"}) do_PATCH = do_DELETE = do_PUT def _secret(data_dir: Path) -> bytes: data_dir.mkdir(parents=True, exist_ok=True) f = data_dir / "cookie-secret" if not f.is_file(): f.write_text(secrets.token_hex(32), encoding="utf-8") f.chmod(0o600) return f.read_text(encoding="utf-8").strip().encode() def main(argv=None) -> None: global CONFIG, _SECRET ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--config", default=os.environ.get("QUILL_CONFIG", str(HERE / "config.json"))) ap.add_argument("--hash-code", metavar="CODE", help="print the sha256 for view_code_sha256 and exit") a = ap.parse_args(argv) if a.hash_code: print(hash_code(a.hash_code)) return CONFIG = load_config(a.config) _SECRET = _secret(Path(CONFIG.get("data_dir") or HERE / "data").expanduser()) srv = ThreadingHTTPServer((CONFIG["host"], int(CONFIG["port"])), Handler) print(f"{CONFIG['name']} on http://{CONFIG['host']}:{CONFIG['port']}/quill ({len(quill_core.SPACES)} spaces, access={CONFIG['access']})", flush=True) srv.serve_forever() if __name__ == "__main__": main()