OXYGENHUB

Coverage / crates/hub/src/web/wiki.rs 69.8%

231 of 331 lines run, 29 of 88 functions · run #515 on main · run missed

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
    /// Every page's slug, for the side bar (#194).
32
    pages: Vec<String>,
33
    /// How many changes touched this page.
34
    revisions: usize,
35
    /// The page's headings: (level, text, id).
36
    toc: Vec<(u8, String, String)>,
37
}
38
39
#[derive(Template)]
40
#[template(path = "wiki_edit.html")]
41
pub struct WikiEditPage {
42
    shell: Shell,
43
    repo: RepoShell,
44
    slug: String,
45
    title: String,
46
    content: String,
47
    /// Whether the page exists (an edit) or is new.
48
    exists: bool,
49
    error: Option<String>,
50
}
51
52
#[derive(Template)]
53
#[template(path = "wiki_pages.html")]
54
pub struct WikiPagesPage {
55
    shell: Shell,
56
    repo: RepoShell,
57
    pages: Vec<String>,
58
    q: String,
59
    hits: Vec<(String, String)>,
60
    can_edit: bool,
61
}
62
63
#[derive(Template)]
64
#[template(path = "wiki_history.html")]
65
pub struct WikiHistoryPage {
66
    shell: Shell,
67
    repo: RepoShell,
68
    slug: String,
69
    title: String,
70
    entries: Vec<ChangeView>,
71
}
72
735×
fn base(owner: &str, name: &str) -> String {
745×
    format!("/{owner}/{name}/wiki/")
755×
}
76
775×
fn render(owner: &str, name: &str, text: &str) -> String {
785×
    crate::render::markdown::render(text, &base(owner, name), &format!("/{owner}/{name}/wiki/_raw/"))
795×
}
80
81
/// Whether the viewer may change the wiki: a writer, and not archived.
8211×
fn editable(row: &crate::db::repos::RepoRow, rs: &RepoShell) -> bool {
8311×
    rs.can(Access::Write) && !row.archived
8411×
}
85
86
#[derive(Deserialize)]
87
pub struct ShowQuery {
88
    notice: Option<String>,
89
}
90
91
/// `GET /{owner}/{name}/wiki`: the Home page.
925×
pub async fn home(
935×
    State(app): State<Shared>,
945×
    viewer: Viewer,
955×
    Path((owner, name)): Path<(String, String)>,
965×
    Query(q): Query<ShowQuery>,
975×
) -> Result<Page<WikiPage>, Error> {
985×
    show_page(app, viewer, owner, name, wiki::HOME.into(), q.notice).await
995×
}
100
101
/// `GET /{owner}/{name}/wiki/{slug}`.
1025×
pub async fn show(
1035×
    State(app): State<Shared>,
1045×
    viewer: Viewer,
1055×
    Path((owner, name, slug)): Path<(String, String, String)>,
1065×
    Query(q): Query<ShowQuery>,
1075×
) -> Result<Page<WikiPage>, Error> {
1085×
    show_page(app, viewer, owner, name, slug, q.notice).await
1095×
}
110
11110×
async fn show_page(
11210×
    app: Shared,
11310×
    viewer: Viewer,
11410×
    owner: String,
11510×
    name: String,
11610×
    slug: String,
11710×
    notice: Option<String>,
11810×
) -> Result<Page<WikiPage>, Error> {
11910×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer);
12010×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
12110×
    if !wiki::slug_ok(&slug) {
1220×
        return Err(Error::not_found(shell));
12310×
    }
12410×
    let e = |e| Error::from_repo(shell.clone(), e);
12510×
    let text = wiki::page(&app, &repo, &slug).await.map_err(e)?;
12610×
    let sidebar = wiki::page(&app, &repo, wiki::SIDEBAR).await.map_err(e)?;
12711×
    let pages: Vec<String> = wiki::pages(&app, &repo).await.map_err(e)?.into_iter().filter(|p| p != wiki::SIDEBAR).collect();
12810×
    let any = !pages.is_empty();
12910×
    let history = if text.is_some() { wiki::history(&app, &repo, &slug, 1000).await.map_err(e)? } else { vec![] };
13010×
    let revisions = history.len();
13110×
    let last = history.into_iter().next();
13210×
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role).with_sky(&app, &row).await;
13310×
    let can_edit = editable(&row, &rs);
13410×
    let (html, toc) = match text.as_deref().map(|t| contents(&render(&owner, &name, t))) {
1355×
        Some((h, t)) => (Some(h), t),
1365×
        None => (None, vec![]),
137
    };
138
    Ok(Page(WikiPage {
13910×
        html,
14010×
        pages,
14110×
        revisions,
14210×
        toc,
14310×
        sidebar: sidebar.as_deref().map(|t| render(&owner, &name, t)),
14410×
        title: wiki::title_of(&slug),
14510×
        shell,
14610×
        repo: rs,
14710×
        slug,
14810×
        any,
14910×
        can_edit,
15010×
        last,
15110×
        notice,
152
    }))
15310×
}
154
155
/// Give a rendered page's `<h1>`–`<h3>` ids and list them, for the
156
/// table of contents (#194). The HTML is already sanitised; the ids are
157
/// made of letters, digits and dashes only, and the text keeps only what
158
/// was between the tags.
1596×
fn contents(html: &str) -> (String, Vec<(u8, String, String)>) {
1606×
    let mut out = String::with_capacity(html.len() + 64);
1616×
    let mut toc = vec![];
1626×
    let mut used = std::collections::HashSet::new();
1636×
    let mut rest = html;
16416×
    while let Some(at) = rest.find("<h") {
16510×
        let level = rest.as_bytes().get(at + 2).copied().unwrap_or(0);
16610×
        let tagged = matches!(level, b'1'..=b'3') && rest[at + 3..].starts_with('>');
16710×
        let close = format!("</h{}>", level as char);
16810×
        let end = if tagged { rest[at..].find(&close).map(|e| at + e) } else { None };
16910×
        let Some(end) = end else {
1702×
            out.push_str(&rest[..at + 2]);
1712×
            rest = &rest[at + 2..];
1722×
            continue;
173
        };
1748×
        let inner = &rest[at + 4..end];
1758×
        let mut text = String::new();
1768×
        let mut in_tag = false;
17765×
        for c in inner.chars() {
17865×
            match c {
1790×
                '<' => in_tag = true,
1800×
                '>' => in_tag = false,
18165×
                c if !in_tag => text.push(c),
1820×
                _ => {}
183
            }
184
        }
1858×
        let mut id: String = text
1868×
            .replace("&amp;", "and")
1878×
            .chars()
18861×
            .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
1898×
            .collect::<String>()
1908×
            .split('-')
19114×
            .filter(|p| !p.is_empty())
1928×
            .collect::<Vec<_>>()
1938×
            .join("-");
1948×
        if id.is_empty() {
1950×
            id = "section".into();
1968×
        }
1978×
        let mut unique = id.clone();
1988×
        let mut n = 1;
1999×
        while !used.insert(unique.clone()) {
2001×
            n += 1;
2011×
            unique = format!("{id}-{n}");
2021×
        }
2038×
        out.push_str(&rest[..at]);
2048×
        out.push_str(&format!("<h{l} id=\"{unique}\">{inner}{close}", l = level as char));
2058×
        toc.push((level - b'0', text.trim().to_string(), unique));
2068×
        rest = &rest[end + close.len()..];
207
    }
2086×
    out.push_str(rest);
2096×
    (out, toc)
2106×
}
211
212
#[derive(Deserialize)]
213
pub struct PagesQuery {
214
    #[serde(default)]
215
    q: String,
216
}
217
218
/// `GET /{owner}/{name}/wiki/_pages`: every page, and a search.
2191×
pub async fn pages(
2201×
    State(app): State<Shared>,
2211×
    viewer: Viewer,
2221×
    Path((owner, name)): Path<(String, String)>,
2231×
    Query(q): Query<PagesQuery>,
2241×
) -> Result<Page<WikiPagesPage>, Error> {
2251×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_pages"), &viewer);
2261×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2271×
    let e = |e| Error::from_repo(shell.clone(), e);
2281×
    let pages = wiki::pages(&app, &repo).await.map_err(e)?;
2291×
    let q: String = q.q.trim().chars().take(100).collect();
2301×
    let hits = if q.is_empty() { vec![] } else { wiki::search(&app, &repo, &q).await.map_err(e)? };
2311×
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role);
2321×
    let can_edit = editable(&row, &rs);
2331×
    Ok(Page(WikiPagesPage { shell, repo: rs, pages, q, hits, can_edit }))
2341×
}
235
236
/// `GET /{owner}/{name}/wiki/{slug}/history`.
2371×
pub async fn history(
2381×
    State(app): State<Shared>,
2391×
    viewer: Viewer,
2401×
    Path((owner, name, slug)): Path<(String, String, String)>,
2411×
) -> Result<Page<WikiHistoryPage>, Error> {
2421×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/history"), &viewer);
2431×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2441×
    if !wiki::slug_ok(&slug) {
2450×
        return Err(Error::not_found(shell));
2461×
    }
2471×
    let mut entries = wiki::history(&app, &repo, &slug, 100).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
2481×
    super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
2491×
    Ok(Page(WikiHistoryPage {
2501×
        title: wiki::title_of(&slug),
2511×
        shell,
2521×
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
2531×
        slug,
2541×
        entries,
2551×
    }))
2561×
}
257
258
#[derive(Deserialize)]
259
pub struct NewQuery {
260
    #[serde(default)]
261
    title: String,
262
}
263
264
/// `GET /{owner}/{name}/wiki/_new`: a new page, named by the form.
2650×
pub async fn new_form(
2660×
    State(app): State<Shared>,
2670×
    viewer: Viewer,
2680×
    Path((owner, name)): Path<(String, String)>,
2690×
    Query(q): Query<NewQuery>,
2700×
) -> Result<Page<WikiEditPage>, Error> {
2710×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_new"), &viewer);
2720×
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
2730×
    let slug = wiki::slug_of(&q.title);
274
    Ok(Page(WikiEditPage {
2750×
        title: if slug.is_empty() { String::new() } else { wiki::title_of(&slug) },
2760×
        shell,
2770×
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
2780×
        slug,
2790×
        content: String::new(),
280
        exists: false,
2810×
        error: None,
282
    }))
2830×
}
284
285
/// `GET /{owner}/{name}/wiki/{slug}/edit`.
2860×
pub async fn edit_form(
2870×
    State(app): State<Shared>,
2880×
    viewer: Viewer,
2890×
    Path((owner, name, slug)): Path<(String, String, String)>,
2900×
) -> Result<Page<WikiEditPage>, Error> {
2910×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer);
2920×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
2930×
    if !wiki::slug_ok(&slug) {
2940×
        return Err(Error::not_found(shell));
2950×
    }
2960×
    let text = wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
2970×
    Ok(Page(WikiEditPage {
2980×
        title: wiki::title_of(&slug),
2990×
        shell,
3000×
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
3010×
        slug,
3020×
        exists: text.is_some(),
3030×
        content: text.unwrap_or_default(),
3040×
        error: None,
3050×
    }))
3060×
}
307
308
#[derive(Deserialize)]
309
pub struct SaveForm {
310
    #[serde(rename = "_csrf")]
311
    csrf_token: String,
312
    /// The page's title; its slug is the file name. A changed title on an
313
    /// existing page renames it.
314
    #[serde(default)]
315
    title: String,
316
    #[serde(default)]
317
    content: String,
318
    #[serde(default)]
319
    message: String,
320
}
321
322
/// `POST /{owner}/{name}/wiki/{slug}/edit` (`_new` for a new page): the
323
/// change is authored by the user and signed by the hub as their delegate.
3243×
pub async fn save(
3253×
    State(app): State<Shared>,
3263×
    viewer: Viewer,
3273×
    Path((owner, name, slug)): Path<(String, String, String)>,
3283×
    Form(f): Form<SaveForm>,
3293×
) -> Result<Response, Error> {
3303×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer);
3313×
    let me = super::settings::require(&viewer, &shell)?;
3323×
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
3333×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
3343×
    let is_new = slug == "_new";
3353×
    let old_slug = if is_new { String::new() } else { slug.clone() };
3363×
    let new_slug = wiki::slug_of(&f.title);
3373×
    let content = f.content.replace("\r\n", "\n");
3383×
    let again = |msg: String| {
339
        Page(WikiEditPage {
3400×
            title: f.title.clone(),
3410×
            shell: shell.clone(),
3420×
            repo: RepoShell::new(&app, &row, "wiki").with_role(role),
3430×
            slug: if is_new { new_slug.clone() } else { old_slug.clone() },
3440×
            content: content.clone(),
3450×
            exists: !is_new,
3460×
            error: Some(msg),
347
        })
3480×
        .into_response()
3490×
    };
3503×
    if !is_new && !wiki::slug_ok(&old_slug) {
3510×
        return Err(Error::not_found(shell));
3523×
    }
3533×
    if !wiki::slug_ok(&new_slug) {
3540×
        return Ok(again("A page title is letters, digits, spaces, dots, dashes and underscores, up to 100 characters.".into()));
3553×
    }
3563×
    if content.trim().is_empty() {
3570×
        return Ok(again("Write something on the page.".into()));
3583×
    }
3593×
    let signer = match super::edit::signer_for(&app, me.account.id).await {
3603×
        Ok(s) => s,
3610×
        Err(msg) => return Ok(again(msg)),
362
    };
3633×
    let renamed = !is_new && new_slug != old_slug;
3643×
    let taken = wiki::page(&app, &repo, &new_slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_some();
3653×
    if (is_new || renamed) && taken {
3660×
        return Ok(again(format!("There is already a page called {}.", wiki::title_of(&new_slug))));
3673×
    }
3683×
    let message = if f.message.trim().is_empty() {
3692×
        format!(
370
            "{} {}",
3712×
            if is_new {
3721×
                "Create"
3731×
            } else if renamed {
3741×
                "Rename and update"
375
            } else {
3760×
                "Update"
377
            },
3782×
            wiki::title_of(&new_slug)
379
        )
380
    } else {
3811×
        f.message.trim().chars().take(200).collect()
382
    };
3833×
    let mut edits = vec![];
3843×
    if renamed {
3851×
        edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&old_slug), content: None });
3862×
    }
3873×
    edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&new_slug), content: Some(content.clone().into_bytes()) });
3883×
    if let Err(e) = wiki::commit(&app, &repo, edits, message.clone(), signer).await {
3890×
        return Ok(again(e.to_string()));
3903×
    }
3913×
    accounts::audit(&app.db, Some(&me.account), "wiki.edited", &repo.to_string(), &new_slug).await.ok();
3923×
    crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": new_slug, "message": message })).await;
3933×
    Ok(Redirect::to(&format!("/{owner}/{name}/wiki/{new_slug}")).into_response())
3943×
}
395
396
#[derive(Deserialize)]
397
pub struct DeleteForm {
398
    #[serde(rename = "_csrf")]
399
    csrf_token: String,
400
}
401
402
/// `POST /{owner}/{name}/wiki/{slug}/delete`: a change that removes the page.
4031×
pub async fn delete(
4041×
    State(app): State<Shared>,
4051×
    viewer: Viewer,
4061×
    Path((owner, name, slug)): Path<(String, String, String)>,
4071×
    Form(f): Form<DeleteForm>,
4081×
) -> Result<Response, Error> {
4091×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer);
4101×
    let me = super::settings::require(&viewer, &shell)?;
4111×
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
4121×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
4131×
    if !wiki::slug_ok(&slug) || wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_none() {
4140×
        return Err(Error::not_found(shell));
4151×
    }
4161×
    let signer = super::edit::signer_for(&app, me.account.id).await.map_err(|m| Error::bad(shell.clone(), m))?;
4171×
    let message = format!("Delete {}", wiki::title_of(&slug));
4181×
    wiki::commit(&app, &repo, vec![oxy_core::repo::FileEdit { path: wiki::path_of(&slug), content: None }], message.clone(), signer)
4191×
        .await
4201×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
4211×
    accounts::audit(&app.db, Some(&me.account), "wiki.deleted", &repo.to_string(), &slug).await.ok();
4221×
    crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": slug, "message": message, "deleted": true }))
4231×
        .await;
4241×
    Ok(Redirect::to(&format!("/{owner}/{name}/wiki?notice={}", super::settings::urlencode(&format!("{} deleted.", wiki::title_of(&slug)))))
4251×
        .into_response())
4261×
}
427
428
/// `GET /{owner}/{name}/wiki/_raw/{*path}`: a file of the wiki as bytes,
429
/// for images pages embed.
4300×
pub async fn raw(
4310×
    State(app): State<Shared>,
4320×
    viewer: Viewer,
4330×
    Path((owner, name, path)): Path<(String, String, String)>,
4340×
) -> Result<Response, Error> {
4350×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_raw/{path}"), &viewer);
4360×
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4370×
    if !wiki::exists(&app, &repo) || path.contains("..") {
4380×
        return Err(Error::not_found(shell));
4390×
    }
4400×
    let p = path.trim_matches('/').to_string();
4410×
    let file_name = p.rsplit('/').next().unwrap_or("").to_string();
4420×
    let bytes = app
4430×
        .wikis
4440×
        .read(&repo, move |r, _| {
4450×
            let heads = r.bookmarks()?.into_iter().find(|b| b.name == wiki::BOOKMARK).map(|b| b.heads).unwrap_or_default();
4460×
            Ok(r.file_at(&p, Some(&heads)).ok())
4470×
        })
4480×
        .await
4490×
        .map_err(|e| Error::from_repo(shell.clone(), e))?
4500×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
4510×
    Ok(super::raw(bytes, &file_name, true))
4520×
}
453
454
#[cfg(test)]
455
mod tests {
456
    #[test]
4571×
    fn headings_get_ids_and_a_contents_list() {
4581×
        let (html, toc) = super::contents("<h1>R* API</h1><p>x</p><h2>Build &amp; run</h2><h2>Build &amp; run</h2><h4>deep</h4><hr>");
4591×
        assert_eq!(
460
            html,
461
            "<h1 id=\"r-api\">R* API</h1><p>x</p><h2 id=\"build-and-run\">Build &amp; run</h2><h2 id=\"build-and-run-2\">Build &amp; run</h2><h4>deep</h4><hr>"
462
        );
4631×
        assert_eq!(
4643×
            toc.iter().map(|t| (t.0, t.2.as_str())).collect::<Vec<_>>(),
465
            [(1, "r-api"), (2, "build-and-run"), (2, "build-and-run-2")]
466
        );
4671×
    }
468
}