OXYGENHUB

Wikis: Markdown pages as an Oxygen repository of their own 66fd9d6b1452

Each repository can have a wiki under <data>/wikis, read and edited on
the web (history, sidebar, page list, search) as changes authored by
the user and signed by the hub as their delegate, or cloned and pushed
as OWNER/NAME.wiki with the repository's permissions. The Wiki tab
shows when a wiki exists or the viewer may start one; Advanced deletes
the wiki data; a repository cannot be named *.wiki. closes #104
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 17:14 UTC
Signature
✔ signed by Andy McCall
Hash
66fd9d6b145201f17500e55103fc78c7608571da3222858c350fa6d0fe925006
Depends on
145306044896 18e381ea88c2 19bb31ff1f5c 43e8ca33bc5d 4ac01a732552 749d9a5fcd9f 756fe44a0f4d 8fb35df7d66e 93be9ce4653e b5668108487f b64e2537d702 d0fbea2eb46e d23464c0ac60 de505df5cf8d e271a37b629a
Depended on by
1062a0f8e985 40a43921ef42 84e3c09656b2 c7067c7bda56 0ee5c3fb32eb 7c06f5b28ade 16ee5d19d220 a7aa76ac52d7 3eb491e9489b c8684d44d549 ffb67a81188c 61ecbd299f22 a0dd06b0094e 7b26e6118bc0 a024e800af4e 6e7dfa0a42b9 1f78655a0466 83940d53664f
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
39 atoms, 6 tree operations, 17 files

Browse the tree at this change

added crates/hub/src/web/wiki.rs +379 −0
1 +
//! Wiki pages on the web (hub spec §6.11, oxygenhub#104): view, edit,
2 +
//! history, the page list with search, and raw files for images.
3 +
4 +
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
5 +
use crate::auth::{Access, Viewer};
6 +
use crate::db::accounts;
7 +
use crate::repos::view::ChangeView;
8 +
use crate::wiki;
9 +
use crate::Shared;
10 +
use askama::Template;
11 +
use axum::extract::{Path, Query, State};
12 +
use axum::response::{IntoResponse, Redirect, Response};
13 +
use axum::Form;
14 +
use serde::Deserialize;
15 +
16 +
#[derive(Template)]
17 +
#[template(path = "wiki.html")]
18 +
pub struct WikiPage {
19 +
    shell: Shell,
20 +
    repo: RepoShell,
21 +
    slug: String,
22 +
    title: String,
23 +
    /// Rendered Markdown, or `None` when there is no such page.
24 +
    html: Option<String>,
25 +
    sidebar: Option<String>,
26 +
    /// Whether the wiki has any page at all.
27 +
    any: bool,
28 +
    can_edit: bool,
29 +
    last: Option<ChangeView>,
30 +
    notice: Option<String>,
31 +
}
32 +
33 +
#[derive(Template)]
34 +
#[template(path = "wiki_edit.html")]
35 +
pub struct WikiEditPage {
36 +
    shell: Shell,
37 +
    repo: RepoShell,
38 +
    slug: String,
39 +
    title: String,
40 +
    content: String,
41 +
    /// Whether the page exists (an edit) or is new.
42 +
    exists: bool,
43 +
    error: Option<String>,
44 +
}
45 +
46 +
#[derive(Template)]
47 +
#[template(path = "wiki_pages.html")]
48 +
pub struct WikiPagesPage {
49 +
    shell: Shell,
50 +
    repo: RepoShell,
51 +
    pages: Vec<String>,
52 +
    q: String,
53 +
    hits: Vec<(String, String)>,
54 +
    can_edit: bool,
55 +
}
56 +
57 +
#[derive(Template)]
58 +
#[template(path = "wiki_history.html")]
59 +
pub struct WikiHistoryPage {
60 +
    shell: Shell,
61 +
    repo: RepoShell,
62 +
    slug: String,
63 +
    title: String,
64 +
    entries: Vec<ChangeView>,
65 +
}
66 +
67 +
fn base(owner: &str, name: &str) -> String {
68 +
    format!("/{owner}/{name}/wiki/")
69 +
}
70 +
71 +
fn render(owner: &str, name: &str, text: &str) -> String {
72 +
    crate::render::markdown::render(text, &base(owner, name), &format!("/{owner}/{name}/wiki/_raw/"))
73 +
}
74 +
75 +
/// Whether the viewer may change the wiki: a writer, and not archived.
76 +
fn editable(row: &crate::db::repos::RepoRow, rs: &RepoShell) -> bool {
77 +
    rs.can(Access::Write) && !row.archived
78 +
}
79 +
80 +
#[derive(Deserialize)]
81 +
pub struct ShowQuery {
82 +
    notice: Option<String>,
83 +
}
84 +
85 +
/// `GET /{owner}/{name}/wiki`: the Home page.
86 +
pub async fn home(
87 +
    State(app): State<Shared>,
88 +
    viewer: Viewer,
89 +
    Path((owner, name)): Path<(String, String)>,
90 +
    Query(q): Query<ShowQuery>,
91 +
) -> Result<Page<WikiPage>, Error> {
92 +
    show_page(app, viewer, owner, name, wiki::HOME.into(), q.notice).await
93 +
}
94 +
95 +
/// `GET /{owner}/{name}/wiki/{slug}`.
96 +
pub async fn show(
97 +
    State(app): State<Shared>,
98 +
    viewer: Viewer,
99 +
    Path((owner, name, slug)): Path<(String, String, String)>,
100 +
    Query(q): Query<ShowQuery>,
101 +
) -> Result<Page<WikiPage>, Error> {
102 +
    show_page(app, viewer, owner, name, slug, q.notice).await
103 +
}
104 +
105 +
async fn show_page(
106 +
    app: Shared,
107 +
    viewer: Viewer,
108 +
    owner: String,
109 +
    name: String,
110 +
    slug: String,
111 +
    notice: Option<String>,
112 +
) -> Result<Page<WikiPage>, Error> {
113 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer);
114 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
115 +
    if !wiki::slug_ok(&slug) {
116 +
        return Err(Error::not_found(shell));
117 +
    }
118 +
    let e = |e| Error::from_repo(shell.clone(), e);
119 +
    let text = wiki::page(&app, &repo, &slug).await.map_err(e)?;
120 +
    let sidebar = wiki::page(&app, &repo, wiki::SIDEBAR).await.map_err(e)?;
121 +
    let any = !wiki::pages(&app, &repo).await.map_err(e)?.is_empty();
122 +
    let last = if text.is_some() { wiki::history(&app, &repo, &slug, 1).await.map_err(e)?.into_iter().next() } else { None };
123 +
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role);
124 +
    let can_edit = editable(&row, &rs);
125 +
    Ok(Page(WikiPage {
126 +
        html: text.as_deref().map(|t| render(&owner, &name, t)),
127 +
        sidebar: sidebar.as_deref().map(|t| render(&owner, &name, t)),
128 +
        title: wiki::title_of(&slug),
129 +
        shell,
130 +
        repo: rs,
131 +
        slug,
132 +
        any,
133 +
        can_edit,
134 +
        last,
135 +
        notice,
136 +
    }))
137 +
}
138 +
139 +
#[derive(Deserialize)]
140 +
pub struct PagesQuery {
141 +
    #[serde(default)]
142 +
    q: String,
143 +
}
144 +
145 +
/// `GET /{owner}/{name}/wiki/_pages`: every page, and a search.
146 +
pub async fn pages(
147 +
    State(app): State<Shared>,
148 +
    viewer: Viewer,
149 +
    Path((owner, name)): Path<(String, String)>,
150 +
    Query(q): Query<PagesQuery>,
151 +
) -> Result<Page<WikiPagesPage>, Error> {
152 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_pages"), &viewer);
153 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
154 +
    let e = |e| Error::from_repo(shell.clone(), e);
155 +
    let pages = wiki::pages(&app, &repo).await.map_err(e)?;
156 +
    let q: String = q.q.trim().chars().take(100).collect();
157 +
    let hits = if q.is_empty() { vec![] } else { wiki::search(&app, &repo, &q).await.map_err(e)? };
158 +
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role);
159 +
    let can_edit = editable(&row, &rs);
160 +
    Ok(Page(WikiPagesPage { shell, repo: rs, pages, q, hits, can_edit }))
161 +
}
162 +
163 +
/// `GET /{owner}/{name}/wiki/{slug}/history`.
164 +
pub async fn history(
165 +
    State(app): State<Shared>,
166 +
    viewer: Viewer,
167 +
    Path((owner, name, slug)): Path<(String, String, String)>,
168 +
) -> Result<Page<WikiHistoryPage>, Error> {
169 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/history"), &viewer);
170 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
171 +
    if !wiki::slug_ok(&slug) {
172 +
        return Err(Error::not_found(shell));
173 +
    }
174 +
    let mut entries = wiki::history(&app, &repo, &slug, 100).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
175 +
    super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
176 +
    Ok(Page(WikiHistoryPage {
177 +
        title: wiki::title_of(&slug),
178 +
        shell,
179 +
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
180 +
        slug,
181 +
        entries,
182 +
    }))
183 +
}
184 +
185 +
#[derive(Deserialize)]
186 +
pub struct NewQuery {
187 +
    #[serde(default)]
188 +
    title: String,
189 +
}
190 +
191 +
/// `GET /{owner}/{name}/wiki/_new`: a new page, named by the form.
192 +
pub async fn new_form(
193 +
    State(app): State<Shared>,
194 +
    viewer: Viewer,
195 +
    Path((owner, name)): Path<(String, String)>,
196 +
    Query(q): Query<NewQuery>,
197 +
) -> Result<Page<WikiEditPage>, Error> {
198 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_new"), &viewer);
199 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
200 +
    let slug = wiki::slug_of(&q.title);
201 +
    Ok(Page(WikiEditPage {
202 +
        title: if slug.is_empty() { String::new() } else { wiki::title_of(&slug) },
203 +
        shell,
204 +
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
205 +
        slug,
206 +
        content: String::new(),
207 +
        exists: false,
208 +
        error: None,
209 +
    }))
210 +
}
211 +
212 +
/// `GET /{owner}/{name}/wiki/{slug}/edit`.
213 +
pub async fn edit_form(
214 +
    State(app): State<Shared>,
215 +
    viewer: Viewer,
216 +
    Path((owner, name, slug)): Path<(String, String, String)>,
217 +
) -> Result<Page<WikiEditPage>, Error> {
218 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer);
219 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
220 +
    if !wiki::slug_ok(&slug) {
221 +
        return Err(Error::not_found(shell));
222 +
    }
223 +
    let text = wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
224 +
    Ok(Page(WikiEditPage {
225 +
        title: wiki::title_of(&slug),
226 +
        shell,
227 +
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
228 +
        slug,
229 +
        exists: text.is_some(),
230 +
        content: text.unwrap_or_default(),
231 +
        error: None,
232 +
    }))
233 +
}
234 +
235 +
#[derive(Deserialize)]
236 +
pub struct SaveForm {
237 +
    #[serde(rename = "_csrf")]
238 +
    csrf_token: String,
239 +
    /// The page's title; its slug is the file name. A changed title on an
240 +
    /// existing page renames it.
241 +
    #[serde(default)]
242 +
    title: String,
243 +
    #[serde(default)]
244 +
    content: String,
245 +
    #[serde(default)]
246 +
    message: String,
247 +
}
248 +
249 +
/// `POST /{owner}/{name}/wiki/{slug}/edit` (`_new` for a new page): the
250 +
/// change is authored by the user and signed by the hub as their delegate.
251 +
pub async fn save(
252 +
    State(app): State<Shared>,
253 +
    viewer: Viewer,
254 +
    Path((owner, name, slug)): Path<(String, String, String)>,
255 +
    Form(f): Form<SaveForm>,
256 +
) -> Result<Response, Error> {
257 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer);
258 +
    let me = super::settings::require(&viewer, &shell)?;
259 +
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
260 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
261 +
    let is_new = slug == "_new";
262 +
    let old_slug = if is_new { String::new() } else { slug.clone() };
263 +
    let new_slug = wiki::slug_of(&f.title);
264 +
    let content = f.content.replace("\r\n", "\n");
265 +
    let again = |msg: String| {
266 +
        Page(WikiEditPage {
267 +
            title: f.title.clone(),
268 +
            shell: shell.clone(),
269 +
            repo: RepoShell::new(&app, &row, "wiki").with_role(role),
270 +
            slug: if is_new { new_slug.clone() } else { old_slug.clone() },
271 +
            content: content.clone(),
272 +
            exists: !is_new,
273 +
            error: Some(msg),
274 +
        })
275 +
        .into_response()
276 +
    };
277 +
    if !is_new && !wiki::slug_ok(&old_slug) {
278 +
        return Err(Error::not_found(shell));
279 +
    }
280 +
    if !wiki::slug_ok(&new_slug) {
281 +
        return Ok(again("A page title is letters, digits, spaces, dots, dashes and underscores, up to 100 characters.".into()));
282 +
    }
283 +
    if content.trim().is_empty() {
284 +
        return Ok(again("Write something on the page.".into()));
285 +
    }
286 +
    let signer = match super::edit::signer_for(&app, me.account.id).await {
287 +
        Ok(s) => s,
288 +
        Err(msg) => return Ok(again(msg)),
289 +
    };
290 +
    let renamed = !is_new && new_slug != old_slug;
291 +
    let taken = wiki::page(&app, &repo, &new_slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_some();
292 +
    if (is_new || renamed) && taken {
293 +
        return Ok(again(format!("There is already a page called {}.", wiki::title_of(&new_slug))));
294 +
    }
295 +
    let message = if f.message.trim().is_empty() {
296 +
        format!(
297 +
            "{} {}",
298 +
            if is_new {
299 +
                "Create"
300 +
            } else if renamed {
301 +
                "Rename and update"
302 +
            } else {
303 +
                "Update"
304 +
            },
305 +
            wiki::title_of(&new_slug)
306 +
        )
307 +
    } else {
308 +
        f.message.trim().chars().take(200).collect()
309 +
    };
310 +
    let mut edits = vec![];
311 +
    if renamed {
312 +
        edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&old_slug), content: None });
313 +
    }
314 +
    edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&new_slug), content: Some(content.clone().into_bytes()) });
315 +
    if let Err(e) = wiki::commit(&app, &repo, edits, message.clone(), signer).await {
316 +
        return Ok(again(e.to_string()));
317 +
    }
318 +
    accounts::audit(&app.db, Some(&me.account), "wiki.edited", &repo.to_string(), &new_slug).await.ok();
319 +
    crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": new_slug, "message": message })).await;
320 +
    Ok(Redirect::to(&format!("/{owner}/{name}/wiki/{new_slug}")).into_response())
321 +
}
322 +
323 +
#[derive(Deserialize)]
324 +
pub struct DeleteForm {
325 +
    #[serde(rename = "_csrf")]
326 +
    csrf_token: String,
327 +
}
328 +
329 +
/// `POST /{owner}/{name}/wiki/{slug}/delete`: a change that removes the page.
330 +
pub async fn delete(
331 +
    State(app): State<Shared>,
332 +
    viewer: Viewer,
333 +
    Path((owner, name, slug)): Path<(String, String, String)>,
334 +
    Form(f): Form<DeleteForm>,
335 +
) -> Result<Response, Error> {
336 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer);
337 +
    let me = super::settings::require(&viewer, &shell)?;
338 +
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
339 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
340 +
    if !wiki::slug_ok(&slug) || wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_none() {
341 +
        return Err(Error::not_found(shell));
342 +
    }
343 +
    let signer = super::edit::signer_for(&app, me.account.id).await.map_err(|m| Error::bad(shell.clone(), m))?;
344 +
    let message = format!("Delete {}", wiki::title_of(&slug));
345 +
    wiki::commit(&app, &repo, vec![oxy_core::repo::FileEdit { path: wiki::path_of(&slug), content: None }], message.clone(), signer)
346 +
        .await
347 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
348 +
    accounts::audit(&app.db, Some(&me.account), "wiki.deleted", &repo.to_string(), &slug).await.ok();
349 +
    crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": slug, "message": message, "deleted": true }))
350 +
        .await;
351 +
    Ok(Redirect::to(&format!("/{owner}/{name}/wiki?notice={}", super::settings::urlencode(&format!("{} deleted.", wiki::title_of(&slug)))))
352 +
        .into_response())
353 +
}
354 +
355 +
/// `GET /{owner}/{name}/wiki/_raw/{*path}`: a file of the wiki as bytes,
356 +
/// for images pages embed.
357 +
pub async fn raw(
358 +
    State(app): State<Shared>,
359 +
    viewer: Viewer,
360 +
    Path((owner, name, path)): Path<(String, String, String)>,
361 +
) -> Result<Response, Error> {
362 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_raw/{path}"), &viewer);
363 +
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
364 +
    if !wiki::exists(&app, &repo) || path.contains("..") {
365 +
        return Err(Error::not_found(shell));
366 +
    }
367 +
    let p = path.trim_matches('/').to_string();
368 +
    let file_name = p.rsplit('/').next().unwrap_or("").to_string();
369 +
    let bytes = app
370 +
        .wikis
371 +
        .read(&repo, move |r, _| {
372 +
            let heads = r.bookmarks()?.into_iter().find(|b| b.name == wiki::BOOKMARK).map(|b| b.heads).unwrap_or_default();
373 +
            Ok(r.file_at(&p, Some(&heads)).ok())
374 +
        })
375 +
        .await
376 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?
377 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
378 +
    Ok(super::raw(bytes, &file_name, true))
379 +
}
added crates/hub/src/wiki.rs +208 −0
1 +
//! Wikis (hub spec §6.11, oxygenhub#104): Markdown pages kept as an
2 +
//! Oxygen repository of their own beside the code, under
3 +
//! `<data>/wikis/<owner>/<name>`, read and written on the web and cloned
4 +
//! or pushed as `OWNER/NAME.wiki`. A page `Getting-Started` is the file
5 +
//! `Getting-Started.md` on bookmark `main`; `_Sidebar.md` is the sidebar.
6 +
//! Web edits are changes authored by the user and signed by the hub as
7 +
//! their delegate, exactly as code edits are (§6.9).
8 +
9 +
use crate::repos::edits::{Committed, Signer};
10 +
use crate::repos::view::{self, ChangeView};
11 +
use crate::repos::{RepoError, RepoName};
12 +
use crate::Shared;
13 +
use oxy_core::hash::ChangeHash;
14 +
use oxy_core::identity::Domain;
15 +
use oxy_core::repo::{FileEdit, Repo};
16 +
17 +
pub const BOOKMARK: &str = "main";
18 +
pub const HOME: &str = "Home";
19 +
pub const SIDEBAR: &str = "_Sidebar";
20 +
/// The suffix a wiki's clone address carries: `OWNER/NAME.wiki`.
21 +
pub const SUFFIX: &str = ".wiki";
22 +
/// Pages searched at most.
23 +
const MAX_SEARCHED: usize = 500;
24 +
25 +
/// A page's name in a URL and file name: `Getting-Started`.
26 +
pub fn slug_ok(slug: &str) -> bool {
27 +
    !slug.is_empty()
28 +
        && slug.len() <= 100
29 +
        && slug.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
30 +
        && !slug.contains("..")
31 +
        && !slug.starts_with('.')
32 +
        && !slug.ends_with('.')
33 +
}
34 +
35 +
/// `Getting Started` → `Getting-Started`.
36 +
pub fn slug_of(title: &str) -> String {
37 +
    title.split_whitespace().collect::<Vec<_>>().join("-")
38 +
}
39 +
40 +
/// `Getting-Started` → `Getting Started`.
41 +
pub fn title_of(slug: &str) -> String {
42 +
    slug.replace('-', " ")
43 +
}
44 +
45 +
pub fn path_of(slug: &str) -> String {
46 +
    format!("{slug}.md")
47 +
}
48 +
49 +
/// The page a file at the root is, if it is one.
50 +
pub fn slug_from_path(path: &str) -> Option<&str> {
51 +
    path.strip_suffix(".md").filter(|s| !s.contains('/') && slug_ok(s))
52 +
}
53 +
54 +
/// `OWNER/NAME.wiki` → the repository it belongs to, and whether it was
55 +
/// a wiki address at all.
56 +
pub fn parent_of(name: &RepoName) -> (RepoName, bool) {
57 +
    match name.name.strip_suffix(SUFFIX).and_then(|n| RepoName::new(&name.owner, n)) {
58 +
        Some(parent) => (parent, true),
59 +
        None => (name.clone(), false),
60 +
    }
61 +
}
62 +
63 +
pub fn exists(app: &Shared, repo: &RepoName) -> bool {
64 +
    app.wikis.exists(repo)
65 +
}
66 +
67 +
/// `main`'s heads, empty before the first page.
68 +
fn heads(r: &Repo) -> Result<Vec<ChangeHash>, RepoError> {
69 +
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == BOOKMARK).map(|b| b.heads).unwrap_or_default())
70 +
}
71 +
72 +
/// A page's Markdown, `None` when there is no such page.
73 +
pub async fn page(app: &Shared, repo: &RepoName, slug: &str) -> Result<Option<String>, RepoError> {
74 +
    if !exists(app, repo) {
75 +
        return Ok(None);
76 +
    }
77 +
    let path = path_of(slug);
78 +
    app.wikis
79 +
        .read(repo, move |r, _| {
80 +
            let heads = heads(r)?;
81 +
            if heads.is_empty() {
82 +
                return Ok(None);
83 +
            }
84 +
            Ok(r.file_at(&path, Some(&heads)).ok().map(|b| String::from_utf8_lossy(&b).into_owned()))
85 +
        })
86 +
        .await
87 +
}
88 +
89 +
/// Every page, by slug, sorted; the sidebar and other `_` pages left out.
90 +
pub async fn pages(app: &Shared, repo: &RepoName) -> Result<Vec<String>, RepoError> {
91 +
    if !exists(app, repo) {
92 +
        return Ok(vec![]);
93 +
    }
94 +
    app.wikis
95 +
        .read(repo, |r, cache| {
96 +
            let heads = heads(r)?;
97 +
            if heads.is_empty() {
98 +
                return Ok(vec![]);
99 +
            }
100 +
            let loaded = cache.load(r, &heads)?;
101 +
            let mut out: Vec<String> =
102 +
                loaded.snapshot.files.keys().filter_map(|p| slug_from_path(p)).filter(|s| !s.starts_with('_')).map(String::from).collect();
103 +
            out.sort_by_key(|s| s.to_lowercase());
104 +
            Ok(out)
105 +
        })
106 +
        .await
107 +
}
108 +
109 +
/// Pages whose text holds `q`, with a line that does: (slug, snippet).
110 +
pub async fn search(app: &Shared, repo: &RepoName, q: &str) -> Result<Vec<(String, String)>, RepoError> {
111 +
    let q = q.trim().to_lowercase();
112 +
    if q.is_empty() || !exists(app, repo) {
113 +
        return Ok(vec![]);
114 +
    }
115 +
    app.wikis
116 +
        .read(repo, move |r, cache| {
117 +
            let heads = heads(r)?;
118 +
            if heads.is_empty() {
119 +
                return Ok(vec![]);
120 +
            }
121 +
            let loaded = cache.load(r, &heads)?;
122 +
            let mut out = vec![];
123 +
            for path in loaded.snapshot.files.keys().take(MAX_SEARCHED) {
124 +
                let Some(slug) = slug_from_path(path) else { continue };
125 +
                let Ok(bytes) = r.file_at(path, Some(&heads)) else { continue };
126 +
                let text = String::from_utf8_lossy(&bytes);
127 +
                let hit = slug.to_lowercase().contains(&q) || text.to_lowercase().contains(&q);
128 +
                if hit {
129 +
                    let line = text.lines().find(|l| l.to_lowercase().contains(&q)).unwrap_or("").trim();
130 +
                    out.push((slug.to_string(), line.chars().take(160).collect()));
131 +
                }
132 +
            }
133 +
            Ok(out)
134 +
        })
135 +
        .await
136 +
}
137 +
138 +
/// The changes that touched a page, newest first.
139 +
pub async fn history(app: &Shared, repo: &RepoName, slug: &str, limit: usize) -> Result<Vec<ChangeView>, RepoError> {
140 +
    if !exists(app, repo) {
141 +
        return Ok(vec![]);
142 +
    }
143 +
    let path = path_of(slug);
144 +
    app.wikis
145 +
        .read(repo, move |r, cache| {
146 +
            if heads(r)?.is_empty() {
147 +
                return Ok(vec![]);
148 +
            }
149 +
            let target = view::resolve(r, cache, BOOKMARK)?;
150 +
            Ok(view::history(r, cache, target, Some(&path), 0, limit)?.entries)
151 +
        })
152 +
        .await
153 +
}
154 +
155 +
/// Record `edits` as a change on the wiki's `main`, authored by the user
156 +
/// and signed by the hub as their delegate; the wiki is made on the first.
157 +
pub async fn commit(app: &Shared, repo: &RepoName, edits: Vec<FileEdit>, message: String, signer: Signer) -> Result<Committed, RepoError> {
158 +
    if !exists(app, repo) {
159 +
        // Bound to an id from the start, so it clones before any push.
160 +
        app.wikis.create(repo, Some(rand::random::<[u8; 16]>()))?;
161 +
    }
162 +
    app.wikis
163 +
        .write(repo, move |r, _| {
164 +
            let base_heads = heads(r)?;
165 +
            let author = signer.author.hash();
166 +
            let change = r.build_change(&base_heads, &edits, &message, author)?;
167 +
            let hash = change.hash();
168 +
            let sig = signer.hub_secret.sign(Domain::Change, &signer.hub_key, &hash);
169 +
            let mut state = r.load_state(&base_heads)?;
170 +
            state.add(change.clone()).map_err(|e| RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
171 +
            let mut candidates = base_heads.clone();
172 +
            candidates.push(hash);
173 +
            let heads = state.maximal(&candidates);
174 +
            let update = r.host_ref_update(BOOKMARK, &heads, Some((&signer.hub_key, &signer.hub_secret)))?;
175 +
            let (old_state, new_state) = (update.old_state.to_hex(), update.new_state.to_hex());
176 +
            let report = r.apply_hosted(Some((change, vec![sig])), &[signer.hub_identity.clone(), signer.author.clone()], update)?;
177 +
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
178 +
                return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("the wiki refused the change: {}", refused.reason))));
179 +
            }
180 +
            Ok(Committed { change: hash, bookmark: BOOKMARK.into(), old_state, new_state, heads, report })
181 +
        })
182 +
        .await
183 +
}
184 +
185 +
/// Remove the wiki (to the trash), when the repository goes or an admin
186 +
/// asks under Settings › Advanced.
187 +
pub fn trash(app: &Shared, repo: &RepoName) -> Result<(), RepoError> {
188 +
    if exists(app, repo) {
189 +
        app.wikis.trash(repo)?;
190 +
    }
191 +
    Ok(())
192 +
}
193 +
194 +
#[cfg(test)]
195 +
mod tests {
196 +
    use super::*;
197 +
198 +
    #[test]
199 +
    fn slugs_titles_and_paths() {
200 +
        assert_eq!(slug_of("  Getting   Started "), "Getting-Started");
201 +
        assert_eq!(title_of("Getting-Started"), "Getting Started");
202 +
        assert_eq!(path_of("Home"), "Home.md");
203 +
        assert_eq!(slug_from_path("Home.md"), Some("Home"));
204 +
        assert_eq!(slug_from_path("docs/Home.md"), None);
205 +
        assert_eq!(slug_from_path("notes.txt"), None);
206 +
        assert!(slug_ok("_Sidebar") && slug_ok("v1.2-notes") && !slug_ok("") && !slug_ok("a/b") && !slug_ok("..") && !slug_ok("a b"));
207 +
    }
208 +
}
added templates/wiki.html +33 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}{{ title }} · wiki · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "wiki") %}{% endblock %}
6 +
{% block content %}
7 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 +
<div class="side-layout wiki">
9 +
<div>
10 +
  <div class="head"><h1>{{ title }}</h1>
11 +
    <p class="small">{% if can_edit %}{% if html.is_some() %}<a class="go" href="{{ repo.url }}/wiki/{{ slug }}/edit">Edit</a> {% endif %}<a class="go" href="{{ repo.url }}/wiki/_new">New page</a> {% endif %}<a class="go" href="{{ repo.url }}/wiki/_pages">Pages</a>{% if html.is_some() %} <a class="go" href="{{ repo.url }}/wiki/{{ slug }}/history">History</a>{% endif %}</p>
12 +
  </div>
13 +
  {% if let Some(h) = html %}
14 +
  <article class="doc wiki-doc">{{ h|safe }}</article>
15 +
  {% if let Some(c) = last %}<p class="small mute">Last changed by {{ c.author.name }}{% if let Some(v) = c.via %} via {{ v.name }}{% endif %} <time title="{{ c.when_ms|when_i }}">{{ c.when_ms|ago_i }}</time>{% if can_edit %} · <form class="tiny inline" method="post" action="{{ repo.url }}/wiki/{{ slug }}/delete" onsubmit="return confirm('Delete the page {{ title }}?')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">delete this page</button></form>{% endif %}</p>{% endif %}
16 +
  {% else if any %}
17 +
  <p class="empty">There is no page called <strong>{{ title }}</strong> yet.{% if can_edit %} <a href="{{ repo.url }}/wiki/_new?title={{ title }}">Create it</a>.{% endif %}</p>
18 +
  {% else %}
19 +
  <section class="panel">
20 +
    <h2>No wiki yet</h2>
21 +
    <p>A wiki is Markdown pages kept as an Oxygen repository of their own beside the code: every edit is a signed change, and it clones and pushes like any repository.</p>
22 +
    {% if can_edit %}<p><a class="go" href="{{ repo.url }}/wiki/_new?title=Home">Write the first page</a></p>{% endif %}
23 +
    <pre class="snippet">oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki</pre>
24 +
    <p class="small mute">Pages are <code>Name.md</code> at the root on <code>main</code>; <code>_Sidebar.md</code> is the sidebar. Web edits need a delegation to this hub's key, as code edits do.</p>
25 +
  </section>
26 +
  {% endif %}
27 +
</div>
28 +
<aside class="side">
29 +
  {% if let Some(s) = sidebar %}<section class="side-sec"><div class="doc wiki-side">{{ s|safe }}</div></section>{% endif %}
30 +
  <section class="side-sec"><div class="side-head"><h3>Clone this wiki</h3></div><pre class="snippet small">oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki</pre></section>
31 +
</aside>
32 +
</div>
33 +
{% endblock %}
added templates/wiki_edit.html +18 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}{% if exists %}Edit {{ title }}{% else %}New page{% endif %} · wiki · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "wiki") %}{% endblock %}
5 +
{% block mainclass %} class="wide"{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>{% if exists %}Edit {{ title }}{% else %}New wiki page{% endif %}</h1></div>
8 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
<form class="card wide" method="post" action="{{ repo.url }}/wiki/{% if exists %}{{ slug }}{% else %}_new{% endif %}/edit">
10 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
11 +
  <label for="wtitle">Title</label><input type="text" id="wtitle" name="title" value="{{ title }}" required maxlength="100" placeholder="Getting Started">
12 +
  <p class="small mute">The page's address is its title with dashes for spaces: <code>{{ repo.url }}/wiki/Getting-Started</code>. Link to another page as <code>[Other page](Other-page)</code>.</p>
13 +
  <label for="wcontent">Content (Markdown)</label><textarea id="wcontent" name="content" class="md code" rows="22" required>{{ content }}</textarea>
14 +
  <label for="wmessage">Change message (optional)</label><input type="text" id="wmessage" name="message" maxlength="200" placeholder="{% if exists %}Update {{ title }}{% else %}Create the page{% endif %}">
15 +
  <p class="small mute">Saved as a change in the wiki's repository, authored by you and signed by this hub as your delegate.</p>
16 +
  <button type="submit">{% if exists %}SAVE{% else %}CREATE PAGE{% endif %}</button> <a class="go" href="{{ repo.url }}/wiki{% if exists %}/{{ slug }}{% endif %}">Cancel</a>
17 +
</form>
18 +
{% endblock %}
added templates/wiki_history.html +13 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}History of {{ title }} · wiki · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "wiki") %}{% endblock %}
5 +
{% block content %}
6 +
<div class="head"><h1>History of {{ title }}</h1><p class="small"><a class="go" href="{{ repo.url }}/wiki/{{ slug }}">Back to the page</a></p></div>
7 +
{% if entries.is_empty() %}<p class="empty">No changes.</p>{% else %}
8 +
<div class="table"><table>
9 +
<tr><th>Change</th><th>Message</th><th>By</th><th>When</th></tr>
10 +
{% for c in entries %}<tr><td><code>{{ c.short }}</code></td><td>{{ c.title }}</td><td>{% if let Some(a) = c.author.account %}<a href="/{{ a }}">{{ c.author.name }}</a>{% else %}{{ c.author.name }}{% endif %}{% if let Some(v) = c.via %} <span class="mute">via {{ v.name }}</span>{% endif %}</td><td><time title="{{ c.when_ms|when_i }}">{{ c.when_ms|ago_i }}</time></td></tr>{% endfor %}
11 +
</table></div>
12 +
{% endif %}
13 +
{% endblock %}
added templates/wiki_pages.html +18 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Pages · wiki · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "wiki") %}{% endblock %}
5 +
{% block content %}
6 +
<div class="head"><h1>Wiki pages <small>{{ pages.len() }}</small></h1>
7 +
  <p class="small">{% if can_edit %}<a class="go" href="{{ repo.url }}/wiki/_new">New page</a> {% endif %}<a class="go" href="{{ repo.url }}/wiki">Home</a></p>
8 +
</div>
9 +
<form class="search-row" method="get" action="{{ repo.url }}/wiki/_pages"><input type="search" name="q" value="{{ q }}" placeholder="Search the wiki" aria-label="Search the wiki"><button type="submit">SEARCH</button></form>
10 +
{% if !q.is_empty() %}
11 +
<section class="panel"><h2>{{ hits.len() }} {% if hits.len() == 1 %}page mentions{% else %}pages mention{% endif %} “{{ q }}”</h2>
12 +
  {% if hits.is_empty() %}<p class="mute small">Nothing.</p>{% else %}<ul class="plain">{% for (slug, line) in hits %}<li><a href="{{ repo.url }}/wiki/{{ slug }}">{{ crate::wiki::title_of(slug) }}</a>{% if !line.is_empty() %} <span class="small mute">{{ line }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
13 +
</section>
14 +
{% endif %}
15 +
{% if pages.is_empty() %}<p class="empty">No pages yet.</p>{% else %}
16 +
<ul class="plain">{% for p in pages %}<li><a href="{{ repo.url }}/wiki/{{ p }}">{{ crate::wiki::title_of(p) }}</a></li>{% endfor %}</ul>
17 +
{% endif %}
18 +
{% endblock %}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Wikis (#104).** Each repository can have a wiki: Markdown pages kept as an Oxygen repository of their own, read and edited on the web (with history, a sidebar, a page list and search) or cloned and pushed as `OWNER/NAME.wiki`. Web edits are signed changes, as code edits are. Settings › Advanced deletes the wiki data.
8 +
7 9
- **Advanced settings: transfer, archive, delete (#108).** A repository's Delete section becomes Advanced: transfer the repository to a user or organisation (the old address redirects and `oxy` remotes keep working), archive it read-only with a reason and bring it back, delete it, and a placeholder for deleting wiki data ahead of wikis.
8 10
9 11
- **Accounts elsewhere on a profile (#105).** Bluesky, Mastodon and GitHub under Settings › Profile, checked and normalised (a handle, `@user@instance` or a profile URL all work), shown on the profile with the service's icon as `rel="me"` links.
…
105 unchanged lines
modified crates/hub/src/api/mod.rs +3 −0
…
489 unchanged lines
490 490
    let c = caller(&app, &headers).await?;
491 491
    let me = c.require("repo:admin")?;
492 492
    let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?;
493 +
    if name.name.ends_with(crate::wiki::SUFFIX) {
494 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "a name cannot end in .wiki: that is the address of a repository's wiki".into()));
495 +
    }
493 496
    if !auth::roles::may_create_under(&app.db, me, &name.owner).await? {
494 497
        return Err(forbidden("you cannot create repositories under that owner"));
495 498
    }
…
1665 unchanged lines
modified crates/hub/src/lib.rs +12 −1
…
25 unchanged lines
26 26
pub mod secrets;
27 27
pub mod variables;
28 28
pub mod web;
29 +
pub mod wiki;
29 30
30 31
use crate::config::Config;
31 32
use crate::repos::Repos;
…
4 unchanged lines
36 37
    pub config: Config,
37 38
    pub db: db::Pool,
38 39
    pub repos: Arc<Repos>,
40 +
    /// Wikis, one Oxygen repository per repository (hub spec §6.11).
41 +
    pub wikis: Arc<Repos>,
39 42
    pub server: Arc<oxy_server::Server>,
40 43
    pub hub_key: keys::HubKey,
41 44
    pub cookie_key: auth::CookieKey,
…
31 unchanged lines
73 76
    std::fs::create_dir_all(config.repos_dir())?;
74 77
    let db = db::open(&config.db_path()).await?;
75 78
    let repos = Arc::new(Repos::new(config.repos_dir()));
79 +
    let wikis = Arc::new(Repos::new(config.data.join("wikis")));
76 80
    let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?;
77 81
    let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?;
78 82
    let assistant_key = assistants::SecretKey::load_or_create(&config.keys_dir())?;
79 83
    let secret_key = assistants::SecretKey::load_or_create_named(&config.keys_dir(), "secrets.key")?;
80 -
    let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current());
84 +
    let backend = protocol::HubBackend::new(
85 +
        repos.clone(),
86 +
        wikis.clone(),
87 +
        db.clone(),
88 +
        config.auth.push_tokens.clone(),
89 +
        tokio::runtime::Handle::current(),
90 +
    );
81 91
    let slot = backend.app_slot();
82 92
    let server = oxy_server::Server::new(backend);
83 93
    let mail = mail::load(&db, &config.email).await;
…
2 unchanged lines
86 96
        config,
87 97
        db,
88 98
        repos,
99 +
        wikis,
89 100
        server,
90 101
        hub_key,
91 102
        cookie_key,
…
57 unchanged lines
modified crates/hub/src/protocol.rs +28 −8
…
15 unchanged lines
16 16
17 17
pub struct HubBackend {
18 18
    repos: Arc<Repos>,
19 +
    /// Wikis, reached as `OWNER/NAME.wiki` (hub spec §6.11).
20 +
    wikis: Arc<Repos>,
19 21
    pool: db::Pool,
20 22
    /// Site tokens from `hub.toml`: full access, for bootstrapping.
21 23
    site_tokens: Vec<String>,
…
17 unchanged lines
39 41
}
40 42
41 43
impl HubBackend {
42 -
    pub fn new(repos: Arc<Repos>, pool: db::Pool, site_tokens: Vec<String>, runtime: tokio::runtime::Handle) -> HubBackend {
43 -
        HubBackend { repos, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)) }
44 +
    pub fn new(
45 +
        repos: Arc<Repos>,
46 +
        wikis: Arc<Repos>,
47 +
        pool: db::Pool,
48 +
        site_tokens: Vec<String>,
49 +
        runtime: tokio::runtime::Handle,
50 +
    ) -> HubBackend {
51 +
        HubBackend { repos, wikis, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)) }
44 52
    }
45 53
46 54
    /// The slot the application registers itself in after construction.
…
62 unchanged lines
109 117
impl Backend for HubBackend {
110 118
    fn open(&self, name: &str) -> Result<Repo, Failure> {
111 119
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
112 -
        // The row knows the current name: a transferred repository still
113 -
        // answers to its old one (#108).
114 -
        let row = self.row(&parsed)?;
120 +
        // `NAME.wiki` is the repository's wiki (§6.11); the row knows the
121 +
        // current name, so a transferred repository still answers to its
122 +
        // old one (#108).
123 +
        let (parent, is_wiki) = crate::wiki::parent_of(&parsed);
124 +
        let row = self.row(&parent)?;
115 125
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?;
116 -
        self.repos.open(&name).map_err(|e| match e {
126 +
        let store = if is_wiki { &self.wikis } else { &self.repos };
127 +
        store.open(&name).map_err(|e| match e {
117 128
            crate::repos::RepoError::NotFound => not_found(),
118 129
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
119 130
        })
…
1 unchanged lines
121 132
122 133
    fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> {
123 134
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
124 -
        let row = self.row(&parsed)?;
135 +
        let (parent, _) = crate::wiki::parent_of(&parsed);
136 +
        let row = self.row(&parent)?;
125 137
        let caller = self.caller(headers)?;
126 138
        if let Caller::Job { repo_id, also } = &caller {
127 139
            // Only reads: of the job's own repository, and of the subrepos its
…
17 unchanged lines
145 157
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
146 158
                };
147 159
                let current = format!("{}/{}", row.owner, row.name);
148 -
                (role, needed && (token.covers(&parsed.to_string()) || token.covers(&current)))
160 +
                let covered = token.covers(&parsed.to_string()) || token.covers(&current) || token.covers(&parent.to_string());
161 +
                (role, needed && covered)
149 162
            }
150 163
        };
151 164
        // An unreadable repository does not exist for the caller (hub spec §4.4).
…
23 unchanged lines
175 188
176 189
    fn default_bookmark(&self, name: &str) -> Option<String> {
177 190
        let parsed = RepoName::parse(name)?;
191 +
        if crate::wiki::parent_of(&parsed).1 {
192 +
            return Some(crate::wiki::BOOKMARK.into());
193 +
        }
178 194
        self.row(&parsed).ok().map(|r| r.default_bookmark)
179 195
    }
180 196
181 197
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
182 198
        let Some(parsed) = RepoName::parse(name) else { return };
199 +
        // A wiki push starts no run and rings no webhook.
200 +
        if crate::wiki::parent_of(&parsed).1 {
201 +
            return;
202 +
        }
183 203
        let Ok(row) = self.row(&parsed) else { return };
184 204
        let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return };
185 205
        let pusher = match self.caller(headers) {
…
149 unchanged lines
modified crates/hub/src/web/mod.rs +13 −0
…
33 unchanged lines
34 34
mod statics;
35 35
mod variables;
36 36
mod webhooks;
37 +
mod wiki;
37 38
38 39
use crate::auth::{self, Access, Role, Viewer};
39 40
use crate::repos::RepoError;
…
119 unchanged lines
159 160
        .route("/{owner}/{name}/settings/delete", post(repo_settings::delete))
160 161
        .route("/{owner}/{name}/settings/transfer", post(repo_settings::transfer))
161 162
        .route("/{owner}/{name}/settings/archive", post(repo_settings::archive))
163 +
        .route("/{owner}/{name}/settings/wiki/delete", post(repo_settings::wiki_delete))
164 +
        .route("/{owner}/{name}/wiki", get(wiki::home))
165 +
        .route("/{owner}/{name}/wiki/_pages", get(wiki::pages))
166 +
        .route("/{owner}/{name}/wiki/_new", get(wiki::new_form))
167 +
        .route("/{owner}/{name}/wiki/_raw/{*path}", get(wiki::raw))
168 +
        .route("/{owner}/{name}/wiki/{slug}", get(wiki::show))
169 +
        .route("/{owner}/{name}/wiki/{slug}/edit", get(wiki::edit_form).post(wiki::save))
170 +
        .route("/{owner}/{name}/wiki/{slug}/history", get(wiki::history))
171 +
        .route("/{owner}/{name}/wiki/{slug}/delete", post(wiki::delete))
162 172
        .route("/{owner}/{name}/settings/requirements", post(repo_settings::requirements))
163 173
        .route("/{owner}/{name}/proposals", get(proposals::list))
164 174
        .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create))
…
216 unchanged lines
381 391
    pub open_proposals: i64,
382 392
    /// Read-only (#108).
383 393
    pub archived: bool,
394 +
    /// Whether the repository has a wiki (§6.11).
395 +
    pub wiki: bool,
384 396
}
385 397
386 398
impl RepoShell {
…
11 unchanged lines
398 410
            open_issues: row.open_issues,
399 411
            open_proposals: row.open_proposals,
400 412
            archived: row.archived,
413 +
            wiki: crate::repos::RepoName::new(&row.owner, &row.name).is_some_and(|n| app.wikis.exists(&n)),
401 414
        }
402 415
    }
403 416
…
276 unchanged lines
modified crates/hub/src/web/repo_settings.rs +30 −0
…
59 unchanged lines
60 60
    let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else {
61 61
        return Ok(fail("A repository name is letters, digits, dots, dashes and underscores."));
62 62
    };
63 +
    if name.name.ends_with(crate::wiki::SUFFIX) {
64 +
        return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki."));
65 +
    }
63 66
    if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
64 67
        return Ok(fail("You cannot create repositories under that owner."));
65 68
    }
…
265 unchanged lines
331 334
    {
332 335
        let _guard = app.repos.write_guard(&repo).await;
333 336
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
337 +
        if app.wikis.exists(&repo) {
338 +
            app.wikis.rename(&repo, &dest).ok();
339 +
        }
334 340
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
335 341
        let mirror = crate::mirror::mirror_dir(&app, &repo);
336 342
        if mirror.is_dir() {
…
48 unchanged lines
385 391
        "The repository is back: writes are allowed again."
386 392
    };
387 393
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
394 +
}
395 +
396 +
/// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104).
397 +
pub async fn wiki_delete(
398 +
    State(app): State<Shared>,
399 +
    viewer: Viewer,
400 +
    Path((owner, name)): Path<(String, String)>,
401 +
    Form(f): Form<DeleteForm>,
402 +
) -> Result<Page<RepoSettingsPage>, Error> {
403 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
404 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
405 +
    csrf(&viewer, &f.csrf_token, &shell)?;
406 +
    if f.confirm.trim() != repo.to_string() {
407 +
        return settings_page(&app, shell, row, role, None, Some("Type the repository's full name to confirm.".into())).await;
408 +
    }
409 +
    if !crate::wiki::exists(&app, &repo) {
410 +
        return settings_page(&app, shell, row, role, None, Some("This repository has no wiki.".into())).await;
411 +
    }
412 +
    let _guard = app.wikis.write_guard(&repo).await;
413 +
    crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
414 +
    drop(_guard);
415 +
    accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok();
416 +
    settings_page(&app, shell, row, role, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None).await
388 417
}
389 418
390 419
#[derive(Deserialize)]
…
19 unchanged lines
410 439
    }
411 440
    let _guard = app.repos.write_guard(&repo).await;
412 441
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
442 +
    crate::wiki::trash(&app, &repo).ok();
413 443
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
414 444
    crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
415 445
    crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok();
…
277 unchanged lines
modified crates/hub/tests/edits.rs +134 −0
…
653 unchanged lines
654 654
    assert!(r.body.contains("The repository is back"), "{}", r.body);
655 655
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
656 656
}
657 +
658 +
#[tokio::test(flavor = "multi_thread")]
659 +
async fn a_wiki_is_edited_on_the_web_and_cloned_and_pushed_as_name_dot_wiki() {
660 +
    hermetic();
661 +
    let dir = tempfile::tempdir().unwrap();
662 +
    let mut config = Config::load(dir.path()).unwrap();
663 +
    config.auth.push_tokens = vec!["site".into()];
664 +
    config.registration.mode = "open".into();
665 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
666 +
    let port = listener.local_addr().unwrap().port();
667 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
668 +
    let app = oxygenhub::build(config).await.unwrap();
669 +
    let router = oxygenhub::router(app.clone());
670 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
671 +
    let base = format!("http://127.0.0.1:{port}");
672 +
    let app = &app;
673 +
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
674 +
    let andy = r.cookie.unwrap();
675 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
676 +
    let repo = RepoName::parse("andy/demo").unwrap();
677 +
    app.repos.create(&repo, None).unwrap();
678 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
679 +
    // Andy delegates to the hub key and links the identity, as for code edits.
680 +
    let work = tempfile::tempdir().unwrap();
681 +
    let mut client = Repo::init(work.path()).unwrap();
682 +
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
683 +
    client.config.set("core.sign", "change").unwrap();
684 +
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
685 +
    client.config.set("remote.origin.token", "site").unwrap();
686 +
    client.save_config().unwrap();
687 +
    write(work.path(), "a.txt", b"one\n");
688 +
    client.snapshot("t").unwrap();
689 +
    client.commit("first", &[]).unwrap();
690 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
691 +
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
692 +
    let key = client.config.author_key.unwrap();
693 +
    let id = client.identity(&key).unwrap();
694 +
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
695 +
696 +
    // No wiki yet: the tab shows for a writer, the page explains.
697 +
    let page = send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await;
698 +
    assert_eq!(page.status, StatusCode::OK, "{}", page.body);
699 +
    assert!(page.body.contains("No wiki yet") && page.body.contains(">Wiki</a>") && page.body.contains("andy/demo.wiki"), "{}", page.body);
700 +
    assert!(!send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki</a>"), "no tab for a reader without a wiki");
701 +
    // A repository cannot take the wiki's address.
702 +
    let csrf_n = csrf_of(&send(app, "GET", "/new", Some(&andy), None).await.body);
703 +
    let r = send(app, "POST", "/new", Some(&andy), Some(&format!("_csrf={csrf_n}&owner=andy&name=demo.wiki&visibility=public"))).await;
704 +
    assert!(r.body.contains("cannot end in .wiki"), "{}", r.body);
705 +
706 +
    // The first page, with a link to a page not yet written.
707 +
    let csrf = csrf_of(&page.body);
708 +
    let r = send(
709 +
        app,
710 +
        "POST",
711 +
        "/andy/demo/wiki/_new/edit",
712 +
        Some(&andy),
713 +
        Some(&format!("_csrf={csrf}&title=Home&content={}", enc("# Welcome\n\nSee [the guide](Guide).\n"))),
714 +
    )
715 +
    .await;
716 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
717 +
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home"));
718 +
    let home = send(app, "GET", "/andy/demo/wiki", None, None).await.body;
719 +
    assert!(
720 +
        home.contains("Welcome") && home.contains("href=\"/andy/demo/wiki/Guide\"") && home.contains("Last changed by Andy McCall"),
721 +
        "{home}"
722 +
    );
723 +
    assert!(send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki</a>"), "the tab shows to everyone now");
724 +
    let missing = send(app, "GET", "/andy/demo/wiki/Guide", Some(&andy), None).await.body;
725 +
    assert!(missing.contains("no page called <strong>Guide</strong>") && missing.contains("_new?title=Guide"), "{missing}");
726 +
    // An edit, then a rename: history follows, the old address is empty.
727 +
    let r = send(
728 +
        app,
729 +
        "POST",
730 +
        "/andy/demo/wiki/Home/edit",
731 +
        Some(&andy),
732 +
        Some(&format!("_csrf={csrf}&title=Home&content={}&message=Say+more", enc("# Welcome\n\nMore.\n"))),
733 +
    )
734 +
    .await;
735 +
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home"), "{}", r.body);
736 +
    let hist = send(app, "GET", "/andy/demo/wiki/Home/history", None, None).await.body;
737 +
    assert!(hist.contains("Say more") && hist.contains("Create Home"), "{hist}");
738 +
    let r = send(
739 +
        app,
740 +
        "POST",
741 +
        "/andy/demo/wiki/Home/edit",
742 +
        Some(&andy),
743 +
        Some(&format!("_csrf={csrf}&title=Start+Here&content={}", enc("# Start\n"))),
744 +
    )
745 +
    .await;
746 +
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Start-Here"), "{}", r.body);
747 +
    assert!(send(app, "GET", "/andy/demo/wiki/Start-Here", None, None).await.body.contains("<h1>Start Here</h1>"));
748 +
    assert!(send(app, "GET", "/andy/demo/wiki", None, None).await.body.contains("no page called <strong>Home</strong>"));
749 +
    let pages = send(app, "GET", "/andy/demo/wiki/_pages?q=start", None, None).await.body;
750 +
    assert!(pages.contains("href=\"/andy/demo/wiki/Start-Here\"") && pages.contains("mentions"), "{pages}");
751 +
752 +
    // By client: clone the wiki, add a page, push it.
753 +
    let wdir = tempfile::tempdir().unwrap();
754 +
    let mut w = Repo::clone(&format!("{base}/andy/demo.wiki"), wdir.path()).unwrap();
755 +
    assert_eq!(std::fs::read_to_string(wdir.path().join("Start-Here.md")).unwrap(), "# Start\n");
756 +
    w.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
757 +
    w.config.set("core.sign", "change").unwrap();
758 +
    w.config.set("remote.origin.token", "site").unwrap();
759 +
    w.save_config().unwrap();
760 +
    write(wdir.path(), "Guide.md", b"# Guide\n\nPushed from a clone.\n");
761 +
    w.snapshot("t").unwrap();
762 +
    w.commit("Add the guide", &[]).unwrap();
763 +
    assert!(w.push("origin", false).unwrap().iter().all(|r| r.ok), "pushed to the wiki");
764 +
    let guide = send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body;
765 +
    assert!(guide.contains("Pushed from a clone"), "{guide}");
766 +
    assert!(
767 +
        db::flow::runs(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, 10, None)
768 +
            .await
769 +
            .map(|r| r.is_empty())
770 +
            .unwrap_or(true),
771 +
        "a wiki push starts no run"
772 +
    );
773 +
774 +
    // Delete a page, then the whole wiki from Advanced settings.
775 +
    let r = send(app, "POST", "/andy/demo/wiki/Guide/delete", Some(&andy), Some(&format!("_csrf={csrf}"))).await;
776 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
777 +
    assert!(send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body.contains("no page called"));
778 +
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None).await.body;
779 +
    assert!(settings.contains("DELETE WIKI DATA"), "{settings}");
780 +
    let r = send(
781 +
        app,
782 +
        "POST",
783 +
        "/andy/demo/settings/wiki/delete",
784 +
        Some(&andy),
785 +
        Some(&format!("_csrf={}&confirm=andy%2Fdemo", csrf_of(&settings))),
786 +
    )
787 +
    .await;
788 +
    assert!(r.body.contains("The wiki is in the trash"), "{}", r.body);
789 +
    assert!(send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await.body.contains("No wiki yet"));
790 +
}
modified docs/spec.md +5 −1
…
47 unchanged lines
48 48
5. **Off the shelf where it is not the product.** Markdown editing uses EasyMDE, in-browser code editing uses CodeMirror 6, syntax highlighting uses syntect, Markdown rendering uses pulldown-cmark with ammonia sanitising. The hub's own code is the forge logic and the pages.
49 49
6. **Honest state.** A page never shows something the object store does not say. A signature is shown as verified only when the hub verified it against policy; an approval is shown only when the approving seal exists.
50 50
51 -
Non-goals for 1.0: hosting Git repositories natively (§14 covers mirrors), a marketplace, wikis, project boards, discussions, GitHub Pages, and federation between hubs. The runner is specified separately (§13, `docs/runner.md`) and is not required for 1.0.
51 +
Non-goals for 1.0: hosting Git repositories natively (§14 covers mirrors), a marketplace, discussions, GitHub Pages, and federation between hubs. The runner is specified separately (§13, `docs/runner.md`) and is not required for 1.0.
52 52
53 53
## 2. How it compares with GitHub
54 54
…
275 unchanged lines
330 330
331 331
`/` focuses search, `g c` / `g p` / `g i` jump to Code, Proposals, Issues, `y` on a tree or file page switches to its permalink, `?` shows the sheet. Every interactive element is reachable by keyboard and shows a visible focus ring; a "Skip to content" link comes first; the header, site band, repository and settings tabs are named navigation landmarks, and the current tab carries `aria-current`. Colour is never the only carrier of meaning (glyphs accompany every status colour). Text meets WCAG AA (4.5:1) on every surface in both themes, checked by a test over the theme variables; `--dim` is for rules and decoration, never text. Under 760 pixels the tab bars and wide tables scroll sideways instead of breaking the layout, sectioned pages stack their section list above the content, and the header search hides (the `/` key and the search page remain).
332 332
333 +
### 6.11 Wiki (done 2026-10-04)
334 +
335 +
A repository may have a **wiki** (oxygenhub#104): Markdown pages kept as an Oxygen repository of their own under `<data>/wikis/<owner>/<name>`, with no row of its own; it follows the repository's visibility, roles, archiving, transfer and deletion. A page `Getting-Started` is the file `Getting-Started.md` at the root of bookmark `main`; `_Sidebar.md` is shown beside every page, and `Home` is the front page. On the web (`/{owner}/{name}/wiki`, `/wiki/{page}`, `/wiki/{page}/edit`, `/wiki/{page}/history`, `/wiki/_pages` with a search over titles and text, `/wiki/_raw/{path}` for images): readers read, writers create, edit, rename and delete pages, and every edit is a change authored by the user and signed by the hub as their delegate, exactly as a code edit (§6.9), so it needs the same delegation. Links between pages are relative (`[Other](Other)`). By client, the wiki is `oxy clone HUB/{owner}/{name}.wiki`: the wire protocol maps that name onto the wiki store with the repository's permissions, pushes need write access and not archived, start no run and ring no webhook; a repository can therefore not be named `*.wiki`. The Wiki tab shows when a wiki exists or the viewer may start one. Settings › Advanced › Delete wiki data moves the wiki to the trash. Audited as `wiki.edited`, `wiki.deleted`, `wiki.data_deleted`; webhooks get a `wiki` event.
336 +
333 337
## 7. Proposals and review
334 338
335 339
### 7.1 Definition
…
373 unchanged lines
modified templates/help.html +4 −0
…
67 unchanged lines
68 68
    <li>Where the site, an organisation or a repository has set up <strong>AI assistants</strong> (Settings), you can ask one to review under Reviewers, or have it draft the description; its answer is recorded as yours <em>via</em> the assistant, with an <span class="tag">agent</span> badge. Only people approve.</li>
69 69
  </ul>
70 70
</section>
71 +
<section class="panel" id="wiki">
72 +
  <h2>Wiki</h2>
73 +
  <p>A repository's wiki is Markdown pages kept as an Oxygen repository of their own: edit them under the <strong>Wiki</strong> tab (every edit is a signed change, so it needs the same delegation as editing code), or clone and push them as <code>oxy clone {{ shell.base_url }}/OWNER/NAME.wiki</code>. Pages are <code>Name.md</code> on <code>main</code>; <code>_Sidebar.md</code> is the sidebar; link between pages with <code>[Other](Other)</code>.</p>
74 +
</section>
71 75
72 76
<section class="panel" id="delegate">
73 77
  <h2>Letting the hub act for you</h2>
…
57 unchanged lines
modified templates/repo_settings.html +8 −2
…
142 unchanged lines
143 143
    </details>
144 144
  </div>
145 145
  <div class="danger-row">
146 -
    <div><strong>Delete wiki data</strong><p class="small mute">Wikis are not here yet (oxygenhub#104). Once they are, this removes this repository's wiki, which cannot be undone.</p></div>
147 -
    <button class="danger-btn" type="button" disabled title="Wikis are not here yet">Delete wiki data</button>
146 +
    <div><strong>Delete wiki data</strong><p class="small mute">{% if repo.wiki %}The wiki's repository moves to the trash with its history; a new first page starts a fresh one.{% else %}This repository has no wiki.{% endif %}</p></div>
147 +
    {% if repo.wiki %}<details class="danger-act"><summary class="danger-btn">Delete wiki data</summary>
148 +
      <form class="card" method="post" action="{{ repo.url }}/settings/wiki/delete">
149 +
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
150 +
        <label for="wconfirm">Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm</label><input type="text" id="wconfirm" name="confirm" required autocomplete="off">
151 +
        <button type="submit">DELETE WIKI DATA</button>
152 +
      </form>
153 +
    </details>{% else %}<button class="danger-btn" type="button" disabled>Delete wiki data</button>{% endif %}
148 154
  </div>
149 155
  <div class="danger-row">
150 156
    <div><strong>Delete this repository</strong><p class="small mute">The repository moves to the trash and is removed after the retention period.</p></div>
…
13 unchanged lines
modified templates/repo_tabs.html +1 −0
…
5 unchanged lines
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
8 8
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on" aria-current="page"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a>
9 +
    {% if repo.wiki || repo.can(crate::auth::Access::Write) %}<a href="{{ repo.url }}/wiki"{% if tab == "wiki" %} class="on" aria-current="page"{% endif %}>Wiki</a>{% endif %}
9 10
    <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a>
10 11
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a>
11 12
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a>
…
63 unchanged lines