//! Pages for snippets: `/snippets` (list), `/snippets/new`, `/snippets/{id}` (view), //! `/snippets/{id}/edit`, `/snippets/{id}/revisions` and `POST /snippets/{id}/delete`. //! Visibility decides who may read: a private snippet answers 404 to everyone but its owner. //! Only the owner edits or deletes, and every save and delete is audited. Files are served on the //! user-content origin (see `snippet_raw.rs`), and the page links to them. use std::collections::HashMap; use forge_core::store::snippets::{ MAX_FILES, MAX_TOTAL_BYTES, Snippet, SnippetFile, SnippetRevision, SnippetSummary, SnippetVisibility, }; use forge_core::{AuditAction, Session, User}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, 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::{current_user, require_csrf, require_user}; use crate::i18n::community::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::snippet_raw::snippet_raw_url; use crate::render::highlight::highlight_lines; use crate::render::markdown::render_markdown; use crate::state::AppState; use crate::views::{alert, shell, user_facing_message}; path_param!(id); /// What the form shows for one file slot. struct Slot { number: usize, name: String, content: String, } /// The editable state of a snippet, as typed or as saved. #[derive(Clone, Default)] struct Draft { title: String, description: String, visibility: String, /// Each file with the number of its form fields (`name_`, `content_`). The number stays /// with the file when another is added or removed: the browser restores typed text by field /// name, so a renumbered card would get the wrong text back. files: Vec<(usize, SnippetFile)>, } impl Draft { fn blank() -> Self { Draft { visibility: SnippetVisibility::Private.as_str().to_owned(), ..Draft::default() } } fn from_snippet(snippet: &Snippet) -> Self { Draft { title: snippet.title.clone(), description: snippet.description.clone(), visibility: snippet.visibility.as_str().to_owned(), files: snippet.files.iter().cloned().enumerate().collect(), } } /// Reads the form. Every card the page rendered is kept, blank ones too, so "Add file" never /// takes a card away; blank cards are dropped only when saving (see `saved_files`). fn from_form(form: &HashMap) -> Self { let field = |key: String| form.get(&key).cloned().unwrap_or_default(); let mut files = Vec::new(); for number in 0..MAX_FILES { if !form.contains_key(&format!("name_{number}")) { continue; } let name = field(format!("name_{number}")).trim().to_owned(); let content = field(format!("content_{number}")).replace("\r\n", "\n"); files.push((number, SnippetFile { name, content })); } Draft { title: field("title".into()), description: field("description".into()), visibility: field("visibility".into()), files, } } /// The files to save: cards with neither a name nor contents are dropped. fn saved_files(&self) -> Vec { self.files .iter() .map(|(_, file)| file) .filter(|file| !(file.name.is_empty() && file.content.trim().is_empty())) .cloned() .collect() } /// Drops the card whose fields are `number`. Other cards keep their numbers. fn remove(&mut self, number: usize) { self.files.retain(|(n, _)| *n != number); } /// The position of the file whose fields are `number`, if there is one. fn position(&self, number: usize) -> Option { self.files.iter().position(|(n, _)| *n == number) } /// Adds an empty card under the lowest free field number, unless the snippet is full. fn add_blank(&mut self) { if self.files.len() >= MAX_FILES { return; } let free = (0..MAX_FILES).find(|n| self.files.iter().all(|(m, _)| m != n)); if let Some(number) = free { let file = SnippetFile { name: String::new(), content: String::new(), }; self.files.push((number, file)); } } /// The cards to render: one per file, or one empty card for a new snippet. fn slots(&self) -> Vec { if self.files.is_empty() { return vec![Slot { number: 0, name: String::new(), content: String::new(), }]; } self.files .iter() .map(|(number, file)| { // A browser drops one newline right after } }) } /// The new and edit form. Save is the first submit button, so Enter in a field saves; "Add file" /// and the remove buttons submit the same form with `formnovalidate` and never save. #[component] async fn snippet_form( lang: Lang, csrf: &str, action: &str, heading: &str, title: &str, description: &str, visibility: &str, slots: Vec, active: usize, error: Option, ) -> Result { let public = visibility == "public"; let unlisted = visibility == "unlisted"; let private = visibility == "private"; let removable = slots.len() > 1; let can_add = slots.len() < MAX_FILES; let counter = g::files_limit( lang, slots.len(), MAX_FILES, MAX_TOTAL_BYTES / (1024 * 1024), ); Ok(view! { if let Some(message) = error { alert(message: message.as_str()) }

(heading)

(g::t(lang, K::Visibility))
snippet_files(lang: lang, slots: slots, active: active, removable: removable, can_add: can_add, counter: counter)
}) } #[component] async fn snippet_body( lang: Lang, snippet_id: &str, title: &str, owner: &str, description: &str, visibility: &'static str, updated: &str, files: Vec, owner_csrf: Option, ) -> Result { let is_owner = owner_csrf.is_some(); let edit_href = format!("/snippets/{snippet_id}/edit"); let history_href = format!("/snippets/{snippet_id}/revisions"); let delete_action = format!("/snippets/{snippet_id}/delete"); Ok(view! {

(title)

(owner) " · " (visibility) " · " (g::updated_on(lang, updated))
if !description.is_empty() {

(description)

} for file in files.into_iter() {
(file.name.clone())
if file.markdown {
(Unescaped::new_unchecked(file.body.clone()))
} else if file.highlighted {
(Unescaped::new_unchecked(file.body.clone()))
} else {
(file.body.clone())
}
} if !is_owner {

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

} }) } #[component] async fn revisions_body( lang: Lang, snippet_id: &str, title: &str, revisions: Vec, ) -> Result { let back = format!("/snippets/{snippet_id}"); let heading = g::revisions_of(lang, title); Ok(view! {

(heading.as_str())

(g::t(lang, K::BackToSnippet))
if revisions.is_empty() {

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

} else {
    for rev in revisions.into_iter() {
  • (g::t(lang, K::RevisionLabel))(rev.number) (rev.author) " · " (rev.at) " · " (rev.file_names)
  • }
}
}) } #[component] async fn snippet_form_page( lang: Lang, viewer: &(User, Session), heading: &str, action: &str, draft: Draft, error: Option, active: usize, ) -> Result { let csrf = viewer.1.csrf_token.clone(); let slots = draft.slots(); let title = draft.title.clone(); let description = draft.description.clone(); let visibility = draft.visibility.clone(); Ok(view! { shell(title: heading, viewer: Some(viewer), snippet_form(lang: lang, csrf: csrf.as_str(), action: action, heading: heading, title: title.as_str(), description: description.as_str(), visibility: visibility.as_str(), slots: slots, active: active, error: error) ) }) } #[page("/snippets")] async fn snippets_list(cx: &Cx) -> Result { let viewer = current_user(cx).await?; let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); let viewer_id = viewer.as_ref().map(|(user, _)| user.id.clone()); let summaries: Vec = app_context::(cx) .store .list_snippets(viewer_id.as_deref()) .await?; let rows: Vec = summaries .into_iter() .map(|s| ListRow { id: s.id, title: s.title, owner: s.owner_name, visibility: visibility_label(lang, s.visibility), meta: g::files_updated(lang, s.file_count, &s.updated_at), }) .collect(); let signed_in = viewer.is_some(); let title = g::t(lang, K::Snippets); Ok(view! { shell(title: title, viewer: viewer.as_ref(), snippet_list_body(lang: lang, rows: rows, signed_in: signed_in) ) }) } #[page("/snippets/new")] async fn snippet_new(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); Ok(view! { snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::NewSnippet), action: "/snippets/new", draft: Draft::blank(), error: None, active: 0) }) } #[page(POST "/snippets/new")] async fn snippet_create(cx: &Cx, Form(form): Form>) -> Result { let (user, _) = require_csrf(cx, form.get("csrf").map_or("", String::as_str)).await?; let lang = i18n::lang(cx, Some(&user)); let mut draft = Draft::from_form(&form); let mut error = None; let step = apply_slot_step(&mut draft, &form); if step.is_none() { let store = &app_context::(cx).store; let files = draft.saved_files(); let saved = match SnippetVisibility::parse(&draft.visibility) { None => Err(g::t(lang, K::ChooseVisibility).to_owned()), Some(visibility) => match store .create_snippet( &user.id, &draft.title, &draft.description, visibility, &files, ) .await { Ok(snippet) => Ok(snippet), Err(e) => match user_facing_message(&e) { Some(message) => Err(message), None => return Err(e.into()), }, }, }; match saved { Ok(snippet) => { audit::record( store, Some(cx), Actor::User(&user), AuditAction::SnippetCreated, Target { kind: "snippet", id: &snippet.id, name: &snippet.title, }, serde_json::Value::Null, ) .await; return Err(see_other(format!("/snippets/{}", snippet.id)).into()); } Err(message) => error = Some(message), } } let active = step.unwrap_or_else(|| posted_tab(&draft, &form)); let viewer = require_user(cx).await?; Ok(view! { snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::NewSnippet), action: "/snippets/new", draft: draft, error: error, active: active) }) } #[page("/snippets/{id}")] async fn snippet_view(cx: &Cx) -> Result { let snippet_id = path_param::(cx).to_owned(); let viewer = current_user(cx).await?; let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); let viewer_id = viewer.as_ref().map(|(user, _)| user.id.clone()); let snippet = readable(cx, viewer_id.as_deref(), &snippet_id).await?; let state = app_context::(cx); let files: Vec = snippet .files .iter() .map(|file| file_view(state, &snippet, file)) .collect(); let owner_csrf = match &viewer { Some((user, session)) if user.id == snippet.owner_id => Some(session.csrf_token.clone()), _ => None, }; let updated = snippet.updated_at.clone(); let visibility = visibility_label(lang, snippet.visibility); Ok(view! { shell(title: snippet.title.as_str(), viewer: viewer.as_ref(), snippet_body(lang: lang, snippet_id: snippet.id.as_str(), title: snippet.title.as_str(), owner: snippet.owner_name.as_str(), description: snippet.description.as_str(), visibility: visibility, updated: updated.as_str(), files: files, owner_csrf: owner_csrf) ) }) } #[page("/snippets/{id}/edit")] async fn snippet_edit(cx: &Cx) -> Result { let snippet_id = path_param::(cx).to_owned(); let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let snippet = owned(cx, &viewer.0, &snippet_id).await?; let action = format!("/snippets/{snippet_id}/edit"); let draft = Draft::from_snippet(&snippet); Ok(view! { snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::EditSnippet), action: action.as_str(), draft: draft, error: None, active: 0) }) } #[page(POST "/snippets/{id}/edit")] async fn snippet_update(cx: &Cx, Form(form): Form>) -> Result { let snippet_id = path_param::(cx).to_owned(); let (user, _) = require_csrf(cx, form.get("csrf").map_or("", String::as_str)).await?; let lang = i18n::lang(cx, Some(&user)); owned(cx, &user, &snippet_id).await?; let mut draft = Draft::from_form(&form); let mut error = None; let step = apply_slot_step(&mut draft, &form); if step.is_none() { let store = &app_context::(cx).store; let files = draft.saved_files(); let saved = match SnippetVisibility::parse(&draft.visibility) { None => Err(g::t(lang, K::ChooseVisibility).to_owned()), Some(visibility) => match store .update_snippet( &user.id, &snippet_id, &draft.title, &draft.description, visibility, &files, ) .await { Ok(snippet) => Ok(snippet), Err(e) => match user_facing_message(&e) { Some(message) => Err(message), None => return Err(e.into()), }, }, }; match saved { Ok(snippet) => { audit::record( store, Some(cx), Actor::User(&user), AuditAction::SnippetUpdated, Target { kind: "snippet", id: &snippet.id, name: &snippet.title, }, serde_json::Value::Null, ) .await; return Err(see_other(format!("/snippets/{}", snippet.id)).into()); } Err(message) => error = Some(message), } } let active = step.unwrap_or_else(|| posted_tab(&draft, &form)); let viewer = require_user(cx).await?; let action = format!("/snippets/{snippet_id}/edit"); Ok(view! { snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::EditSnippet), action: action.as_str(), draft: draft, error: error, active: active) }) } #[page("/snippets/{id}/revisions")] async fn snippet_revisions(cx: &Cx) -> Result { let snippet_id = path_param::(cx).to_owned(); let viewer = current_user(cx).await?; let viewer_id = viewer.as_ref().map(|(user, _)| user.id.clone()); let snippet = readable(cx, viewer_id.as_deref(), &snippet_id).await?; let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); let store = &app_context::(cx).store; let revisions: Vec = store.list_snippet_revisions(&snippet.id).await?; // Stored oldest first; the page shows the newest first with its number. let rows: Vec = revisions .into_iter() .enumerate() .map(|(index, rev)| RevisionView { number: index + 1, author: rev.author_name, at: rev.created_at, file_names: rev .files .iter() .map(|f| f.name.as_str()) .collect::>() .join(", "), }) .rev() .collect(); Ok(view! { shell(title: snippet.title.as_str(), viewer: viewer.as_ref(), revisions_body(lang: lang, snippet_id: snippet.id.as_str(), title: snippet.title.as_str(), revisions: rows) ) }) } #[route(POST "/snippets/{id}/delete")] async fn snippet_delete(cx: &Cx, Form(form): Form>) -> Result { let snippet_id = path_param::(cx).to_owned(); let (user, _) = require_csrf(cx, form.get("csrf").map_or("", String::as_str)).await?; let snippet = owned(cx, &user, &snippet_id).await?; let store = &app_context::(cx).store; if store.delete_snippet(&user.id, &snippet.id).await? { audit::record( store, Some(cx), Actor::User(&user), AuditAction::SnippetDeleted, Target { kind: "snippet", id: &snippet.id, name: &snippet.title, }, serde_json::Value::Null, ) .await; } Ok(see_other("/snippets")) } #[cfg(test)] mod tests { use super::*; fn form(pairs: &[(&str, &str)]) -> HashMap { pairs .iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect() } #[test] fn blank_cards_are_dropped_only_on_save() { let draft = Draft::from_form(&form(&[ ("title", "t"), ("visibility", "public"), ("name_0", "a.txt"), ("content_0", "one\r\ntwo"), ("name_1", ""), ("content_1", " "), ("name_2", "b.rs"), ("content_2", ""), ])); assert_eq!( draft.files.len(), 3, "every rendered card is kept while editing" ); let saved = draft.saved_files(); assert_eq!(saved.len(), 2); assert_eq!(saved[0].content, "one\ntwo"); assert_eq!(saved[1].name, "b.rs"); } #[test] fn new_form_shows_one_empty_card() { let draft = Draft::blank(); let slots = draft.slots(); assert_eq!(slots.len(), 1); assert_eq!((slots[0].number, slots[0].name.as_str()), (0, "")); } #[test] fn add_appends_a_card_until_the_snippet_is_full() { let mut draft = Draft::blank(); for _ in 0..MAX_FILES + 3 { draft.add_blank(); } assert_eq!(draft.slots().len(), MAX_FILES); let numbers: Vec = draft.slots().iter().map(|s| s.number).collect(); assert_eq!(numbers, (0..MAX_FILES).collect::>()); } #[test] fn remove_keeps_the_other_field_numbers() { let mut draft = Draft::blank(); draft.add_blank(); draft.add_blank(); draft.add_blank(); draft.files[1].1.name = "b.txt".into(); draft.remove(0); let numbers: Vec = draft.slots().iter().map(|s| s.number).collect(); assert_eq!(numbers, vec![1, 2]); assert_eq!(draft.slots()[0].name, "b.txt"); } #[test] fn add_and_remove_clicks_are_told_apart_from_a_save() { let add = form(&[("op", "add"), ("name_0", "a")]); let mut draft = Draft::from_form(&add); assert_eq!(apply_slot_step(&mut draft, &add), Some(1)); assert_eq!(draft.slots().len(), 2); let remove = form(&[("remove", "1"), ("name_0", "a"), ("name_1", "")]); let mut draft = Draft::from_form(&remove); assert_eq!(apply_slot_step(&mut draft, &remove), Some(0)); assert_eq!(draft.slots().len(), 1); assert_eq!( apply_slot_step(&mut Draft::blank(), &form(&[("title", "t")])), None ); } #[test] fn add_shows_the_new_last_file() { let mut draft = Draft::blank(); let add = form(&[("op", "add"), ("name_0", "a")]); assert_eq!(apply_slot_step(&mut draft, &add), Some(0)); assert_eq!(apply_slot_step(&mut draft, &add), Some(1)); assert_eq!(draft.slots().len(), 2); } #[test] fn remove_shows_the_file_before_the_removed_one() { let mut draft = Draft::blank(); for _ in 0..3 { draft.add_blank(); } // Files 0..=2 at positions 0..=2; removing the file at position 2 shows position 1. let remove = form(&[("remove", "2")]); assert_eq!(apply_slot_step(&mut draft, &remove), Some(1)); // Removing the first file shows the new first one. let remove = form(&[("remove", "0")]); assert_eq!(apply_slot_step(&mut draft, &remove), Some(0)); } #[test] fn a_failed_save_keeps_the_posted_tab() { let mut draft = Draft::blank(); for _ in 0..3 { draft.add_blank(); } let numbers: Vec = draft.slots().iter().map(|s| s.number).collect(); assert_eq!(numbers, vec![0, 1, 2]); assert_eq!(posted_tab(&draft, &form(&[("tab", "2")])), 2); assert_eq!(posted_tab(&draft, &form(&[("tab", "9")])), 0); assert_eq!(posted_tab(&draft, &form(&[])), 0); } #[test] fn leading_newline_survives_the_textarea() { let mut draft = Draft::blank(); draft.files = vec![( 0, SnippetFile { name: "a".into(), content: "\nx".into(), }, )]; assert_eq!(draft.slots()[0].content, "\n\nx"); } }