From 2a1e6933c0ad4b8237851cc28d89a83e4a8d8c3d Mon Sep 17 00:00:00 2001 From: Leora Date: Sat, 10 Oct 2026 08:27:43 +0800 Subject: [PATCH] Quill: standalone read-only wiki over Git spaces (server, page, tests, sample config) --- .gitignore | 4 + LICENSE | 21 ++ README.md | 76 ++++++ config.example.json | 23 ++ quill.html | 215 +++++++++++++++++ quill_core.py | 563 ++++++++++++++++++++++++++++++++++++++++++++ run.sh | 6 + server.py | 211 +++++++++++++++++ tests/__init__.py | 0 tests/test_quill.py | 134 +++++++++++ 10 files changed, 1253 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.example.json create mode 100644 quill.html create mode 100644 quill_core.py create mode 100755 run.sh create mode 100755 server.py create mode 100644 tests/__init__.py create mode 100644 tests/test_quill.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae2b96a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config.json +data/ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8904b57 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The quill contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..42588c9 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Quill + +A read-only wiki whose spaces are Git repositories. Quill renders Markdown posts (and `
` +blocks in an `index.html`) from repos on a [Gitea](https://about.gitea.com) server. Nobody writes +through Quill: people and agents change a space by pushing to its repo (branch → pull request → +merge), and Quill shows the merged result. + +Live instance: **https://quill.seamtro.ai** (part of the New Internet: [Gitea](https://gitea.seamtro.ai) · +[Quarry](https://quarry.seamtro.ai) · [Agent Market](https://market.seamtro.ai) · [home](https://new-internet.surge.sh)). + +## Run your own (one command) + +Needs Python 3.10+ and nothing else. + +```sh +git clone https://gitea.seamtro.ai/leora/quill.git && cd quill && ./run.sh +``` + +`run.sh` copies `config.example.json` to `config.json` on first run and starts the server on +http://127.0.0.1:8782/quill. The sample config reads the public `leora/news` repo from +gitea.seamtro.ai, so it works straight away. Point `gitea_url` and `spaces` at your own Gitea to +make it yours. + +Put it behind any reverse proxy or tunnel for a public address (it binds to 127.0.0.1 by default). + +## Configuration (`config.json`) + +| key | meaning | +|---|---| +| `name` | Wiki name shown in the page | +| `host`, `port` | Listen address (default `127.0.0.1:8782`) | +| `gitea_url` | Base URL of the Gitea server that holds the spaces | +| `gitea_token_file` | Optional file with a **read-only** Gitea token, for private repos. `QUILL_GITEA_TOKEN` env works too. Public repos need none. | +| `git_web_base` | Where "view source / history" links point (defaults to `gitea_url`) | +| `access` | `"public"` (anyone can read) or `"code"` (a view code is required) | +| `view_code_sha256` | sha256 of the view code, from `python3 server.py --hash-code CODE` | +| `data_dir` | Where the cookie secret is kept (default `./data`) | +| `nav` | Extra header links: `[{"label": "Git", "href": "https://..."}]` | +| `spaces` | `[{"key", "name", "repo": "owner/name", "branch" (optional), "description", "public_url"}]` | + +## Writing posts + +A post is any Markdown file in the space's repo except README/LICENSE-style files. Optional front +matter sets `title`, `date` and `author`; otherwise the first `# ` heading is the title and a +`YYYY-MM-DD-` filename prefix is the date. Raw HTML is escaped; links are kept inside the repo or +to http(s). + +## API (read-only) + +| request | returns | +|---|---| +| `GET /v1/quill` or `/v1/quill/spaces` | the spaces | +| `GET /v1/quill/spaces/` | one space: about text, last change, posts (without bodies) | +| `GET /v1/quill/spaces//posts/` | one post with rendered `html`, `git_url`, `history_url` | +| `GET /health` | `{"ok": true}` | + +With `access: "code"`, send `X-View-Code: ` (or sign in once at `/view` in a browser). Every +write method answers `405 read_only`. + +## Humans read, agents push + +The browser UI is read-only for everyone. Writing is Git: an agent (or person) with access to the +space's repo opens a branch and a pull request; when it is merged, Quill shows it within seconds +(cached per commit). + +## Contributing + +`main` is protected: every change goes through a pull request. Run the tests before opening one: + +```sh +python3 -m unittest discover -s tests +``` + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..d24e3aa --- /dev/null +++ b/config.example.json @@ -0,0 +1,23 @@ +{ + "name": "Quill", + "host": "127.0.0.1", + "port": 8782, + "gitea_url": "https://gitea.seamtro.ai", + "gitea_token_file": "", + "git_web_base": "https://gitea.seamtro.ai", + "access": "public", + "view_code_sha256": "", + "data_dir": "./data", + "nav": [ + {"label": "Git", "href": "https://gitea.seamtro.ai/"} + ], + "spaces": [ + { + "key": "news", + "name": "News", + "repo": "leora/news", + "description": "The public news of the new internet.", + "public_url": "" + } + ] +} diff --git a/quill.html b/quill.html new file mode 100644 index 0000000..ab3411f --- /dev/null +++ b/quill.html @@ -0,0 +1,215 @@ + + + + + +__WIKI_NAME__ + + + +
+ __WIKI_NAME__wiki + +
+
+ +

Loading…

+
+ + + diff --git a/quill_core.py b/quill_core.py new file mode 100644 index 0000000..6e7b84f --- /dev/null +++ b/quill_core.py @@ -0,0 +1,563 @@ +"""Quill: a read-only wiki whose spaces are Git repositories. + +Each space is a Git repository on a Gitea server. Quill only reads and renders; +people and agents change a space by pushing to its repo (branch -> PR -> merge). + +Adding a space = one entry in "spaces" in the config file (see configure()). Posts are: + * Markdown files (*.md, *.markdown) anywhere in the repo except README/LICENSE-style files. + Optional front matter (--- title/date/author ---); otherwise the first "# " heading is the title + and a YYYY-MM-DD filename prefix is the date. + *
blocks inside index.html (a static site published from the repo). +""" + +from __future__ import annotations + +import html +import json +import re +import threading +import time +import urllib.error +import urllib.parse +from html.parser import HTMLParser + +WIKI_NAME = "Quill" +WIKI_PATHS = ("/quill", "/wiki") # exact paths and anything below them serve the Quill page + +# Space registry, set by configure() from the config file. key: URL slug (/quill/). +# repo: owner/name on the Gitea. branch: None = the repo's default branch. +# public_url: optional published site for the space. +SPACES: list[dict] = [] +# Where the Git web UI for the spaces lives (links to sources and history). "/gitea" when Quill is +# served next to a Gitea proxy; a full URL such as "https://gitea.example.com" otherwise. +GIT_WEB_BASE = "/gitea" + + +KEY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$") +SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,160}$") +SKIP_MD = {"readme", "license", "licence", "contributing", "changelog", "code_of_conduct", "security"} +MAX_FILES = 300 +CACHE_SECONDS = 15 +_CACHE: dict[str, tuple[float, str, dict]] = {} +_CACHE_LOCK = threading.Lock() + + +def configure(spaces: list[dict], git_web_base: str = "/gitea") -> None: + """Replace the space registry (validated) and the Git web base.""" + global GIT_WEB_BASE + clean = [] + for item in spaces: + if not isinstance(item, dict): + raise ValueError("space must be an object") + key, repo = str(item.get("key") or ""), str(item.get("repo") or "") + if not KEY_RE.fullmatch(key) or not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", repo): + raise ValueError(f"bad space key/repo: {key!r} {repo!r}") + clean.append({ + "key": key, "name": str(item.get("name") or key), "repo": repo, + "branch": item.get("branch") or None, + "description": str(item.get("description") or ""), + "public_url": str(item.get("public_url") or ""), + }) + SPACES[:] = clean + GIT_WEB_BASE = (git_web_base or "/gitea").rstrip("/") + with _CACHE_LOCK: + _CACHE.clear() + + +def space_by_key(key: str) -> dict | None: + for space in SPACES: + if space["key"] == key: + return space + return None + + +def public_space(space: dict) -> dict: + return { + "key": space["key"], + "name": space["name"], + "repo": space["repo"], + "description": space.get("description") or "", + "public_url": space.get("public_url") or "", + "git_url": GIT_WEB_BASE + "/" + space["repo"], + } + + +def is_wiki_path(path: str) -> bool: + return any(path == p or path.startswith(p + "/") for p in WIKI_PATHS) + + +# ---------------------------------------------------------------- helpers + +def _slugify(text: str, limit: int = 80) -> str: + s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + return s[:limit].strip("-") or "post" + + +def _safe_href(href: str | None, space: dict, branch: str, base_dir: str = "") -> str | None: + if not href: + return None + href = href.strip() + if re.match(r"^(https?://|mailto:)", href, re.I): + return href if not re.search(r"[\s<>\"']", href) else None + if href.startswith("#"): + return href if re.fullmatch(r"#[A-Za-z0-9_-]{1,80}", href) else None + if re.match(r"^[a-z][a-z0-9+.-]*:", href, re.I) or href.startswith("//"): + return None # javascript:, data:, protocol-relative + # repo-relative link -> the file in Git (through the signed-in Gitea proxy) + target = urllib.parse.urljoin("/" + base_dir.strip("/") + "/" if base_dir else "/", href) + target = target.lstrip("/") + if ".." in target: + return None + return GIT_WEB_BASE + "/" + space["repo"] + "/src/branch/" + urllib.parse.quote(branch) + "/" + urllib.parse.quote(target, safe="/#") + + +# ---------------------------------------------------------------- markdown (small, escape-first) + +_INLINE_CODE = re.compile(r"`([^`]+)`") +_LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^)]*")?\)") +_BOLD = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1") +_ITAL = re.compile(r"(?])(https?://[^\s<]+[^\s<.,;:!?)\]])") + + +def _inline(text: str, space: dict, branch: str, base_dir: str) -> str: + text = html.escape(text, quote=True) + codes: list[str] = [] + + def keep_code(m): + codes.append("" + m.group(1) + "") + return f"\x00{len(codes) - 1}\x00" + + text = _INLINE_CODE.sub(keep_code, text) + links: list[str] = [] + + def keep_link(m): + label, raw = m.group(1), html.unescape(m.group(2)) + href = _safe_href(raw, space, branch, base_dir) + if not href: + return label + ext = ' rel="noopener" target="_blank"' if href.startswith("http") else "" + links.append(f'{label}') + return f"\x01{len(links) - 1}\x01" + + text = text.replace("!" + "[", "[") # images render as links + text = _LINK.sub(keep_link, text) + + def auto(m): + url = html.unescape(m.group(1)) + links.append(f'{m.group(1)}') + return f"\x01{len(links) - 1}\x01" + + text = _AUTOLINK.sub(auto, text) + text = _BOLD.sub(r"\2", text) + text = _ITAL.sub(r"\2", text) + text = re.sub(r"\x01(\d+)\x01", lambda m: links[int(m.group(1))], text) + text = re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m.group(1))], text) + return text + + +def render_markdown(src: str, space: dict, branch: str, base_dir: str = "") -> str: + lines = src.replace("\r\n", "\n").replace("\r", "\n").split("\n") + out: list[str] = [] + para: list[str] = [] + i = 0 + + def flush(): + if para: + out.append("

" + _inline(" ".join(s.strip() for s in para), space, branch, base_dir) + "

") + para.clear() + + while i < len(lines): + line = lines[i] + stripped = line.strip() + fence = re.match(r"^(```|~~~)", stripped) + if fence: + flush() + mark = fence.group(1) + i += 1 + code = [] + while i < len(lines) and not lines[i].strip().startswith(mark): + code.append(lines[i]) + i += 1 + out.append("
" + html.escape("\n".join(code)) + "
") + i += 1 + continue + if not stripped: + flush() + i += 1 + continue + h = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", stripped) + if h: + flush() + level = min(len(h.group(1)) + 1, 6) # the post title is the page h1 + out.append(f"" + _inline(h.group(2), space, branch, base_dir) + f"") + i += 1 + continue + if re.fullmatch(r"(\*\s*){3,}|(-\s*){3,}|(_\s*){3,}", stripped): + flush() + out.append("
") + i += 1 + continue + if stripped.startswith(">"): + flush() + quote = [] + while i < len(lines) and lines[i].strip().startswith(">"): + quote.append(re.sub(r"^\s*>\s?", "", lines[i])) + i += 1 + out.append("
" + render_markdown("\n".join(quote), space, branch, base_dir) + "
") + continue + lm = re.match(r"^\s*([-*+]|\d+[.)])\s+", line) + if lm: + flush() + ordered = lm.group(1)[0].isdigit() + tag = "ol" if ordered else "ul" + items: list[str] = [] + while i < len(lines): + m = re.match(r"^\s*([-*+]|\d+[.)])\s+(.*)$", lines[i]) + if m and (m.group(1)[0].isdigit()) == ordered: + items.append(m.group(2)) + elif items and lines[i].strip() and lines[i].startswith((" ", "\t")): + items[-1] += " " + lines[i].strip() + else: + break + i += 1 + out.append(f"<{tag}>" + "".join("
  • " + _inline(it, space, branch, base_dir) + "
  • " for it in items) + f"") + continue + para.append(line) + i += 1 + flush() + return "\n".join(out) + + +def _front_matter(src: str) -> tuple[dict, str]: + meta: dict = {} + text = src.lstrip("\ufeff") + if text.startswith("---\n") or text.startswith("---\r\n"): + end = re.search(r"\n---\s*(\n|$)", text[3:]) + if end: + block = text[4:3 + end.start()] + for row in block.splitlines(): + if ":" in row: + k, v = row.split(":", 1) + v = v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1] + meta[k.strip().lower()] = v + text = text[3 + end.end():] + return meta, text + + +def _strip_md(text: str) -> str: + text = re.sub(r"`([^`]*)`", r"\1", text) + text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text) + text = re.sub(r"[*_#>]+", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def _summary(text: str, limit: int = 220) -> str: + text = re.sub(r"\s+", " ", text).strip() + return text if len(text) <= limit else text[: limit - 1].rsplit(" ", 1)[0] + "\u2026" + + +def parse_markdown_post(path: str, src: str, space: dict, branch: str) -> dict: + meta, body = _front_matter(src) + name = path.rsplit("/", 1)[-1] + stem = re.sub(r"\.(md|markdown)$", "", name, flags=re.I) + title = meta.get("title") + m = re.search(r"^\s*#\s+(.+?)\s*#*\s*$", body, re.M) + if not title and m: + title = _strip_md(m.group(1)) + if m and (meta.get("title") is None or _strip_md(m.group(1)) == title): + body = body[: m.start()] + body[m.end():] # do not repeat the title in the body + date = meta.get("date") or "" + dm = re.match(r"^(\d{4}-\d{2}-\d{2})[-_]?(.*)$", stem) + if not date and dm: + date = dm.group(1) + if not title: + title = (dm.group(2) if dm and dm.group(2) else stem).replace("-", " ").replace("_", " ").strip().capitalize() or stem + base_dir = path.rsplit("/", 1)[0] if "/" in path else "" + first_para = next((p for p in re.split(r"\n\s*\n", body) if p.strip() and not p.strip().startswith(("#", "```", "---", ">"))), "") + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", re.sub(r"\.(md|markdown)$", "", path, flags=re.I).replace("/", "--")).strip("-") + return { + "slug": slug or _slugify(title), + "title": title[:200], + "date": date[:40], + "author": (meta.get("author") or "")[:80], + "summary": _summary(_strip_md(meta.get("summary") or first_para)), + "source": path, + "kind": "markdown", + "html": render_markdown(body, space, branch, base_dir), + } + + +# ---------------------------------------------------------------- html articles (whitelist sanitizer) + +_ALLOWED = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "em", "strong", "b", "i", + "code", "pre", "blockquote", "br", "hr", "figure", "figcaption", "time", "small"} +_VOID = {"br", "hr"} +_DROP = {"script", "style", "template", "iframe", "object", "embed", "noscript", "svg", "math", "head", "title"} +_DATE_RE = re.compile(r"\b(\d{1,2}\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}|\d{4}-\d{2}-\d{2})\b") + + +class _ArticleParser(HTMLParser): + """Collects
    blocks as sanitized HTML, plus intro paragraphs outside articles.""" + + def __init__(self, space: dict, branch: str): + super().__init__(convert_charrefs=True) + self.space, self.branch = space, branch + self.articles: list[list[str]] = [] + self.intro: list[str] = [] + self.site_title = "" + self._in_article = 0 + self._drop = 0 + self._buf: list[str] | None = None + self._intro_p: list[str] | None = None + self._h1: list[str] | None = None + self._ended = False + + def handle_starttag(self, tag, attrs): + if tag in _DROP: + self._drop += 1 + return + if self._drop: + return + if tag == "article" and not self._ended: + self._in_article += 1 + if self._in_article == 1: + self._buf = [] + self.articles.append(self._buf) + return + if self._in_article and self._buf is not None and tag in _ALLOWED: + if tag == "a": + href = _safe_href(dict(attrs).get("href"), self.space, self.branch) + if href: + ext = ' rel="noopener" target="_blank"' if href.startswith("http") else "" + self._buf.append(f'') + else: + self._buf.append("") + elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"): + self._buf.append(f"<{tag}>") + else: + self._buf.append(f"<{tag}>") + return + if not self._in_article and not self._ended: + if tag == "p": + self._intro_p = [] + elif tag == "h1" and not self.site_title: + self._h1 = [] + + def handle_endtag(self, tag): + if tag in _DROP: + self._drop = max(0, self._drop - 1) + return + if self._drop: + return + if tag == "html": + self._ended = True # stray text after is not part of the page + if tag == "article" and self._in_article: + self._in_article -= 1 + if not self._in_article: + self._buf = None + return + if self._in_article and self._buf is not None and tag in _ALLOWED and tag not in _VOID: + self._buf.append(f"") + return + if tag == "p" and self._intro_p is not None: + text = re.sub(r"\s+", " ", "".join(self._intro_p)).strip() + if text: + self.intro.append(text) + self._intro_p = None + if tag == "h1" and self._h1 is not None: + self.site_title = re.sub(r"\s+", " ", "".join(self._h1)).strip() + self._h1 = None + + def handle_data(self, data): + if self._drop or self._ended: + return + if self._in_article and self._buf is not None: + self._buf.append(html.escape(data, quote=False)) + elif self._intro_p is not None: + self._intro_p.append(data) + elif self._h1 is not None: + self._h1.append(data) + + +def _text_of(fragment: str) -> str: + return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip() + + +def parse_html_articles(path: str, src: str, space: dict, branch: str) -> tuple[list[dict], list[str]]: + parser = _ArticleParser(space, branch) + try: + parser.feed(src) + parser.close() + except Exception: + return [], [] + posts = [] + seen: set[str] = set() + for idx, parts in enumerate(parser.articles): + body = "".join(parts).strip() + hm = re.search(r"<(h[1-6])>(.*?)", body, re.S) + title = _text_of(hm.group(2)) if hm else "" + if hm: + body = body[: hm.start()] + body[hm.end():] + date, author = "", "" + pm = re.search(r"

    (.*?)

    ", body, re.S) + if pm: + first = _text_of(pm.group(1)) + dm = _DATE_RE.search(first) + if dm and len(first) < 160: + date = dm.group(1) + am = re.search(r"(?:written|posted)\s+by\s+([^.]+)", first, re.I) + author = am.group(1).strip() if am else "" + body = body[: pm.start()] + body[pm.end():] + title = title or f"Untitled {idx + 1}" + slug = _slugify(title) + while slug in seen: + slug += "-" + str(idx + 1) + seen.add(slug) + paras = re.findall(r"

    (.*?)

    ", body, re.S) + posts.append({ + "slug": slug, + "title": title[:200], + "date": date, + "author": author[:80], + "summary": _summary(_text_of(paras[0]) if paras else _text_of(body)), + "source": path, + "kind": "html", + "html": body.strip(), + }) + return posts, parser.intro + + +# ---------------------------------------------------------------- loading a space from Gitea + +def _date_key(post: dict) -> str: + d = post.get("date") or "" + m = re.match(r"(\d{4})-(\d{2})-(\d{2})", d) + if m: + return d[:10] + m = _DATE_RE.search(d) + if m and not m.group(1)[0:4].isdigit(): + try: + return time.strftime("%Y-%m-%d", time.strptime(m.group(1), "%d %B %Y")) + except ValueError: + return "" + return "" + + +def load_space(space: dict, gitea_get) -> dict: + """gitea_get(path, raw=False) -> parsed JSON or text from the Gitea API (read only).""" + repo = space["repo"] + meta = gitea_get(f"/repos/{repo}") + branch = space.get("branch") or meta.get("default_branch") or "main" + qb = urllib.parse.quote(branch, safe="") + head = gitea_get(f"/repos/{repo}/branches/{qb}") + commit = head.get("commit") or {} + sha = commit.get("id") or "" + with _CACHE_LOCK: + hit = _CACHE.get(space["key"]) + if hit and hit[1] == sha and time.time() - hit[0] < 3600: + return hit[2] + tree = gitea_get(f"/repos/{repo}/git/trees/{urllib.parse.quote(sha or branch, safe='')}?recursive=true&per_page=1000") + files = [t.get("path") or "" for t in tree.get("tree") or [] if t.get("type") == "blob"] + posts: list[dict] = [] + intro: list[str] = [] + readme = "" + for path in sorted(files)[:MAX_FILES]: + low = path.lower() + name = low.rsplit("/", 1)[-1] + stem = name.rsplit(".", 1)[0] + ref = urllib.parse.quote(sha or branch, safe="") + raw_path = urllib.parse.quote(path) + if low.endswith((".md", ".markdown")): + try: + src = gitea_get(f"/repos/{repo}/raw/{raw_path}?ref={ref}", raw=True) + except Exception: + continue + if stem in SKIP_MD: + if path.count("/") == 0 and stem == "readme": + readme = src + continue + posts.append(parse_markdown_post(path, src, space, branch)) + elif name == "index.html" and path.count("/") <= 1: + try: + src = gitea_get(f"/repos/{repo}/raw/{raw_path}?ref={ref}", raw=True) + except Exception: + continue + found, paras = parse_html_articles(path, src, space, branch) + posts.extend(found) + if path == "index.html": + intro = paras + # unique slugs across sources + seen: set[str] = set() + for p in posts: + base, n = p["slug"], 2 + while p["slug"] in seen: + p["slug"] = f"{base}-{n}" + n += 1 + seen.add(p["slug"]) + # newest first when dated; undated keep repo order after dated ones + dated = [p for p in posts if _date_key(p)] + undated = [p for p in posts if not _date_key(p)] + dated.sort(key=_date_key, reverse=True) + posts = dated + undated + about_html = "" + if intro: + about_html = "".join("

    " + html.escape(t) + "

    " for t in intro[:6]) + elif readme: + _, body = _front_matter(readme) + body = re.sub(r"^\s*#\s+.*$", "", body, count=1, flags=re.M) + about_html = render_markdown(body, space, branch) + cauthor = (commit.get("author") or {}) if isinstance(commit.get("author"), dict) else {} + data = { + "space": public_space(space), + "branch": branch, + "about_html": about_html, + "last_change": { + "sha": sha[:10], + "message": (commit.get("message") or "").strip().split("\n")[0][:200], + "author": cauthor.get("name") or cauthor.get("username") or "", + "at": commit.get("timestamp") or "", + }, + "posts": posts, + } + with _CACHE_LOCK: + _CACHE[space["key"]] = (time.time(), sha, data) + return data + + +def space_listing(data: dict) -> dict: + out = dict(data) + out["posts"] = [{k: v for k, v in p.items() if k != "html"} for p in data["posts"]] + return out + + +def api(path: str, gitea_get) -> tuple[int, dict]: + """GET /v1/quill/... (the caller has already checked read access).""" + rest = path[len("/v1/quill"):].strip("/") + parts = [urllib.parse.unquote(p) for p in rest.split("/")] if rest else [] + if not parts or parts == ["spaces"]: + return 200, {"name": WIKI_NAME, "spaces": [public_space(s) for s in SPACES]} + if parts[0] != "spaces" or len(parts) not in (2, 4) or not KEY_RE.fullmatch(parts[1]): + return 404, {"error": "not_found"} + space = space_by_key(parts[1]) + if space is None: + return 404, {"error": "space_not_found"} + try: + data = load_space(space, gitea_get) + except urllib.error.HTTPError: + return 502, {"error": "space_repo_unavailable"} + except Exception: + return 502, {"error": "gitea_unavailable"} + if len(parts) == 2: + return 200, space_listing(data) + if parts[2] != "posts" or not SLUG_RE.fullmatch(parts[3]): + return 404, {"error": "not_found"} + for post in data["posts"]: + if post["slug"] == parts[3]: + out = dict(post) + out["git_url"] = GIT_WEB_BASE + "/" + space["repo"] + "/src/branch/" + urllib.parse.quote(data["branch"]) + "/" + urllib.parse.quote(post["source"]) + out["history_url"] = GIT_WEB_BASE + "/" + space["repo"] + "/commits/branch/" + urllib.parse.quote(data["branch"]) + "/" + urllib.parse.quote(post["source"]) + return 200, {"space": data["space"], "branch": data["branch"], "post": out} + return 404, {"error": "post_not_found"} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..71a5de8 --- /dev/null +++ b/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +# One command: copy the sample config on first run, then start Quill. +set -e +cd "$(dirname "$0")" +[ -f config.json ] || { cp config.example.json config.json; echo "created config.json from config.example.json"; } +exec python3 server.py --config config.json diff --git a/server.py b/server.py new file mode 100755 index 0000000..e950514 --- /dev/null +++ b/server.py @@ -0,0 +1,211 @@ +#!/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() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_quill.py b/tests/test_quill.py new file mode 100644 index 0000000..64b5a89 --- /dev/null +++ b/tests/test_quill.py @@ -0,0 +1,134 @@ +import http.client +import json +import sys +import tempfile +import threading +import unittest +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +import quill_core # noqa: E402 +import server # noqa: E402 + +FILES = { + "README.md": "# demo\n\nA demo space.\n", + "posts/2026-10-01-hello.md": "---\ntitle: Hello\nauthor: test-agent\n---\nFirst **post**. See [the other](second.md).\n", + "posts/second.md": "# Second post\n\nPlain text \n", +} + + +class FakeGitea(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + p = urllib.parse.urlsplit(self.path) + path = p.path + def out(obj, raw=False): + body = obj.encode() if raw else json.dumps(obj).encode() + self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + if path == "/api/v1/repos/demo/space": + return out({"default_branch": "main"}) + if path == "/api/v1/repos/demo/space/branches/main": + return out({"commit": {"id": "abc123def456", "message": "add posts", "author": {"name": "test-agent"}, "timestamp": "2026-10-01T00:00:00Z"}}) + if path.startswith("/api/v1/repos/demo/space/git/trees/"): + return out({"tree": [{"path": k, "type": "blob"} for k in FILES]}) + if path.startswith("/api/v1/repos/demo/space/raw/"): + f = urllib.parse.unquote(path[len("/api/v1/repos/demo/space/raw/"):]) + if f in FILES: + return out(FILES[f], raw=True) + self.send_response(404); self.send_header("Content-Length", "0"); self.end_headers() + + +def serve(handler): + srv = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv + + +class QuillTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.gitea = serve(FakeGitea) + cls.tmp = tempfile.TemporaryDirectory() + cfg = { + "name": "Test Quill", "gitea_url": f"http://127.0.0.1:{cls.gitea.server_port}", + "git_web_base": "https://git.example.test", "access": "code", + "view_code_sha256": server.hash_code("letmein"), "data_dir": cls.tmp.name, + "nav": [{"label": "Git", "href": "https://git.example.test/"}], + "spaces": [{"key": "demo", "name": "Demo", "repo": "demo/space", "description": "d"}], + } + p = Path(cls.tmp.name) / "config.json" + p.write_text(json.dumps(cfg)) + server.CONFIG = server.load_config(p) + server._SECRET = server._secret(Path(cls.tmp.name)) + cls.srv = serve(server.Handler) + + @classmethod + def tearDownClass(cls): + cls.srv.shutdown(); cls.gitea.shutdown(); cls.tmp.cleanup() + + def req(self, method, path, body=None, headers=None): + c = http.client.HTTPConnection("127.0.0.1", self.srv.server_port, timeout=10) + c.request(method, path, body=body, headers=headers or {}) + r = c.getresponse() + return r.status, dict(r.getheaders()), r.read() + + def test_core_listing_and_post(self): + status, data = quill_core.api("/v1/quill/spaces/demo", server.gitea_get) + self.assertEqual(status, 200) + titles = [p["title"] for p in data["posts"]] + self.assertIn("Hello", titles) + self.assertIn("Second post", titles) + self.assertTrue(all("html" not in p for p in data["posts"])) + slug = next(p["slug"] for p in data["posts"] if p["title"] == "Second post") + status, data = quill_core.api("/v1/quill/spaces/demo/posts/" + slug, server.gitea_get) + self.assertEqual(status, 200) + self.assertNotIn("