OXYGENHUB
◆ v0.5.2 oxygenhub / crates / hub / src / web / wiki.rs
16.8 KiB · Rust · last changed UI polish: keys, backups, wiki, editor, bookmarks, summaries, backdrops 21 days ago source blame history raw
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
73
fn base(owner: &str, name: &str) -> String {
74
    format!("/{owner}/{name}/wiki/")
75
}
76
77
fn render(owner: &str, name: &str, text: &str) -> String {
78
    crate::render::markdown::render(text, &base(owner, name), &format!("/{owner}/{name}/wiki/_raw/"))
79
}
80
81
/// Whether the viewer may change the wiki: a writer, and not archived.
82
fn editable(row: &crate::db::repos::RepoRow, rs: &RepoShell) -> bool {
83
    rs.can(Access::Write) && !row.archived
84
}
85
86
#[derive(Deserialize)]
87
pub struct ShowQuery {
88
    notice: Option<String>,
89
}
90
91
/// `GET /{owner}/{name}/wiki`: the Home page.
92
pub async fn home(
93
    State(app): State<Shared>,
94
    viewer: Viewer,
95
    Path((owner, name)): Path<(String, String)>,
96
    Query(q): Query<ShowQuery>,
97
) -> Result<Page<WikiPage>, Error> {
98
    show_page(app, viewer, owner, name, wiki::HOME.into(), q.notice).await
99
}
100
101
/// `GET /{owner}/{name}/wiki/{slug}`.
102
pub async fn show(
103
    State(app): State<Shared>,
104
    viewer: Viewer,
105
    Path((owner, name, slug)): Path<(String, String, String)>,
106
    Query(q): Query<ShowQuery>,
107
) -> Result<Page<WikiPage>, Error> {
108
    show_page(app, viewer, owner, name, slug, q.notice).await
109
}
110
111
async fn show_page(
112
    app: Shared,
113
    viewer: Viewer,
114
    owner: String,
115
    name: String,
116
    slug: String,
117
    notice: Option<String>,
118
) -> Result<Page<WikiPage>, Error> {
119
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer);
120
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
121
    if !wiki::slug_ok(&slug) {
122
        return Err(Error::not_found(shell));
123
    }
124
    let e = |e| Error::from_repo(shell.clone(), e);
125
    let text = wiki::page(&app, &repo, &slug).await.map_err(e)?;
126
    let sidebar = wiki::page(&app, &repo, wiki::SIDEBAR).await.map_err(e)?;
127
    let pages: Vec<String> = wiki::pages(&app, &repo).await.map_err(e)?.into_iter().filter(|p| p != wiki::SIDEBAR).collect();
128
    let any = !pages.is_empty();
129
    let history = if text.is_some() { wiki::history(&app, &repo, &slug, 1000).await.map_err(e)? } else { vec![] };
130
    let revisions = history.len();
131
    let last = history.into_iter().next();
132
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role).with_sky(&app, &row).await;
133
    let can_edit = editable(&row, &rs);
134
    let (html, toc) = match text.as_deref().map(|t| contents(&render(&owner, &name, t))) {
135
        Some((h, t)) => (Some(h), t),
136
        None => (None, vec![]),
137
    };
138
    Ok(Page(WikiPage {
139
        html,
140
        pages,
141
        revisions,
142
        toc,
143
        sidebar: sidebar.as_deref().map(|t| render(&owner, &name, t)),
144
        title: wiki::title_of(&slug),
145
        shell,
146
        repo: rs,
147
        slug,
148
        any,
149
        can_edit,
150
        last,
151
        notice,
152
    }))
153
}
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.
159
fn contents(html: &str) -> (String, Vec<(u8, String, String)>) {
160
    let mut out = String::with_capacity(html.len() + 64);
161
    let mut toc = vec![];
162
    let mut used = std::collections::HashSet::new();
163
    let mut rest = html;
164
    while let Some(at) = rest.find("<h") {
165
        let level = rest.as_bytes().get(at + 2).copied().unwrap_or(0);
166
        let tagged = matches!(level, b'1'..=b'3') && rest[at + 3..].starts_with('>');
167
        let close = format!("</h{}>", level as char);
168
        let end = if tagged { rest[at..].find(&close).map(|e| at + e) } else { None };
169
        let Some(end) = end else {
170
            out.push_str(&rest[..at + 2]);
171
            rest = &rest[at + 2..];
172
            continue;
173
        };
174
        let inner = &rest[at + 4..end];
175
        let mut text = String::new();
176
        let mut in_tag = false;
177
        for c in inner.chars() {
178
            match c {
179
                '<' => in_tag = true,
180
                '>' => in_tag = false,
181
                c if !in_tag => text.push(c),
182
                _ => {}
183
            }
184
        }
185
        let mut id: String = text
186
            .replace("&amp;", "and")
187
            .chars()
188
            .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
189
            .collect::<String>()
190
            .split('-')
191
            .filter(|p| !p.is_empty())
192
            .collect::<Vec<_>>()
193
            .join("-");
194
        if id.is_empty() {
195
            id = "section".into();
196
        }
197
        let mut unique = id.clone();
198
        let mut n = 1;
199
        while !used.insert(unique.clone()) {
200
            n += 1;
201
            unique = format!("{id}-{n}");
202
        }
203
        out.push_str(&rest[..at]);
204
        out.push_str(&format!("<h{l} id=\"{unique}\">{inner}{close}", l = level as char));
205
        toc.push((level - b'0', text.trim().to_string(), unique));
206
        rest = &rest[end + close.len()..];
207
    }
208
    out.push_str(rest);
209
    (out, toc)
210
}
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.
219
pub async fn pages(
220
    State(app): State<Shared>,
221
    viewer: Viewer,
222
    Path((owner, name)): Path<(String, String)>,
223
    Query(q): Query<PagesQuery>,
224
) -> Result<Page<WikiPagesPage>, Error> {
225
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_pages"), &viewer);
226
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
227
    let e = |e| Error::from_repo(shell.clone(), e);
228
    let pages = wiki::pages(&app, &repo).await.map_err(e)?;
229
    let q: String = q.q.trim().chars().take(100).collect();
230
    let hits = if q.is_empty() { vec![] } else { wiki::search(&app, &repo, &q).await.map_err(e)? };
231
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role);
232
    let can_edit = editable(&row, &rs);
233
    Ok(Page(WikiPagesPage { shell, repo: rs, pages, q, hits, can_edit }))
234
}
235
236
/// `GET /{owner}/{name}/wiki/{slug}/history`.
237
pub async fn history(
238
    State(app): State<Shared>,
239
    viewer: Viewer,
240
    Path((owner, name, slug)): Path<(String, String, String)>,
241
) -> Result<Page<WikiHistoryPage>, Error> {
242
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/history"), &viewer);
243
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
244
    if !wiki::slug_ok(&slug) {
245
        return Err(Error::not_found(shell));
246
    }
247
    let mut entries = wiki::history(&app, &repo, &slug, 100).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
248
    super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
249
    Ok(Page(WikiHistoryPage {
250
        title: wiki::title_of(&slug),
251
        shell,
252
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
253
        slug,
254
        entries,
255
    }))
256
}
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.
265
pub async fn new_form(
266
    State(app): State<Shared>,
267
    viewer: Viewer,
268
    Path((owner, name)): Path<(String, String)>,
269
    Query(q): Query<NewQuery>,
270
) -> Result<Page<WikiEditPage>, Error> {
271
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_new"), &viewer);
272
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
273
    let slug = wiki::slug_of(&q.title);
274
    Ok(Page(WikiEditPage {
275
        title: if slug.is_empty() { String::new() } else { wiki::title_of(&slug) },
276
        shell,
277
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
278
        slug,
279
        content: String::new(),
280
        exists: false,
281
        error: None,
282
    }))
283
}
284
285
/// `GET /{owner}/{name}/wiki/{slug}/edit`.
286
pub async fn edit_form(
287
    State(app): State<Shared>,
288
    viewer: Viewer,
289
    Path((owner, name, slug)): Path<(String, String, String)>,
290
) -> Result<Page<WikiEditPage>, Error> {
291
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer);
292
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
293
    if !wiki::slug_ok(&slug) {
294
        return Err(Error::not_found(shell));
295
    }
296
    let text = wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
297
    Ok(Page(WikiEditPage {
298
        title: wiki::title_of(&slug),
299
        shell,
300
        repo: RepoShell::new(&app, &row, "wiki").with_role(role),
301
        slug,
302
        exists: text.is_some(),
303
        content: text.unwrap_or_default(),
304
        error: None,
305
    }))
306
}
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.
324
pub async fn save(
325
    State(app): State<Shared>,
326
    viewer: Viewer,
327
    Path((owner, name, slug)): Path<(String, String, String)>,
328
    Form(f): Form<SaveForm>,
329
) -> Result<Response, Error> {
330
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer);
331
    let me = super::settings::require(&viewer, &shell)?;
332
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
333
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
334
    let is_new = slug == "_new";
335
    let old_slug = if is_new { String::new() } else { slug.clone() };
336
    let new_slug = wiki::slug_of(&f.title);
337
    let content = f.content.replace("\r\n", "\n");
338
    let again = |msg: String| {
339
        Page(WikiEditPage {
340
            title: f.title.clone(),
341
            shell: shell.clone(),
342
            repo: RepoShell::new(&app, &row, "wiki").with_role(role),
343
            slug: if is_new { new_slug.clone() } else { old_slug.clone() },
344
            content: content.clone(),
345
            exists: !is_new,
346
            error: Some(msg),
347
        })
348
        .into_response()
349
    };
350
    if !is_new && !wiki::slug_ok(&old_slug) {
351
        return Err(Error::not_found(shell));
352
    }
353
    if !wiki::slug_ok(&new_slug) {
354
        return Ok(again("A page title is letters, digits, spaces, dots, dashes and underscores, up to 100 characters.".into()));
355
    }
356
    if content.trim().is_empty() {
357
        return Ok(again("Write something on the page.".into()));
358
    }
359
    let signer = match super::edit::signer_for(&app, me.account.id).await {
360
        Ok(s) => s,
361
        Err(msg) => return Ok(again(msg)),
362
    };
363
    let renamed = !is_new && new_slug != old_slug;
364
    let taken = wiki::page(&app, &repo, &new_slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_some();
365
    if (is_new || renamed) && taken {
366
        return Ok(again(format!("There is already a page called {}.", wiki::title_of(&new_slug))));
367
    }
368
    let message = if f.message.trim().is_empty() {
369
        format!(
370
            "{} {}",
371
            if is_new {
372
                "Create"
373
            } else if renamed {
374
                "Rename and update"
375
            } else {
376
                "Update"
377
            },
378
            wiki::title_of(&new_slug)
379
        )
380
    } else {
381
        f.message.trim().chars().take(200).collect()
382
    };
383
    let mut edits = vec![];
384
    if renamed {
385
        edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&old_slug), content: None });
386
    }
387
    edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&new_slug), content: Some(content.clone().into_bytes()) });
388
    if let Err(e) = wiki::commit(&app, &repo, edits, message.clone(), signer).await {
389
        return Ok(again(e.to_string()));
390
    }
391
    accounts::audit(&app.db, Some(&me.account), "wiki.edited", &repo.to_string(), &new_slug).await.ok();
392
    crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": new_slug, "message": message })).await;
393
    Ok(Redirect::to(&format!("/{owner}/{name}/wiki/{new_slug}")).into_response())
394
}
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.
403
pub async fn delete(
404
    State(app): State<Shared>,
405
    viewer: Viewer,
406
    Path((owner, name, slug)): Path<(String, String, String)>,
407
    Form(f): Form<DeleteForm>,
408
) -> Result<Response, Error> {
409
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer);
410
    let me = super::settings::require(&viewer, &shell)?;
411
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
412
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
413
    if !wiki::slug_ok(&slug) || wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_none() {
414
        return Err(Error::not_found(shell));
415
    }
416
    let signer = super::edit::signer_for(&app, me.account.id).await.map_err(|m| Error::bad(shell.clone(), m))?;
417
    let message = format!("Delete {}", wiki::title_of(&slug));
418
    wiki::commit(&app, &repo, vec![oxy_core::repo::FileEdit { path: wiki::path_of(&slug), content: None }], message.clone(), signer)
419
        .await
420
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
421
    accounts::audit(&app.db, Some(&me.account), "wiki.deleted", &repo.to_string(), &slug).await.ok();
422
    crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": slug, "message": message, "deleted": true }))
423
        .await;
424
    Ok(Redirect::to(&format!("/{owner}/{name}/wiki?notice={}", super::settings::urlencode(&format!("{} deleted.", wiki::title_of(&slug)))))
425
        .into_response())
426
}
427
428
/// `GET /{owner}/{name}/wiki/_raw/{*path}`: a file of the wiki as bytes,
429
/// for images pages embed.
430
pub async fn raw(
431
    State(app): State<Shared>,
432
    viewer: Viewer,
433
    Path((owner, name, path)): Path<(String, String, String)>,
434
) -> Result<Response, Error> {
435
    let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_raw/{path}"), &viewer);
436
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
437
    if !wiki::exists(&app, &repo) || path.contains("..") {
438
        return Err(Error::not_found(shell));
439
    }
440
    let p = path.trim_matches('/').to_string();
441
    let file_name = p.rsplit('/').next().unwrap_or("").to_string();
442
    let bytes = app
443
        .wikis
444
        .read(&repo, move |r, _| {
445
            let heads = r.bookmarks()?.into_iter().find(|b| b.name == wiki::BOOKMARK).map(|b| b.heads).unwrap_or_default();
446
            Ok(r.file_at(&p, Some(&heads)).ok())
447
        })
448
        .await
449
        .map_err(|e| Error::from_repo(shell.clone(), e))?
450
        .ok_or_else(|| Error::not_found(shell.clone()))?;
451
    Ok(super::raw(bytes, &file_name, true))
452
}
453
454
#[cfg(test)]
455
mod tests {
456
    #[test]
457
    fn headings_get_ids_and_a_contents_list() {
458
        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>");
459
        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
        );
463
        assert_eq!(
464
            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
        );
467
    }
468
}