//! Pages: the repo wiki. `/{owner}/{repo}/wiki` shows `Home`; `/wiki/{page}` shows a page, //! `/wiki/{page}/edit` edits it, `/wiki/{page}/history` lists its commits, `/wiki/-/pages` lists all //! pages and `/wiki/-/new` creates one. Pages are `.md` files on the wiki's `main` branch (see //! forge-git `wiki.rs`). Reading needs repo Read; editing needs repo Write, and the wiki must be on. //! Saves are commits made with plumbing and guarded by the head the editor started from, so a //! concurrent edit is shown as a conflict rather than lost. `[[Page]]` links resolve to pages. use std::path::PathBuf; use forge_core::AuditAction; use forge_core::tz::format_in_tz; use forge_git::repo::wiki_path; use forge_git::wiki::{self, MAX_PAGE_BYTES, PageEdit, PageRevision, valid_page_name}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{Unescaped, View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::community::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::render::markdown::render_markdown; use crate::state::AppState; use crate::views::{alert, repo_href, shell}; path_param!(owner); path_param!(repo); path_param!(wiki_page); /// Longest commit summary accepted for an edit, in characters. const MAX_SUMMARY_CHARS: usize = 200; #[derive(Deserialize)] struct EditForm { #[serde(default)] csrf: String, /// Head the editor started from. Empty when the wiki had no pages. #[serde(default)] base: String, /// Page name, used by the new-page form only. #[serde(default)] name: String, #[serde(default)] content: String, #[serde(default)] message: String, } /// Runs a blocking git read or write on the blocking pool. async fn blocking(f: F) -> Result where F: FnOnce() -> forge_git::Result + Send + 'static, T: Send + 'static, { Ok(tokio::task::spawn_blocking(f).await??) } /// The wiki directory of the repo, whether or not it exists yet. fn wiki_dir(state: &AppState, ctx: &RepoCtx) -> Result { Ok(wiki_path(&state.config.data_dir, &ctx.repo.id).map_err(forge_core::Error::from)?) } /// 404 unless the repo's wiki is on. The header tab is hidden in the same case. async fn ensure_enabled(state: &AppState, ctx: &RepoCtx) -> Result<()> { if state.store.wiki_enabled(&ctx.repo.id).await? { Ok(()) } else { Err(not_found().into()) } } /// The `[[Page]]` and `[[Label|Page]]` links in `src` become Markdown links to the wiki. A link /// whose target is not a valid page name is left as it is. pub fn expand_wiki_links(src: &str, owner: &str, repo: &str) -> String { let mut out = String::with_capacity(src.len()); let mut rest = src; while let Some(start) = rest.find("[[") { out.push_str(&rest[..start]); let after = &rest[start + 2..]; let Some(end) = after.find("]]") else { break; }; let inner = &after[..end]; let (label, target) = match inner.split_once('|') { Some((label, target)) => (label.trim(), target.trim()), None => (inner.trim(), inner.trim()), }; let slug = target.replace(' ', "-"); let plain = !label.contains(['[', ']']) && !label.is_empty(); if plain && valid_page_name(&slug) { let href = repo_href(owner, repo, "wiki", &slug); out.push_str(&format!("[{label}]({href})")); } else { out.push_str(&rest[start..start + 2 + end + 2]); } rest = &after[end + 2..]; } out.push_str(rest); out } /// The page's Markdown as trusted HTML, with wiki links expanded. pub fn render_page(src: &str, owner: &str, repo: &str) -> String { render_markdown(&expand_wiki_links(src, owner, repo), None) } /// Commit summary: one line, trimmed, at most [`MAX_SUMMARY_CHARS`] characters. Blank means a default. fn summary(raw: &str, page: &str) -> String { let line = raw.lines().next().unwrap_or("").trim(); if line.is_empty() { return format!("Edit {page}"); } line.chars().take(MAX_SUMMARY_CHARS).collect() } fn page_href(ctx: &RepoCtx, page: &str) -> String { repo_href(&ctx.repo.owner_name, &ctx.repo.name, "wiki", page) } /// A row of the history list. pub struct RevisionRow { pub short: String, pub href: String, pub author: String, pub when: String, pub subject: String, } fn revision_rows(ctx: &RepoCtx, revs: &[PageRevision], tz: &str) -> Vec { revs.iter() .map(|rev| RevisionRow { short: rev.oid.chars().take(10).collect(), href: repo_href(&ctx.repo.owner_name, &ctx.repo.name, "commit", &rev.oid), author: rev.author.clone(), when: format_in_tz(rev.at, tz), subject: rev.subject.clone(), }) .collect() } #[component] async fn wiki_nav(lang: Lang, owner: String, repo: String, can_write: bool) -> Result { let home = repo_href(&owner, &repo, "wiki", ""); let pages = repo_href(&owner, &repo, "wiki", "-/pages"); let new = repo_href(&owner, &repo, "wiki", "-/new"); Ok(view! { }) } #[component] async fn wiki_view_body( lang: Lang, owner: String, repo: String, name: String, html: String, can_write: bool, ) -> Result { let edit = repo_href(&owner, &repo, "wiki", &format!("{name}/edit")); let history = repo_href(&owner, &repo, "wiki", &format!("{name}/history")); let title = name.replace('-', " "); Ok(view! {

(title.as_str())

(Unescaped::new_unchecked(html))
}) } #[component] async fn wiki_empty_body( lang: Lang, owner: String, repo: String, can_write: bool, ) -> Result { let new = repo_href(&owner, &repo, "wiki", "-/new"); Ok(view! {

(g::t(lang, K::Wiki))

(g::t(lang, K::WikiNoPages))

if can_write {

(g::t(lang, K::CreateFirstPage))

}
}) } #[component] async fn wiki_list_body( lang: Lang, owner: String, repo: String, names: Vec, can_write: bool, ) -> Result { let new = repo_href(&owner, &repo, "wiki", "-/new"); let rows: Vec<(String, String)> = names .into_iter() .map(|name| { let href = repo_href(&owner, &repo, "wiki", &name); (name.replace('-', " "), href) }) .collect(); Ok(view! {

(g::t(lang, K::AllPages))

if can_write {

(g::t(lang, K::NewPage))

} if rows.is_empty() {

(g::t(lang, K::NoPagesYet))

}
}) } #[component] async fn wiki_history_body( lang: Lang, owner: String, repo: String, name: String, rows: Vec, ) -> Result { let back = repo_href(&owner, &repo, "wiki", &name); let title = name.replace('-', " "); Ok(view! {

(title.as_str())

if rows.is_empty() {

(g::t(lang, K::NoChangesYet))

} for row in rows.into_iter() { }
(row.short.as_str()) (row.subject.as_str()) (row.author.as_str()) (row.when.as_str())
}) } #[component] async fn wiki_edit_body( lang: Lang, action: String, csrf: String, base: String, name: String, is_new: bool, content: String, error: Option, ) -> Result { let title = if is_new { g::t(lang, K::NewPage).to_owned() } else { name.replace('-', " ") }; Ok(view! {

(title.as_str())

if let Some(message) = error { alert(message: message.as_str()) } if is_new { }
}) } /// Reads the page `name` and the wiki head in one blocking call. async fn load_page(dir: PathBuf, name: String) -> Result<(Option, Option)> { blocking(move || { let head = wiki::head_commit(&dir)?; let text = wiki::read_page(&dir, &name)?; Ok((head, text)) }) .await } #[page("/{owner}/{repo}/wiki")] async fn wiki_home(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; let dir = wiki_dir(state, &ctx)?; let (_, home) = load_page(dir, "Home".to_owned()).await?; let can_write = ctx.can_write(); let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let body_html = home.map(|src| render_page(&src, &owner_name, &repo_name)); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let title = format!( "{}/{} \u{b7} {}", ctx.repo.owner_name, ctx.repo.name, g::t(lang, K::Wiki) ); let header = ctx.head(state, "wiki").await?; Ok(view! { cx => shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write) if let Some(html) = body_html.clone() { wiki_view_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), name: "Home".to_owned(), html: html, can_write: can_write) } if body_html.is_none() { wiki_empty_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write) } ) }) } #[page("/{owner}/{repo}/wiki/-/pages")] async fn wiki_pages(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let dir = wiki_dir(state, &ctx)?; let names = blocking(move || wiki::list_pages(&dir)).await?; let can_write = ctx.can_write(); let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let title = format!( "{}/{} \u{b7} {}", ctx.repo.owner_name, ctx.repo.name, g::t(lang, K::WikiPages) ); let header = ctx.head(state, "wiki").await?; Ok(view! { cx => shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write) wiki_list_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), names: names, can_write: can_write) ) }) } #[page("/{owner}/{repo}/wiki/{wiki_page}")] async fn wiki_page_view(cx: &Cx) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let page_name = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, repo_param).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; if !valid_page_name(&page_name) { return Err(not_found().into()); } let dir = wiki_dir(state, &ctx)?; let (_, text) = load_page(dir, page_name.clone()).await?; let Some(text) = text else { return Err(not_found().into()); }; let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let html = render_page(&text, &owner_name, &repo_name); let can_write = ctx.can_write(); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let title = format!( "{} \u{b7} {}/{} wiki", page_name.replace('-', " "), ctx.repo.owner_name, ctx.repo.name ); let header = ctx.head(state, "wiki").await?; Ok(view! { cx => shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: can_write) wiki_view_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), name: page_name.clone(), html: html, can_write: can_write) ) }) } #[page("/{owner}/{repo}/wiki/{wiki_page}/history")] async fn wiki_page_history(cx: &Cx) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let page_name = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, repo_param).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; if !valid_page_name(&page_name) { return Err(not_found().into()); } let dir = wiki_dir(state, &ctx)?; let git = state.git.clone(); let revs = wiki::page_history(&git, &dir, &page_name).await?; let tz = ctx.viewer_tz().to_owned(); let rows = revision_rows(&ctx, &revs, &tz); let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let title = format!( "{} \u{b7} {}/{} wiki", g::t(lang, K::History), ctx.repo.owner_name, ctx.repo.name ); let header = ctx.head(state, "wiki").await?; Ok(view! { cx => shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: ctx.can_write()) wiki_history_body(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), name: page_name.clone(), rows: rows) ) }) } /// The edit screen for `name` (existing or new). Writers only. async fn edit_screen( cx: &Cx, ctx: RepoCtx, name: String, is_new: bool, content: String, error: Option, ) -> Result { let state = app_context::(cx); let (user, session) = require_user(cx).await?; let lang = i18n::lang(cx, Some(&user)); let dir = wiki_dir(state, &ctx)?; let head = blocking(move || wiki::head_commit(&dir)).await?; let action = if is_new { repo_href(&ctx.repo.owner_name, &ctx.repo.name, "wiki", "-/new") } else { repo_href( &ctx.repo.owner_name, &ctx.repo.name, "wiki", &format!("{name}/edit"), ) }; let title = format!( "{} \u{b7} {}/{} wiki", g::t(lang, K::Edit), ctx.repo.owner_name, ctx.repo.name ); let header = ctx.head(state, "wiki").await?; let owner_name = ctx.repo.owner_name.clone(); let repo_name = ctx.repo.name.clone(); let csrf = session.csrf_token.clone(); let base = head.unwrap_or_default(); Ok(view! { cx => shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) wiki_nav(lang: lang, owner: owner_name.clone(), repo: repo_name.clone(), can_write: true) wiki_edit_body(lang: lang, action: action, csrf: csrf, base: base, name: name.clone(), is_new: is_new, content: content, error: error) ) }) } #[page("/{owner}/{repo}/wiki/-/new")] async fn wiki_new_page(cx: &Cx) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let ctx = load_repo(cx, owner, repo_param).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; if !ctx.can_write() { return Err(forbidden().into()); } edit_screen(cx, ctx, String::new(), true, String::new(), None).await } #[page("/{owner}/{repo}/wiki/{wiki_page}/edit")] async fn wiki_edit_page(cx: &Cx) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let page_name = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, repo_param).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; if !ctx.can_write() { return Err(forbidden().into()); } if !valid_page_name(&page_name) { return Err(not_found().into()); } let dir = wiki_dir(state, &ctx)?; let (_, text) = load_page(dir, page_name.clone()).await?; edit_screen(cx, ctx, page_name, false, text.unwrap_or_default(), None).await } #[page(POST "/{owner}/{repo}/wiki/-/new")] async fn wiki_new_post(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let ctx = load_repo(cx, owner, repo_param).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; if !ctx.can_write() { return Err(forbidden().into()); } let (viewer, _) = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer)); let name = form.name.trim().to_owned(); let refused = if !valid_page_name(&name) { Some(g::t(lang, K::PageNameRules).to_owned()) } else { let dir = wiki_dir(state, &ctx)?; let (_, existing) = load_page(dir, name.clone()).await?; if existing.is_some() { Some(g::t(lang, K::PageExists).to_owned()) } else { None } }; let refused = match refused { Some(message) => Some(message), None => match save(cx, &ctx, &form, &name).await? { SaveOutcome::Saved(href) => return Err(see_other(href).into()), SaveOutcome::Refused(message) => Some(message), }, }; edit_screen(cx, ctx, name, true, form.content.clone(), refused).await } #[page(POST "/{owner}/{repo}/wiki/{wiki_page}/edit")] async fn wiki_edit_post(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let page_name = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, repo_param).await?; let state = app_context::(cx); ensure_enabled(state, &ctx).await?; if !ctx.can_write() { return Err(forbidden().into()); } require_csrf(cx, &form.csrf).await?; if !valid_page_name(&page_name) { return Err(not_found().into()); } let refused = match save(cx, &ctx, &form, &page_name).await? { SaveOutcome::Saved(href) => return Err(see_other(href).into()), SaveOutcome::Refused(message) => Some(message), }; edit_screen(cx, ctx, page_name, false, form.content.clone(), refused).await } /// How a save ended: redirected to the page, or refused with a message for the editor. enum SaveOutcome { Saved(String), Refused(String), } /// Writes the page as a commit guarded by the form's `base`. A conflicting or invalid save is /// refused with a message, and the caller shows the edit screen again with the typed text. async fn save(cx: &Cx, ctx: &RepoCtx, form: &EditForm, name: &str) -> Result { let state = app_context::(cx); let (user, _) = require_user(cx).await?; let lang = i18n::lang(cx, Some(&user)); if form.content.len() > MAX_PAGE_BYTES { return Ok(SaveOutcome::Refused(g::page_too_large( lang, MAX_PAGE_BYTES, ))); } let dir = wiki_dir(state, ctx)?; let base = form.base.trim(); let base = (!base.is_empty()).then_some(base.to_owned()); let message = summary(&form.message, name); let author_email = user .email .clone() .unwrap_or_else(|| format!("{}@users.noreply.forge", user.username)); let edit = PageEdit { name, content: form.content.as_bytes(), message: &message, author_name: &user.username, author_email: &author_email, }; let git = state.git.clone(); let commit = match wiki::commit_page(&git, &dir, base.as_deref(), &edit).await { Ok(commit) => commit, Err(forge_git::Error::RefMoved(_)) => { return Ok(SaveOutcome::Refused(g::t(lang, K::WikiConflict).to_owned())); } Err(forge_git::Error::InvalidInput(msg)) => return Ok(SaveOutcome::Refused(msg)), Err(e) => return Err(forge_core::Error::from(e).into()), }; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::WikiPageSaved, Target { kind: "repo", id: &ctx.repo.id, name: &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name), }, serde_json::json!({ "page": name, "commit": commit }), ) .await; crate::replication::enqueue_repo(state, &ctx.repo.id); tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, page = %name, "wiki page saved"); Ok(SaveOutcome::Saved(page_href(ctx, name))) } #[component] async fn wiki_settings_body( lang: Lang, action: String, csrf: String, enabled: bool, ) -> Result { Ok(view! {

(g::t(lang, K::WikiSettings))

(g::t(lang, K::WikiDisabledHint))
}) } #[page("/{owner}/{repo}/settings/wiki")] async fn wiki_settings_page(cx: &Cx) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let ctx = load_repo(cx, owner, repo_param).await?; let (user, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(not_found().into()); } let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let enabled = state.store.wiki_enabled(&ctx.repo.id).await?; let title = format!( "{}/{} \u{b7} {}", ctx.repo.owner_name, ctx.repo.name, g::t(lang, K::WikiSettings) ); let header = ctx.head(state, "settings").await?; let action = repo_href(&ctx.repo.owner_name, &ctx.repo.name, "settings/wiki", ""); let csrf = session.csrf_token.clone(); Ok(view! { cx => shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header) wiki_settings_body(lang: lang, action: action, csrf: csrf, enabled: enabled) ) }) } #[derive(Deserialize)] struct WikiSettingsForm { #[serde(default)] csrf: String, #[serde(default)] enabled: Option, } #[route(POST "/{owner}/{repo}/settings/wiki")] async fn wiki_settings_post(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let repo_param = path_param::(cx); let ctx = load_repo(cx, owner, repo_param).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let state = app_context::(cx); let enable = form.enabled.is_some(); let before = state.store.wiki_enabled(&ctx.repo.id).await?; state.store.set_wiki_enabled(&ctx.repo.id, enable).await?; if before != enable { let action = if enable { AuditAction::WikiEnabled } else { AuditAction::WikiDisabled }; audit::record( &state.store, Some(cx), Actor::User(&user), action, Target { kind: "repo", id: &ctx.repo.id, name: &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name), }, serde_json::json!({ "enabled": enable }), ) .await; } Ok(see_other(repo_href( &ctx.repo.owner_name, &ctx.repo.name, "settings/wiki", "", ))) } #[cfg(test)] mod tests { use super::*; #[test] fn wiki_links_resolve_and_invalid_targets_stay_text() { let out = expand_wiki_links( "See [[Home]] and [[Getting Started|Start]].", "alice", "proj", ); assert!(out.contains("[Home](/alice/proj/wiki/Home)"), "{out}"); assert!( out.contains("[Getting Started](/alice/proj/wiki/Start)"), "{out}" ); let spaced = expand_wiki_links("[[Getting Started]]", "alice", "proj"); assert_eq!( spaced, "[Getting Started](/alice/proj/wiki/Getting-Started)" ); let bad = expand_wiki_links("[[../etc]] and [[]]", "alice", "proj"); assert_eq!(bad, "[[../etc]] and [[]]"); } #[test] fn summary_defaults_and_is_one_line() { assert_eq!(summary(" ", "Home"), "Edit Home"); assert_eq!(summary("first\nsecond", "Home"), "first"); } }