//! Wiki pages on the web (hub spec ยง6.11, oxygenhub#104): view, edit, //! history, the page list with search, and raw files for images. use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::db::accounts; use crate::repos::view::ChangeView; use crate::wiki; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use axum::Form; use serde::Deserialize; #[derive(Template)] #[template(path = "wiki.html")] pub struct WikiPage { shell: Shell, repo: RepoShell, slug: String, title: String, /// Rendered Markdown, or `None` when there is no such page. html: Option, sidebar: Option, /// Whether the wiki has any page at all. any: bool, can_edit: bool, last: Option, notice: Option, } #[derive(Template)] #[template(path = "wiki_edit.html")] pub struct WikiEditPage { shell: Shell, repo: RepoShell, slug: String, title: String, content: String, /// Whether the page exists (an edit) or is new. exists: bool, error: Option, } #[derive(Template)] #[template(path = "wiki_pages.html")] pub struct WikiPagesPage { shell: Shell, repo: RepoShell, pages: Vec, q: String, hits: Vec<(String, String)>, can_edit: bool, } #[derive(Template)] #[template(path = "wiki_history.html")] pub struct WikiHistoryPage { shell: Shell, repo: RepoShell, slug: String, title: String, entries: Vec, } fn base(owner: &str, name: &str) -> String { format!("/{owner}/{name}/wiki/") } fn render(owner: &str, name: &str, text: &str) -> String { crate::render::markdown::render(text, &base(owner, name), &format!("/{owner}/{name}/wiki/_raw/")) } /// Whether the viewer may change the wiki: a writer, and not archived. fn editable(row: &crate::db::repos::RepoRow, rs: &RepoShell) -> bool { rs.can(Access::Write) && !row.archived } #[derive(Deserialize)] pub struct ShowQuery { notice: Option, } /// `GET /{owner}/{name}/wiki`: the Home page. pub async fn home( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { show_page(app, viewer, owner, name, wiki::HOME.into(), q.notice).await } /// `GET /{owner}/{name}/wiki/{slug}`. pub async fn show( State(app): State, viewer: Viewer, Path((owner, name, slug)): Path<(String, String, String)>, Query(q): Query, ) -> Result, Error> { show_page(app, viewer, owner, name, slug, q.notice).await } async fn show_page( app: Shared, viewer: Viewer, owner: String, name: String, slug: String, notice: Option, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if !wiki::slug_ok(&slug) { return Err(Error::not_found(shell)); } let e = |e| Error::from_repo(shell.clone(), e); let text = wiki::page(&app, &repo, &slug).await.map_err(e)?; let sidebar = wiki::page(&app, &repo, wiki::SIDEBAR).await.map_err(e)?; let any = !wiki::pages(&app, &repo).await.map_err(e)?.is_empty(); let last = if text.is_some() { wiki::history(&app, &repo, &slug, 1).await.map_err(e)?.into_iter().next() } else { None }; let rs = RepoShell::new(&app, &row, "wiki").with_role(role); let can_edit = editable(&row, &rs); Ok(Page(WikiPage { html: text.as_deref().map(|t| render(&owner, &name, t)), sidebar: sidebar.as_deref().map(|t| render(&owner, &name, t)), title: wiki::title_of(&slug), shell, repo: rs, slug, any, can_edit, last, notice, })) } #[derive(Deserialize)] pub struct PagesQuery { #[serde(default)] q: String, } /// `GET /{owner}/{name}/wiki/_pages`: every page, and a search. pub async fn pages( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_pages"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_repo(shell.clone(), e); let pages = wiki::pages(&app, &repo).await.map_err(e)?; let q: String = q.q.trim().chars().take(100).collect(); let hits = if q.is_empty() { vec![] } else { wiki::search(&app, &repo, &q).await.map_err(e)? }; let rs = RepoShell::new(&app, &row, "wiki").with_role(role); let can_edit = editable(&row, &rs); Ok(Page(WikiPagesPage { shell, repo: rs, pages, q, hits, can_edit })) } /// `GET /{owner}/{name}/wiki/{slug}/history`. pub async fn history( State(app): State, viewer: Viewer, Path((owner, name, slug)): Path<(String, String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/history"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if !wiki::slug_ok(&slug) { return Err(Error::not_found(shell)); } let mut entries = wiki::history(&app, &repo, &slug, 100).await.map_err(|e| Error::from_repo(shell.clone(), e))?; super::attribute(&app, &mut entries.iter_mut().collect::>()).await; Ok(Page(WikiHistoryPage { title: wiki::title_of(&slug), shell, repo: RepoShell::new(&app, &row, "wiki").with_role(role), slug, entries, })) } #[derive(Deserialize)] pub struct NewQuery { #[serde(default)] title: String, } /// `GET /{owner}/{name}/wiki/_new`: a new page, named by the form. pub async fn new_form( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_new"), &viewer); let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let slug = wiki::slug_of(&q.title); Ok(Page(WikiEditPage { title: if slug.is_empty() { String::new() } else { wiki::title_of(&slug) }, shell, repo: RepoShell::new(&app, &row, "wiki").with_role(role), slug, content: String::new(), exists: false, error: None, })) } /// `GET /{owner}/{name}/wiki/{slug}/edit`. pub async fn edit_form( State(app): State, viewer: Viewer, Path((owner, name, slug)): Path<(String, String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer); let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; if !wiki::slug_ok(&slug) { return Err(Error::not_found(shell)); } let text = wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?; Ok(Page(WikiEditPage { title: wiki::title_of(&slug), shell, repo: RepoShell::new(&app, &row, "wiki").with_role(role), slug, exists: text.is_some(), content: text.unwrap_or_default(), error: None, })) } #[derive(Deserialize)] pub struct SaveForm { #[serde(rename = "_csrf")] csrf_token: String, /// The page's title; its slug is the file name. A changed title on an /// existing page renames it. #[serde(default)] title: String, #[serde(default)] content: String, #[serde(default)] message: String, } /// `POST /{owner}/{name}/wiki/{slug}/edit` (`_new` for a new page): the /// change is authored by the user and signed by the hub as their delegate. pub async fn save( State(app): State, viewer: Viewer, Path((owner, name, slug)): Path<(String, String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}/edit"), &viewer); let me = super::settings::require(&viewer, &shell)?; super::settings::csrf(&viewer, &f.csrf_token, &shell)?; let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let is_new = slug == "_new"; let old_slug = if is_new { String::new() } else { slug.clone() }; let new_slug = wiki::slug_of(&f.title); let content = f.content.replace("\r\n", "\n"); let again = |msg: String| { Page(WikiEditPage { title: f.title.clone(), shell: shell.clone(), repo: RepoShell::new(&app, &row, "wiki").with_role(role), slug: if is_new { new_slug.clone() } else { old_slug.clone() }, content: content.clone(), exists: !is_new, error: Some(msg), }) .into_response() }; if !is_new && !wiki::slug_ok(&old_slug) { return Err(Error::not_found(shell)); } if !wiki::slug_ok(&new_slug) { return Ok(again("A page title is letters, digits, spaces, dots, dashes and underscores, up to 100 characters.".into())); } if content.trim().is_empty() { return Ok(again("Write something on the page.".into())); } let signer = match super::edit::signer_for(&app, me.account.id).await { Ok(s) => s, Err(msg) => return Ok(again(msg)), }; let renamed = !is_new && new_slug != old_slug; let taken = wiki::page(&app, &repo, &new_slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_some(); if (is_new || renamed) && taken { return Ok(again(format!("There is already a page called {}.", wiki::title_of(&new_slug)))); } let message = if f.message.trim().is_empty() { format!( "{} {}", if is_new { "Create" } else if renamed { "Rename and update" } else { "Update" }, wiki::title_of(&new_slug) ) } else { f.message.trim().chars().take(200).collect() }; let mut edits = vec![]; if renamed { edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&old_slug), content: None }); } edits.push(oxy_core::repo::FileEdit { path: wiki::path_of(&new_slug), content: Some(content.clone().into_bytes()) }); if let Err(e) = wiki::commit(&app, &repo, edits, message.clone(), signer).await { return Ok(again(e.to_string())); } accounts::audit(&app.db, Some(&me.account), "wiki.edited", &repo.to_string(), &new_slug).await.ok(); crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": new_slug, "message": message })).await; Ok(Redirect::to(&format!("/{owner}/{name}/wiki/{new_slug}")).into_response()) } #[derive(Deserialize)] pub struct DeleteForm { #[serde(rename = "_csrf")] csrf_token: String, } /// `POST /{owner}/{name}/wiki/{slug}/delete`: a change that removes the page. pub async fn delete( State(app): State, viewer: Viewer, Path((owner, name, slug)): Path<(String, String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/{slug}"), &viewer); let me = super::settings::require(&viewer, &shell)?; super::settings::csrf(&viewer, &f.csrf_token, &shell)?; let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; if !wiki::slug_ok(&slug) || wiki::page(&app, &repo, &slug).await.map_err(|e| Error::from_repo(shell.clone(), e))?.is_none() { return Err(Error::not_found(shell)); } let signer = super::edit::signer_for(&app, me.account.id).await.map_err(|m| Error::bad(shell.clone(), m))?; let message = format!("Delete {}", wiki::title_of(&slug)); wiki::commit(&app, &repo, vec![oxy_core::repo::FileEdit { path: wiki::path_of(&slug), content: None }], message.clone(), signer) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "wiki.deleted", &repo.to_string(), &slug).await.ok(); crate::hooks::emit(&app, &row, "wiki", &me.account.username, serde_json::json!({ "page": slug, "message": message, "deleted": true })) .await; Ok(Redirect::to(&format!("/{owner}/{name}/wiki?notice={}", super::settings::urlencode(&format!("{} deleted.", wiki::title_of(&slug))))) .into_response()) } /// `GET /{owner}/{name}/wiki/_raw/{*path}`: a file of the wiki as bytes, /// for images pages embed. pub async fn raw( State(app): State, viewer: Viewer, Path((owner, name, path)): Path<(String, String, String)>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/wiki/_raw/{path}"), &viewer); let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; if !wiki::exists(&app, &repo) || path.contains("..") { return Err(Error::not_found(shell)); } let p = path.trim_matches('/').to_string(); let file_name = p.rsplit('/').next().unwrap_or("").to_string(); let bytes = app .wikis .read(&repo, move |r, _| { let heads = r.bookmarks()?.into_iter().find(|b| b.name == wiki::BOOKMARK).map(|b| b.heads).unwrap_or_default(); Ok(r.file_at(&p, Some(&heads)).ok()) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; Ok(super::raw(bytes, &file_name, true)) }