212 lines
8.8 KiB
Python
Executable File
212 lines
8.8 KiB
Python
Executable File
#!/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(
|
|
' <a class="nav" href="' + html.escape(str(item.get("href") or "#"), quote=True) + '">'
|
|
+ html.escape(str(item.get("label") or "")) + "</a>\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 = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>%s</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
|
|
input,button{font:inherit;padding:.6rem .8rem;border-radius:.5rem;border:1px solid #bbb}button{cursor:pointer}</style>
|
|
<h1>%s</h1><p>Enter the view code to read this wiki.</p>
|
|
<form method="post" action="/view"><input type="hidden" name="next" value="%s">
|
|
<input name="code" type="password" autocomplete="off" required autofocus> <button>Open</button></form>%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), "<p>Wrong code.</p>")).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()
|