OXYGENHUB
# 26d0d091deb7 oxygenhub / crates / hub / src / web / wiki.rs
13.5 KiB · Rust · last changed Wikis: Markdown pages as an Oxygen repository of their own 1 month 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
}
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
}