Quill: standalone read-only wiki over Git spaces (server, page, tests, sample config)

This commit is contained in:
Leora 2026-10-10 08:27:43 +08:00
commit 2a1e6933c0
10 changed files with 1253 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
config.json
data/
__pycache__/
*.pyc

21
LICENSE Normal file
View File

@ -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.

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Quill
A read-only wiki whose spaces are Git repositories. Quill renders Markdown posts (and `<article>`
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/<key>` | one space: about text, last change, posts (without bodies) |
| `GET /v1/quill/spaces/<key>/posts/<slug>` | one post with rendered `html`, `git_url`, `history_url` |
| `GET /health` | `{"ok": true}` |
With `access: "code"`, send `X-View-Code: <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).

23
config.example.json Normal file
View File

@ -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": ""
}
]
}

215
quill.html Normal file
View File

@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__WIKI_NAME__</title>
<style>
:root { color-scheme: dark; --bg: #0e0f12; --panel: #16181d; --card: #1c1f25; --line: #2a2d34; --line2: #3a3e47; --ink: #f4f1ea; --soft: #d9d4ca; --mute: #9d988e; --accent: #c9b28a; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; }
a { color: var(--ink); }
header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 10px; font: 500 22px/1 Georgia, serif; letter-spacing: 0.01em; color: var(--ink); text-decoration: none; }
.brand .mark { width: 22px; height: 22px; border-radius: 6px; background: linear-gradient(135deg, #d8c39b, #8f7a55); }
.brand small { font: 13px/1 system-ui, sans-serif; color: var(--mute); margin-left: 4px; }
header nav { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
a.nav, button { border: 1px solid var(--line2); background: transparent; color: var(--ink); border-radius: 999px; padding: 7px 12px; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; }
#who { color: var(--mute); font-size: 13px; }
.shell { flex: 1; display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 0; }
aside { border-right: 1px solid var(--line); padding: 18px 12px; overflow-y: auto; background: var(--panel); }
aside h2 { margin: 4px 8px 10px; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mute); font-weight: 600; }
.space { display: block; padding: 9px 10px; border-radius: 10px; text-decoration: none; color: var(--soft); margin-bottom: 2px; }
.space:hover { background: var(--card); color: var(--ink); }
.space.on { background: var(--card); color: var(--ink); box-shadow: inset 2px 0 0 var(--accent); }
.space small { display: block; color: var(--mute); font-size: 12px; margin-top: 2px; }
.side-note { margin: 22px 8px 0; color: #6d6962; font-size: 12px; }
.side-note code { color: var(--mute); }
main { overflow-y: auto; padding: 28px 32px 60px; }
.wrap { max-width: 760px; margin: 0 auto; }
.crumbs { font-size: 13px; color: var(--mute); margin: 0 0 14px; }
.crumbs a { color: var(--mute); text-decoration: none; }
.crumbs a:hover { color: var(--ink); }
h1 { margin: 0 0 10px; font: 500 38px/1.15 Georgia, serif; }
.lede { color: var(--soft); font: 17px/1.55 Georgia, serif; margin: 0 0 6px; }
.about p { color: var(--soft); font: 16px/1.6 Georgia, serif; margin: 0 0 10px; }
.meta { color: var(--mute); font-size: 13px; }
.meta a { color: var(--mute); }
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 16px 0 26px; }
.bar .meta { flex-basis: 100%; }
.foot.bar .meta { flex-basis: auto; margin-right: auto; }
h3.sec { margin: 26px 0 10px; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mute); font-weight: 600; }
.post { display: block; padding: 16px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); text-decoration: none; color: var(--ink); margin-bottom: 10px; }
.post:hover { border-color: var(--line2); background: var(--card); }
.post h4 { margin: 0 0 6px; font: 500 21px/1.25 Georgia, serif; }
.post p { margin: 6px 0 0; color: var(--soft); }
.empty { color: var(--mute); padding: 18px; border: 1px dashed var(--line2); border-radius: 14px; }
article.read { font: 18px/1.65 Georgia, serif; color: var(--soft); }
article.read h2, article.read h3, article.read h4 { color: var(--ink); font-weight: 500; margin: 1.4em 0 0.5em; }
article.read p { margin: 0 0 1em; }
article.read a { color: var(--accent); }
article.read code { font: 0.85em ui-monospace, monospace; background: var(--panel); padding: 1px 5px; border-radius: 5px; }
article.read pre { background: var(--panel); border: 1px solid var(--line); padding: 12px 14px; border-radius: 10px; overflow-x: auto; }
article.read pre code { background: none; padding: 0; }
article.read blockquote { margin: 0 0 1em; padding: 2px 16px; border-left: 3px solid var(--line2); color: var(--mute); }
article.read hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
.foot { margin-top: 34px; padding-top: 14px; border-top: 1px solid var(--line); }
#status { color: var(--mute); }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); padding: 10px 12px; display: flex; gap: 6px; overflow-x: auto; }
aside h2, .side-note { display: none; }
.space { white-space: nowrap; }
.space small { display: none; }
main { padding: 20px 16px 48px; }
h1 { font-size: 30px; }
.brand small, #who { display: none; }
}
</style>
</head>
<body>
<header>
<a class="brand" id="home" href="/quill"><span class="mark" aria-hidden="true"></span>__WIKI_NAME__<small>wiki</small></a>
<nav>
__NAV_LINKS__
<span id="who"></span>
</nav>
</header>
<div class="shell">
<aside>
<h2>Spaces</h2>
<div id="spaces"></div>
<p class="side-note">Every space is a Git repository. To write, push to it: branch, pull request, merge. __WIKI_NAME__ shows the merged result.</p>
</aside>
<main><div class="wrap" id="view"><p id="status">Loading…</p></div></main>
</div>
<script>
"use strict";
// Routes: <base>, <base>/<space>, <base>/<space>/<post-slug>. <base> is /quill or /wiki.
const BASE = location.pathname.startsWith("/wiki") ? "/wiki" : "/quill";
const WIKI = document.title;
const view = document.getElementById("view");
const spacesEl = document.getElementById("spaces");
document.getElementById("home").href = BASE;
let spaces = [];
const cache = {};
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function link(href, text, cls) { const a = el("a", cls, text); a.href = href; return a; }
function external(href, text, cls) { const a = link(href, text, cls); a.target = "_blank"; a.rel = "noopener"; return a; }
async function api(url) {
const res = await fetch(url, { credentials: "same-origin" });
if (res.status === 401) { location.href = "/view?next=" + encodeURIComponent(location.pathname); throw new Error("signed_out"); }
let data = {};
try { data = await res.json(); } catch (e) {}
if (!res.ok) { const err = new Error(data.error || "failed"); err.status = res.status; throw err; }
return data;
}
function when(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function byline(p) { return [p.date, p.author ? "by " + p.author : ""].filter(Boolean).join(" \u00b7 "); }
function route() {
const rest = location.pathname.slice(BASE.length).replace(/^\/+|\/+$/g, "");
const parts = rest ? rest.split("/").map(decodeURIComponent) : [];
return { space: parts[0] || "", post: parts[1] || "" };
}
function go(href) { history.pushState(null, "", href); render(); main.scrollTop = 0; }
const main = document.querySelector("main");
document.addEventListener("click", (e) => {
const a = e.target.closest("a");
if (!a || a.target || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
const url = new URL(a.href, location.href);
if (url.origin === location.origin && (url.pathname === BASE || url.pathname.startsWith(BASE + "/"))) { e.preventDefault(); go(url.pathname); }
});
window.addEventListener("popstate", render);
function drawSidebar(active) {
spacesEl.replaceChildren(...spaces.map((s) => {
const a = link(BASE + "/" + s.key, s.name, "space" + (s.key === active ? " on" : ""));
a.append(el("small", null, s.repo));
return a;
}));
}
function fail(err, what) {
const box = el("div", "empty");
box.textContent = err.status === 404 ? what + " not found." : "Could not load " + what.toLowerCase() + " from Git right now.";
box.append(" ", link(BASE, "Back to " + WIKI));
view.replaceChildren(box);
}
async function loadSpace(key) {
if (!cache[key] || Date.now() - cache[key].at > 15000) cache[key] = { at: Date.now(), data: await api("/v1/quill/spaces/" + encodeURIComponent(key)) };
return cache[key].data;
}
async function showSpace(key) {
let data;
try { data = await loadSpace(key); } catch (e) { if (e.message !== "signed_out") fail(e, "Space"); return; }
const s = data.space;
document.title = s.name + " \u00b7 " + WIKI;
const out = [];
const crumbs = el("p", "crumbs"); crumbs.append(link(BASE, WIKI), " / ", s.name); out.push(crumbs);
out.push(el("h1", null, s.name));
if (s.description) out.push(el("p", "lede", s.description));
const bar = el("div", "bar");
const lc = data.last_change || {};
const m = el("span", "meta", lc.at ? "Last change " + when(lc.at) + (lc.author ? " by " + lc.author : "") + (lc.message ? " \u2014 " + lc.message : "") : "");
bar.append(m, link(s.git_url, "Source in Git", "nav"));
if (s.public_url) bar.append(external(s.public_url, "Published site", "nav"));
out.push(bar);
if (data.about_html) { const about = el("div", "about"); about.innerHTML = data.about_html; out.push(about); }
out.push(el("h3", "sec", (data.posts.length || "No") + (data.posts.length === 1 ? " article" : " articles")));
if (!data.posts.length) out.push(el("p", "empty", "Nothing here yet. Push a Markdown file to " + s.repo + " and merge it."));
data.posts.forEach((p) => {
const a = link(BASE + "/" + s.key + "/" + encodeURIComponent(p.slug), null, "post");
a.append(el("h4", null, p.title));
const b = byline(p); if (b) a.append(el("span", "meta", b));
if (p.summary) a.append(el("p", null, p.summary));
out.push(a);
});
const foot = el("p", "meta foot");
foot.append("Write here: branch from ", el("code", null, data.branch), " in ", link(s.git_url, s.repo), ", add a Markdown file (or an ", el("code", null, "<article>"), " in index.html), open a pull request, merge.");
out.push(foot);
view.replaceChildren(...out);
}
async function showPost(key, slug) {
let data;
try { data = await api("/v1/quill/spaces/" + encodeURIComponent(key) + "/posts/" + encodeURIComponent(slug)); } catch (e) { if (e.message !== "signed_out") fail(e, "Article"); return; }
const s = data.space, p = data.post;
document.title = p.title + " \u00b7 " + s.name + " \u00b7 " + WIKI;
const crumbs = el("p", "crumbs"); crumbs.append(link(BASE, WIKI), " / ", link(BASE + "/" + s.key, s.name), " / ", p.title);
const h = el("h1", null, p.title);
const meta = el("p", "meta", byline(p));
const body = el("article", "read"); body.innerHTML = p.html; // sanitized server-side (whitelist)
const foot = el("div", "bar foot");
foot.append(el("span", "meta", "From " + s.repo + " \u00b7 " + p.source), link(p.git_url, "View source", "nav"), link(p.history_url, "History", "nav"), link(BASE + "/" + s.key, "All articles", "nav"));
view.replaceChildren(crumbs, h, meta, body, foot);
}
async function render() {
const r = route();
if (!spaces.length) {
try { spaces = (await api("/v1/quill/spaces")).spaces || []; } catch (e) { if (e.message !== "signed_out") fail(e, "Spaces"); return; }
}
const key = r.space || (spaces[0] && spaces[0].key) || "";
drawSidebar(key);
if (!key) { view.replaceChildren(el("p", "empty", "No spaces yet.")); return; }
if (r.post) await showPost(key, r.post); else await showSpace(key);
}
render();
</script>
</body>
</html>

563
quill_core.py Normal file
View File

@ -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.
* <article> 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/<key>).
# 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+&quot;[^)]*&quot;)?\)")
_BOLD = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")
_ITAL = re.compile(r"(?<![\w*])([*_])(?=\S)(.+?)(?<=\S)\1(?![\w*])")
_AUTOLINK = 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("<code>" + m.group(1) + "</code>")
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'<a href="{html.escape(href, quote=True)}"{ext}>{label}</a>')
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'<a href="{html.escape(url, quote=True)}" rel="noopener" target="_blank">{m.group(1)}</a>')
return f"\x01{len(links) - 1}\x01"
text = _AUTOLINK.sub(auto, text)
text = _BOLD.sub(r"<strong>\2</strong>", text)
text = _ITAL.sub(r"<em>\2</em>", 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("<p>" + _inline(" ".join(s.strip() for s in para), space, branch, base_dir) + "</p>")
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("<pre><code>" + html.escape("\n".join(code)) + "</code></pre>")
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"<h{level}>" + _inline(h.group(2), space, branch, base_dir) + f"</h{level}>")
i += 1
continue
if re.fullmatch(r"(\*\s*){3,}|(-\s*){3,}|(_\s*){3,}", stripped):
flush()
out.append("<hr>")
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("<blockquote>" + render_markdown("\n".join(quote), space, branch, base_dir) + "</blockquote>")
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("<li>" + _inline(it, space, branch, base_dir) + "</li>" for it in items) + f"</{tag}>")
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 <article> 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'<a href="{html.escape(href, quote=True)}"{ext}>')
else:
self._buf.append("<a>")
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 </html> 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"</{tag}>")
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])>(.*?)</\1>", 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"<p>(.*?)</p>", 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"<p>(.*?)</p>", 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("<p>" + html.escape(t) + "</p>" 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"}

6
run.sh Executable file
View File

@ -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

211
server.py Executable file
View File

@ -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(
' <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()

0
tests/__init__.py Normal file
View File

134
tests/test_quill.py Normal file
View File

@ -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 <script>alert(1)</script>\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("<script>", data["post"]["html"])
self.assertTrue(data["post"]["git_url"].startswith("https://git.example.test/demo/space/src/branch/main/"))
def test_unknown_space(self):
self.assertEqual(quill_core.api("/v1/quill/spaces/nope", server.gitea_get)[0], 404)
def test_bad_config(self):
with self.assertRaises(ValueError):
quill_core.configure([{"key": "Bad Key", "repo": "a/b"}])
quill_core.configure([{"key": "demo", "name": "Demo", "repo": "demo/space"}], "https://git.example.test")
def test_view_code_flow(self):
s, h, _ = self.req("GET", "/quill")
self.assertEqual(s, 302)
self.assertTrue(h["Location"].startswith("/view?next="))
self.assertEqual(self.req("GET", "/v1/quill")[0], 401)
self.assertEqual(self.req("GET", "/v1/quill", headers={"X-View-Code": "letmein"})[0], 200)
s, _, _ = self.req("POST", "/view", body="code=wrong&next=/quill", headers={"Content-Type": "application/x-www-form-urlencoded"})
self.assertEqual(s, 401)
s, h, _ = self.req("POST", "/view", body="code=letmein&next=/quill/demo", headers={"Content-Type": "application/x-www-form-urlencoded"})
self.assertEqual(s, 303)
self.assertEqual(h["Location"], "/quill/demo")
cookie = h["Set-Cookie"].split(";")[0]
s, _, body = self.req("GET", "/quill/demo", headers={"Cookie": cookie})
self.assertEqual(s, 200)
self.assertIn(b"Test Quill", body)
self.assertIn(b"https://git.example.test/", body)
s, _, body = self.req("GET", "/v1/quill/spaces", headers={"Cookie": cookie})
self.assertEqual(json.loads(body)["spaces"][0]["key"], "demo")
def test_open_redirect_blocked(self):
s, h, _ = self.req("POST", "/view", body="code=letmein&next=//evil.example", headers={"Content-Type": "application/x-www-form-urlencoded"})
self.assertEqual(h["Location"], "/quill")
def test_read_only(self):
for m in ("PUT", "PATCH", "DELETE"):
self.assertEqual(self.req(m, "/v1/quill/spaces/demo", body=b"{}")[0], 405)
self.assertEqual(self.req("POST", "/v1/quill", body=b"{}")[0], 405)
def test_health(self):
self.assertEqual(self.req("GET", "/health")[0], 200)
if __name__ == "__main__":
unittest.main()