Quill: standalone read-only wiki over Git spaces (server, page, tests, sample config)
This commit is contained in:
commit
2a1e6933c0
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
config.json
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
76
README.md
Normal 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
23
config.example.json
Normal 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
215
quill.html
Normal 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
563
quill_core.py
Normal 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+"[^)]*")?\)")
|
||||||
|
_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
6
run.sh
Executable 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
211
server.py
Executable 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
0
tests/__init__.py
Normal file
134
tests/test_quill.py
Normal file
134
tests/test_quill.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user