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(¤t))) |
|
| 160 | + | let covered = token.covers(&parsed.to_string()) || token.covers(¤t) || 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 |