Tahinli/forge
public0 watching
★ 0
| 1 | //! Pages for snippets: `/snippets` (list), `/snippets/new`, `/snippets/{id}` (view), |
| 2 | //! `/snippets/{id}/edit`, `/snippets/{id}/revisions` and `POST /snippets/{id}/delete`. |
| 3 | //! Visibility decides who may read: a private snippet answers 404 to everyone but its owner. |
| 4 | //! Only the owner edits or deletes, and every save and delete is audited. Files are served on the |
| 5 | //! user-content origin (see `snippet_raw.rs`), and the page links to them. |
| 6 | |
| 7 | use std::collections::HashMap; |
| 8 | |
| 9 | use forge_core::store::snippets::{ |
| 10 | MAX_FILES, MAX_TOTAL_BYTES, Snippet, SnippetFile, SnippetRevision, SnippetSummary, |
| 11 | SnippetVisibility, |
| 12 | }; |
| 13 | use forge_core::{AuditAction, Session, User}; |
| 14 | use topcoat::Result; |
| 15 | use topcoat::context::{Cx, app_context}; |
| 16 | use topcoat::router::content::Form; |
| 17 | use topcoat::router::error::{SeeOther, not_found, see_other}; |
| 18 | use topcoat::router::{page, path_param, route}; |
| 19 | use topcoat::view::{Unescaped, View, component, view}; |
| 20 | |
| 21 | use crate::audit::{self, Actor, Target}; |
| 22 | use crate::auth::{current_user, require_csrf, require_user}; |
| 23 | use crate::i18n::community::{self as g, K}; |
| 24 | use crate::i18n::{self, Lang}; |
| 25 | use crate::pages::snippet_raw::snippet_raw_url; |
| 26 | use crate::render::highlight::highlight_lines; |
| 27 | use crate::render::markdown::render_markdown; |
| 28 | use crate::state::AppState; |
| 29 | use crate::views::{alert, shell, user_facing_message}; |
| 30 | |
| 31 | path_param!(id); |
| 32 | |
| 33 | /// What the form shows for one file slot. |
| 34 | struct Slot { |
| 35 | number: usize, |
| 36 | name: String, |
| 37 | content: String, |
| 38 | } |
| 39 | |
| 40 | /// The editable state of a snippet, as typed or as saved. |
| 41 | #[derive(Clone, Default)] |
| 42 | struct Draft { |
| 43 | title: String, |
| 44 | description: String, |
| 45 | visibility: String, |
| 46 | /// Each file with the number of its form fields (`name_<n>`, `content_<n>`). The number stays |
| 47 | /// with the file when another is added or removed: the browser restores typed text by field |
| 48 | /// name, so a renumbered card would get the wrong text back. |
| 49 | files: Vec<(usize, SnippetFile)>, |
| 50 | } |
| 51 | |
| 52 | impl Draft { |
| 53 | fn blank() -> Self { |
| 54 | Draft { |
| 55 | visibility: SnippetVisibility::Private.as_str().to_owned(), |
| 56 | ..Draft::default() |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | fn from_snippet(snippet: &Snippet) -> Self { |
| 61 | Draft { |
| 62 | title: snippet.title.clone(), |
| 63 | description: snippet.description.clone(), |
| 64 | visibility: snippet.visibility.as_str().to_owned(), |
| 65 | files: snippet.files.iter().cloned().enumerate().collect(), |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// Reads the form. Every card the page rendered is kept, blank ones too, so "Add file" never |
| 70 | /// takes a card away; blank cards are dropped only when saving (see `saved_files`). |
| 71 | fn from_form(form: &HashMap<String, String>) -> Self { |
| 72 | let field = |key: String| form.get(&key).cloned().unwrap_or_default(); |
| 73 | let mut files = Vec::new(); |
| 74 | for number in 0..MAX_FILES { |
| 75 | if !form.contains_key(&format!("name_{number}")) { |
| 76 | continue; |
| 77 | } |
| 78 | let name = field(format!("name_{number}")).trim().to_owned(); |
| 79 | let content = field(format!("content_{number}")).replace("\r\n", "\n"); |
| 80 | files.push((number, SnippetFile { name, content })); |
| 81 | } |
| 82 | Draft { |
| 83 | title: field("title".into()), |
| 84 | description: field("description".into()), |
| 85 | visibility: field("visibility".into()), |
| 86 | files, |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /// The files to save: cards with neither a name nor contents are dropped. |
| 91 | fn saved_files(&self) -> Vec<SnippetFile> { |
| 92 | self.files |
| 93 | .iter() |
| 94 | .map(|(_, file)| file) |
| 95 | .filter(|file| !(file.name.is_empty() && file.content.trim().is_empty())) |
| 96 | .cloned() |
| 97 | .collect() |
| 98 | } |
| 99 | |
| 100 | /// Drops the card whose fields are `number`. Other cards keep their numbers. |
| 101 | fn remove(&mut self, number: usize) { |
| 102 | self.files.retain(|(n, _)| *n != number); |
| 103 | } |
| 104 | |
| 105 | /// The position of the file whose fields are `number`, if there is one. |
| 106 | fn position(&self, number: usize) -> Option<usize> { |
| 107 | self.files.iter().position(|(n, _)| *n == number) |
| 108 | } |
| 109 | |
| 110 | /// Adds an empty card under the lowest free field number, unless the snippet is full. |
| 111 | fn add_blank(&mut self) { |
| 112 | if self.files.len() >= MAX_FILES { |
| 113 | return; |
| 114 | } |
| 115 | let free = (0..MAX_FILES).find(|n| self.files.iter().all(|(m, _)| m != n)); |
| 116 | if let Some(number) = free { |
| 117 | let file = SnippetFile { |
| 118 | name: String::new(), |
| 119 | content: String::new(), |
| 120 | }; |
| 121 | self.files.push((number, file)); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | /// The cards to render: one per file, or one empty card for a new snippet. |
| 126 | fn slots(&self) -> Vec<Slot> { |
| 127 | if self.files.is_empty() { |
| 128 | return vec![Slot { |
| 129 | number: 0, |
| 130 | name: String::new(), |
| 131 | content: String::new(), |
| 132 | }]; |
| 133 | } |
| 134 | self.files |
| 135 | .iter() |
| 136 | .map(|(number, file)| { |
| 137 | // A browser drops one newline right after <textarea>, so keep a leading one. |
| 138 | let content = if file.content.starts_with('\n') { |
| 139 | format!("\n{}", file.content) |
| 140 | } else { |
| 141 | file.content.clone() |
| 142 | }; |
| 143 | Slot { |
| 144 | number: *number, |
| 145 | name: file.name.clone(), |
| 146 | content, |
| 147 | } |
| 148 | }) |
| 149 | .collect() |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | /// Applies an "Add file" or "Remove file" click to the draft. Returns the tab to show next, or |
| 154 | /// None for a save, which leaves the draft as typed. Adding shows the new last file; removing |
| 155 | /// shows the file before the removed one. |
| 156 | fn apply_slot_step(draft: &mut Draft, form: &HashMap<String, String>) -> Option<usize> { |
| 157 | if form.get("op").map(String::as_str) == Some("add") { |
| 158 | draft.add_blank(); |
| 159 | Some(draft.files.len().saturating_sub(1)) |
| 160 | } else if let Some(number) = form.get("remove") { |
| 161 | let number: usize = number.parse().unwrap_or(usize::MAX); |
| 162 | let position = draft.position(number).unwrap_or(0); |
| 163 | draft.remove(number); |
| 164 | Some(position.saturating_sub(1)) |
| 165 | } else { |
| 166 | None |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// The tab that was checked when a save failed, so the user stays on the file they were editing. |
| 171 | fn posted_tab(draft: &Draft, form: &HashMap<String, String>) -> usize { |
| 172 | form.get("tab") |
| 173 | .and_then(|tab| tab.parse().ok()) |
| 174 | .and_then(|number| draft.position(number)) |
| 175 | .unwrap_or(0) |
| 176 | } |
| 177 | |
| 178 | /// One file as the view page shows it. `body` is trusted HTML when `markdown` or `highlighted` |
| 179 | /// is set, and plain text otherwise. |
| 180 | struct FileView { |
| 181 | name: String, |
| 182 | raw: String, |
| 183 | markdown: bool, |
| 184 | highlighted: bool, |
| 185 | body: String, |
| 186 | } |
| 187 | |
| 188 | /// One revision as the history page shows it. |
| 189 | struct RevisionView { |
| 190 | number: usize, |
| 191 | author: String, |
| 192 | at: String, |
| 193 | file_names: String, |
| 194 | } |
| 195 | |
| 196 | /// One row of the list page. |
| 197 | struct ListRow { |
| 198 | id: String, |
| 199 | title: String, |
| 200 | owner: String, |
| 201 | visibility: &'static str, |
| 202 | /// The file count and the last update, as one line. |
| 203 | meta: String, |
| 204 | } |
| 205 | |
| 206 | fn visibility_label(lang: Lang, visibility: SnippetVisibility) -> &'static str { |
| 207 | match visibility { |
| 208 | SnippetVisibility::Public => g::t(lang, K::VisibilityPublic), |
| 209 | SnippetVisibility::Unlisted => g::t(lang, K::VisibilityUnlisted), |
| 210 | SnippetVisibility::Private => g::t(lang, K::VisibilityPrivate), |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | fn extension(name: &str) -> String { |
| 215 | name.rsplit_once('.') |
| 216 | .map(|(_, ext)| ext.to_ascii_lowercase()) |
| 217 | .unwrap_or_default() |
| 218 | } |
| 219 | |
| 220 | fn file_view(state: &AppState, snippet: &Snippet, file: &SnippetFile) -> FileView { |
| 221 | let markdown = matches!(extension(&file.name).as_str(), "md" | "markdown"); |
| 222 | let (body, highlighted) = if markdown { |
| 223 | (render_markdown(&file.content, None), false) |
| 224 | } else { |
| 225 | match highlight_lines(&file.name, &file.content) { |
| 226 | Some(lines) => (lines.join("\n"), true), |
| 227 | None => (file.content.clone(), false), |
| 228 | } |
| 229 | }; |
| 230 | FileView { |
| 231 | name: file.name.clone(), |
| 232 | raw: snippet_raw_url(state, snippet, &file.name), |
| 233 | markdown, |
| 234 | highlighted, |
| 235 | body, |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /// The snippet if the viewer may read it, else 404. |
| 240 | async fn readable(cx: &Cx, viewer_id: Option<&str>, snippet_id: &str) -> Result<Snippet> { |
| 241 | let store = &app_context::<AppState>(cx).store; |
| 242 | match store.get_snippet(snippet_id).await? { |
| 243 | Some(snippet) if snippet.is_visible_to(viewer_id) => Ok(snippet), |
| 244 | _ => Err(not_found().into()), |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | /// The snippet if the user owns it, else 404. Edits and deletes go through this check. |
| 249 | async fn owned(cx: &Cx, user: &User, snippet_id: &str) -> Result<Snippet> { |
| 250 | let store = &app_context::<AppState>(cx).store; |
| 251 | match store.get_snippet(snippet_id).await? { |
| 252 | Some(snippet) if snippet.owner_id == user.id => Ok(snippet), |
| 253 | _ => Err(not_found().into()), |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | #[component] |
| 258 | async fn snippet_list_body(lang: Lang, rows: Vec<ListRow>, signed_in: bool) -> Result<impl View> { |
| 259 | Ok(view! { |
| 260 | <section class="page-head"> |
| 261 | <h1>(g::t(lang, K::Snippets))</h1> |
| 262 | if signed_in { |
| 263 | <a class="button primary" href="/snippets/new">(g::t(lang, K::NewSnippet))</a> |
| 264 | } |
| 265 | </section> |
| 266 | <section class="card"> |
| 267 | if rows.is_empty() { |
| 268 | <p class="muted">(g::t(lang, K::NoSnippets))</p> |
| 269 | } else { |
| 270 | <ul class="item-list snippet-list"> |
| 271 | for row in rows.into_iter() { |
| 272 | <li class="item"> |
| 273 | <div class="item-main"> |
| 274 | <a href=(format!("/snippets/{}", row.id))><strong>(row.title)</strong></a> |
| 275 | <span class="muted small"> |
| 276 | (row.owner) " · " (row.visibility) " · " (row.meta) |
| 277 | </span> |
| 278 | </div> |
| 279 | </li> |
| 280 | } |
| 281 | </ul> |
| 282 | } |
| 283 | </section> |
| 284 | }) |
| 285 | } |
| 286 | |
| 287 | /// The files card: a tab strip with one tab per file and the "Add file" tab at its end, then one |
| 288 | /// panel per file. Every panel stays in the form so all fields submit; CSS shows only the panel |
| 289 | /// of the checked tab. A tab is a radio plus a label, so the strip works with JavaScript off. |
| 290 | /// The panel's remove button is shown only when there is more than one file. |
| 291 | #[component] |
| 292 | async fn snippet_files( |
| 293 | lang: Lang, |
| 294 | slots: Vec<Slot>, |
| 295 | active: usize, |
| 296 | removable: bool, |
| 297 | can_add: bool, |
| 298 | counter: String, |
| 299 | ) -> Result<impl View> { |
| 300 | let blanks = slots.iter().filter(|s| s.name.trim().is_empty()).count(); |
| 301 | let tabs: Vec<(usize, String, bool)> = slots |
| 302 | .iter() |
| 303 | .enumerate() |
| 304 | .map(|(index, slot)| { |
| 305 | let label = if slot.name.trim().is_empty() { |
| 306 | let untitled = g::t(lang, K::Untitled); |
| 307 | if blanks > 1 { |
| 308 | format!("{untitled} {}", index + 1) |
| 309 | } else { |
| 310 | untitled.to_owned() |
| 311 | } |
| 312 | } else { |
| 313 | slot.name.clone() |
| 314 | }; |
| 315 | (slot.number, label, index == active) |
| 316 | }) |
| 317 | .collect(); |
| 318 | Ok(view! { |
| 319 | <div class="snippet-files"> |
| 320 | <div class="snippet-tabbar"> |
| 321 | <div class="snippet-tabs" role="group" aria-label=(g::t(lang, K::Files))> |
| 322 | for (number, label, checked) in tabs.into_iter() { |
| 323 | <input type="radio" class="snippet-tab-radio" id=(format!("snippet-tab-{number}")) |
| 324 | name="tab" value=(number.to_string()) checked=(checked)> |
| 325 | <label class="snippet-tab" for=(format!("snippet-tab-{number}"))>(label)</label> |
| 326 | } |
| 327 | if can_add { |
| 328 | <button type="submit" class="ghost snippet-tab-add" name="op" value="add" formnovalidate=(true) |
| 329 | title=(g::t(lang, K::AddFile)) aria-label=(g::t(lang, K::AddFile))> |
| 330 | <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg> |
| 331 | </button> |
| 332 | } |
| 333 | </div> |
| 334 | <span class="snippet-count muted small">(counter)</span> |
| 335 | </div> |
| 336 | for (index, slot) in slots.into_iter().enumerate() { |
| 337 | <fieldset class=(format!("snippet-panel snippet-panel-{}", slot.number))> |
| 338 | <legend class="sr-only">(g::t(lang, K::FileLabel))(index + 1)</legend> |
| 339 | <div class="snippet-slot-head"> |
| 340 | <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></svg> |
| 341 | <input type="text" class="snippet-name" name=(format!("name_{}", slot.number)) value=(slot.name) |
| 342 | maxlength="100" placeholder=(g::t(lang, K::FileNamePlaceholder)) aria-label=(g::t(lang, K::FileName))> |
| 343 | if removable { |
| 344 | <button type="submit" class="ghost snippet-remove" name="remove" value=(slot.number.to_string()) |
| 345 | formnovalidate=(true) title=(g::t(lang, K::RemoveFile)) aria-label=(g::t(lang, K::RemoveFile))> |
| 346 | <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg> |
| 347 | </button> |
| 348 | } |
| 349 | </div> |
| 350 | <textarea class="code snippet-content" name=(format!("content_{}", slot.number)) |
| 351 | spellcheck="false" aria-label=(g::t(lang, K::FileContents))>(slot.content)</textarea> |
| 352 | </fieldset> |
| 353 | } |
| 354 | </div> |
| 355 | }) |
| 356 | } |
| 357 | |
| 358 | /// The new and edit form. Save is the first submit button, so Enter in a field saves; "Add file" |
| 359 | /// and the remove buttons submit the same form with `formnovalidate` and never save. |
| 360 | #[component] |
| 361 | async fn snippet_form( |
| 362 | lang: Lang, |
| 363 | csrf: &str, |
| 364 | action: &str, |
| 365 | heading: &str, |
| 366 | title: &str, |
| 367 | description: &str, |
| 368 | visibility: &str, |
| 369 | slots: Vec<Slot>, |
| 370 | active: usize, |
| 371 | error: Option<String>, |
| 372 | ) -> Result<impl View> { |
| 373 | let public = visibility == "public"; |
| 374 | let unlisted = visibility == "unlisted"; |
| 375 | let private = visibility == "private"; |
| 376 | let removable = slots.len() > 1; |
| 377 | let can_add = slots.len() < MAX_FILES; |
| 378 | let counter = g::files_limit( |
| 379 | lang, |
| 380 | slots.len(), |
| 381 | MAX_FILES, |
| 382 | MAX_TOTAL_BYTES / (1024 * 1024), |
| 383 | ); |
| 384 | Ok(view! { |
| 385 | if let Some(message) = error { |
| 386 | alert(message: message.as_str()) |
| 387 | } |
| 388 | <form method="post" action=(action) class="snippet-form"> |
| 389 | <input type="hidden" name="csrf" value=(csrf)> |
| 390 | <header class="snippet-head"> |
| 391 | <h1>(heading)</h1> |
| 392 | <div class="snippet-head-actions"> |
| 393 | <fieldset class="snippet-visibility"> |
| 394 | <legend class="sr-only">(g::t(lang, K::Visibility))</legend> |
| 395 | <div class="segmented snippet-segmented"> |
| 396 | <label class="segment" title=(g::t(lang, K::PublicOption))> |
| 397 | <input type="radio" name="visibility" value="public" checked=(public)> |
| 398 | <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg> |
| 399 | <span>(g::t(lang, K::VisibilityPublic))</span> |
| 400 | </label> |
| 401 | <label class="segment" title=(g::t(lang, K::UnlistedOption))> |
| 402 | <input type="radio" name="visibility" value="unlisted" checked=(unlisted)> |
| 403 | <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg> |
| 404 | <span>(g::t(lang, K::VisibilityUnlisted))</span> |
| 405 | </label> |
| 406 | <label class="segment" title=(g::t(lang, K::PrivateOption))> |
| 407 | <input type="radio" name="visibility" value="private" checked=(private)> |
| 408 | <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg> |
| 409 | <span>(g::t(lang, K::VisibilityPrivate))</span> |
| 410 | </label> |
| 411 | </div> |
| 412 | </fieldset> |
| 413 | <button type="submit" class="primary">(g::t(lang, K::SaveSnippet))</button> |
| 414 | </div> |
| 415 | </header> |
| 416 | <div class="snippet-doc"> |
| 417 | <div class="snippet-meta"> |
| 418 | <input type="text" class="snippet-title" name="title" value=(title) maxlength="120" required=(true) |
| 419 | placeholder=(g::t(lang, K::SnippetTitlePlaceholder)) aria-label=(g::t(lang, K::Title))> |
| 420 | <input type="text" class="snippet-desc" name="description" value=(description) maxlength="1000" |
| 421 | placeholder=(g::t(lang, K::DescriptionOptional)) aria-label=(g::t(lang, K::Description))> |
| 422 | </div> |
| 423 | snippet_files(lang: lang, slots: slots, active: active, removable: removable, can_add: can_add, |
| 424 | counter: counter) |
| 425 | </div> |
| 426 | </form> |
| 427 | }) |
| 428 | } |
| 429 | |
| 430 | #[component] |
| 431 | async fn snippet_body( |
| 432 | lang: Lang, |
| 433 | snippet_id: &str, |
| 434 | title: &str, |
| 435 | owner: &str, |
| 436 | description: &str, |
| 437 | visibility: &'static str, |
| 438 | updated: &str, |
| 439 | files: Vec<FileView>, |
| 440 | owner_csrf: Option<String>, |
| 441 | ) -> Result<impl View> { |
| 442 | let is_owner = owner_csrf.is_some(); |
| 443 | let edit_href = format!("/snippets/{snippet_id}/edit"); |
| 444 | let history_href = format!("/snippets/{snippet_id}/revisions"); |
| 445 | let delete_action = format!("/snippets/{snippet_id}/delete"); |
| 446 | Ok(view! { |
| 447 | <section class="page-head"> |
| 448 | <h1>(title)</h1> |
| 449 | <span class="muted small">(owner) " · " (visibility) " · " (g::updated_on(lang, updated))</span> |
| 450 | </section> |
| 451 | if !description.is_empty() { |
| 452 | <p class="snippet-description">(description)</p> |
| 453 | } |
| 454 | <nav class="file-actions" aria-label=(g::t(lang, K::SnippetNav))> |
| 455 | <a href=(history_href)>(g::t(lang, K::Revisions))</a> |
| 456 | if let Some(token) = owner_csrf { |
| 457 | <a href=(edit_href)>(g::t(lang, K::Edit))</a> |
| 458 | <form method="post" action=(delete_action) class="inline-form"> |
| 459 | <input type="hidden" name="csrf" value=(token.as_str())> |
| 460 | <button type="submit" class="danger">(g::t(lang, K::Delete))</button> |
| 461 | </form> |
| 462 | } |
| 463 | </nav> |
| 464 | for file in files.into_iter() { |
| 465 | <section class="file snippet-file"> |
| 466 | <header class="file-head"> |
| 467 | <strong>(file.name.clone())</strong> |
| 468 | <nav class="file-actions" aria-label=(g::t(lang, K::FileNav))> |
| 469 | <a href=(file.raw.clone())>(g::t(lang, K::Raw))</a> |
| 470 | </nav> |
| 471 | </header> |
| 472 | if file.markdown { |
| 473 | <div class="markdown-body">(Unescaped::new_unchecked(file.body.clone()))</div> |
| 474 | } else if file.highlighted { |
| 475 | <pre class="code snippet-code"><code>(Unescaped::new_unchecked(file.body.clone()))</code></pre> |
| 476 | } else { |
| 477 | <pre class="code snippet-code"><code>(file.body.clone())</code></pre> |
| 478 | } |
| 479 | </section> |
| 480 | } |
| 481 | if !is_owner { |
| 482 | <p class="muted small">(g::t(lang, K::OnlyOwnerEdits))</p> |
| 483 | } |
| 484 | }) |
| 485 | } |
| 486 | |
| 487 | #[component] |
| 488 | async fn revisions_body( |
| 489 | lang: Lang, |
| 490 | snippet_id: &str, |
| 491 | title: &str, |
| 492 | revisions: Vec<RevisionView>, |
| 493 | ) -> Result<impl View> { |
| 494 | let back = format!("/snippets/{snippet_id}"); |
| 495 | let heading = g::revisions_of(lang, title); |
| 496 | Ok(view! { |
| 497 | <section class="page-head"> |
| 498 | <h1>(heading.as_str())</h1> |
| 499 | <a href=(back)>(g::t(lang, K::BackToSnippet))</a> |
| 500 | </section> |
| 501 | <section class="card"> |
| 502 | if revisions.is_empty() { |
| 503 | <p class="muted">(g::t(lang, K::NoRevisions))</p> |
| 504 | } else { |
| 505 | <ul class="item-list"> |
| 506 | for rev in revisions.into_iter() { |
| 507 | <li class="item"> |
| 508 | <div class="item-main"> |
| 509 | <strong>(g::t(lang, K::RevisionLabel))(rev.number)</strong> |
| 510 | <span class="muted small"> |
| 511 | (rev.author) " · " (rev.at) " · " (rev.file_names) |
| 512 | </span> |
| 513 | </div> |
| 514 | </li> |
| 515 | } |
| 516 | </ul> |
| 517 | } |
| 518 | </section> |
| 519 | }) |
| 520 | } |
| 521 | |
| 522 | #[component] |
| 523 | async fn snippet_form_page( |
| 524 | lang: Lang, |
| 525 | viewer: &(User, Session), |
| 526 | heading: &str, |
| 527 | action: &str, |
| 528 | draft: Draft, |
| 529 | error: Option<String>, |
| 530 | active: usize, |
| 531 | ) -> Result<impl View> { |
| 532 | let csrf = viewer.1.csrf_token.clone(); |
| 533 | let slots = draft.slots(); |
| 534 | let title = draft.title.clone(); |
| 535 | let description = draft.description.clone(); |
| 536 | let visibility = draft.visibility.clone(); |
| 537 | Ok(view! { |
| 538 | shell(title: heading, viewer: Some(viewer), |
| 539 | snippet_form(lang: lang, csrf: csrf.as_str(), action: action, heading: heading, |
| 540 | title: title.as_str(), description: description.as_str(), |
| 541 | visibility: visibility.as_str(), slots: slots, active: active, error: error) |
| 542 | ) |
| 543 | }) |
| 544 | } |
| 545 | |
| 546 | #[page("/snippets")] |
| 547 | async fn snippets_list(cx: &Cx) -> Result<impl View> { |
| 548 | let viewer = current_user(cx).await?; |
| 549 | let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); |
| 550 | let viewer_id = viewer.as_ref().map(|(user, _)| user.id.clone()); |
| 551 | let summaries: Vec<SnippetSummary> = app_context::<AppState>(cx) |
| 552 | .store |
| 553 | .list_snippets(viewer_id.as_deref()) |
| 554 | .await?; |
| 555 | let rows: Vec<ListRow> = summaries |
| 556 | .into_iter() |
| 557 | .map(|s| ListRow { |
| 558 | id: s.id, |
| 559 | title: s.title, |
| 560 | owner: s.owner_name, |
| 561 | visibility: visibility_label(lang, s.visibility), |
| 562 | meta: g::files_updated(lang, s.file_count, &s.updated_at), |
| 563 | }) |
| 564 | .collect(); |
| 565 | let signed_in = viewer.is_some(); |
| 566 | let title = g::t(lang, K::Snippets); |
| 567 | Ok(view! { |
| 568 | shell(title: title, viewer: viewer.as_ref(), |
| 569 | snippet_list_body(lang: lang, rows: rows, signed_in: signed_in) |
| 570 | ) |
| 571 | }) |
| 572 | } |
| 573 | |
| 574 | #[page("/snippets/new")] |
| 575 | async fn snippet_new(cx: &Cx) -> Result<impl View> { |
| 576 | let viewer = require_user(cx).await?; |
| 577 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 578 | Ok(view! { |
| 579 | snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::NewSnippet), action: "/snippets/new", |
| 580 | draft: Draft::blank(), error: None, active: 0) |
| 581 | }) |
| 582 | } |
| 583 | |
| 584 | #[page(POST "/snippets/new")] |
| 585 | async fn snippet_create(cx: &Cx, Form(form): Form<HashMap<String, String>>) -> Result<impl View> { |
| 586 | let (user, _) = require_csrf(cx, form.get("csrf").map_or("", String::as_str)).await?; |
| 587 | let lang = i18n::lang(cx, Some(&user)); |
| 588 | let mut draft = Draft::from_form(&form); |
| 589 | let mut error = None; |
| 590 | let step = apply_slot_step(&mut draft, &form); |
| 591 | if step.is_none() { |
| 592 | let store = &app_context::<AppState>(cx).store; |
| 593 | let files = draft.saved_files(); |
| 594 | let saved = match SnippetVisibility::parse(&draft.visibility) { |
| 595 | None => Err(g::t(lang, K::ChooseVisibility).to_owned()), |
| 596 | Some(visibility) => match store |
| 597 | .create_snippet( |
| 598 | &user.id, |
| 599 | &draft.title, |
| 600 | &draft.description, |
| 601 | visibility, |
| 602 | &files, |
| 603 | ) |
| 604 | .await |
| 605 | { |
| 606 | Ok(snippet) => Ok(snippet), |
| 607 | Err(e) => match user_facing_message(&e) { |
| 608 | Some(message) => Err(message), |
| 609 | None => return Err(e.into()), |
| 610 | }, |
| 611 | }, |
| 612 | }; |
| 613 | match saved { |
| 614 | Ok(snippet) => { |
| 615 | audit::record( |
| 616 | store, |
| 617 | Some(cx), |
| 618 | Actor::User(&user), |
| 619 | AuditAction::SnippetCreated, |
| 620 | Target { |
| 621 | kind: "snippet", |
| 622 | id: &snippet.id, |
| 623 | name: &snippet.title, |
| 624 | }, |
| 625 | serde_json::Value::Null, |
| 626 | ) |
| 627 | .await; |
| 628 | return Err(see_other(format!("/snippets/{}", snippet.id)).into()); |
| 629 | } |
| 630 | Err(message) => error = Some(message), |
| 631 | } |
| 632 | } |
| 633 | let active = step.unwrap_or_else(|| posted_tab(&draft, &form)); |
| 634 | let viewer = require_user(cx).await?; |
| 635 | Ok(view! { |
| 636 | snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::NewSnippet), action: "/snippets/new", |
| 637 | draft: draft, error: error, active: active) |
| 638 | }) |
| 639 | } |
| 640 | |
| 641 | #[page("/snippets/{id}")] |
| 642 | async fn snippet_view(cx: &Cx) -> Result<impl View> { |
| 643 | let snippet_id = path_param::<Id>(cx).to_owned(); |
| 644 | let viewer = current_user(cx).await?; |
| 645 | let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); |
| 646 | let viewer_id = viewer.as_ref().map(|(user, _)| user.id.clone()); |
| 647 | let snippet = readable(cx, viewer_id.as_deref(), &snippet_id).await?; |
| 648 | let state = app_context::<AppState>(cx); |
| 649 | let files: Vec<FileView> = snippet |
| 650 | .files |
| 651 | .iter() |
| 652 | .map(|file| file_view(state, &snippet, file)) |
| 653 | .collect(); |
| 654 | let owner_csrf = match &viewer { |
| 655 | Some((user, session)) if user.id == snippet.owner_id => Some(session.csrf_token.clone()), |
| 656 | _ => None, |
| 657 | }; |
| 658 | let updated = snippet.updated_at.clone(); |
| 659 | let visibility = visibility_label(lang, snippet.visibility); |
| 660 | Ok(view! { |
| 661 | shell(title: snippet.title.as_str(), viewer: viewer.as_ref(), |
| 662 | snippet_body(lang: lang, snippet_id: snippet.id.as_str(), title: snippet.title.as_str(), |
| 663 | owner: snippet.owner_name.as_str(), description: snippet.description.as_str(), |
| 664 | visibility: visibility, updated: updated.as_str(), files: files, |
| 665 | owner_csrf: owner_csrf) |
| 666 | ) |
| 667 | }) |
| 668 | } |
| 669 | |
| 670 | #[page("/snippets/{id}/edit")] |
| 671 | async fn snippet_edit(cx: &Cx) -> Result<impl View> { |
| 672 | let snippet_id = path_param::<Id>(cx).to_owned(); |
| 673 | let viewer = require_user(cx).await?; |
| 674 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 675 | let snippet = owned(cx, &viewer.0, &snippet_id).await?; |
| 676 | let action = format!("/snippets/{snippet_id}/edit"); |
| 677 | let draft = Draft::from_snippet(&snippet); |
| 678 | Ok(view! { |
| 679 | snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::EditSnippet), action: action.as_str(), |
| 680 | draft: draft, error: None, active: 0) |
| 681 | }) |
| 682 | } |
| 683 | |
| 684 | #[page(POST "/snippets/{id}/edit")] |
| 685 | async fn snippet_update(cx: &Cx, Form(form): Form<HashMap<String, String>>) -> Result<impl View> { |
| 686 | let snippet_id = path_param::<Id>(cx).to_owned(); |
| 687 | let (user, _) = require_csrf(cx, form.get("csrf").map_or("", String::as_str)).await?; |
| 688 | let lang = i18n::lang(cx, Some(&user)); |
| 689 | owned(cx, &user, &snippet_id).await?; |
| 690 | let mut draft = Draft::from_form(&form); |
| 691 | let mut error = None; |
| 692 | let step = apply_slot_step(&mut draft, &form); |
| 693 | if step.is_none() { |
| 694 | let store = &app_context::<AppState>(cx).store; |
| 695 | let files = draft.saved_files(); |
| 696 | let saved = match SnippetVisibility::parse(&draft.visibility) { |
| 697 | None => Err(g::t(lang, K::ChooseVisibility).to_owned()), |
| 698 | Some(visibility) => match store |
| 699 | .update_snippet( |
| 700 | &user.id, |
| 701 | &snippet_id, |
| 702 | &draft.title, |
| 703 | &draft.description, |
| 704 | visibility, |
| 705 | &files, |
| 706 | ) |
| 707 | .await |
| 708 | { |
| 709 | Ok(snippet) => Ok(snippet), |
| 710 | Err(e) => match user_facing_message(&e) { |
| 711 | Some(message) => Err(message), |
| 712 | None => return Err(e.into()), |
| 713 | }, |
| 714 | }, |
| 715 | }; |
| 716 | match saved { |
| 717 | Ok(snippet) => { |
| 718 | audit::record( |
| 719 | store, |
| 720 | Some(cx), |
| 721 | Actor::User(&user), |
| 722 | AuditAction::SnippetUpdated, |
| 723 | Target { |
| 724 | kind: "snippet", |
| 725 | id: &snippet.id, |
| 726 | name: &snippet.title, |
| 727 | }, |
| 728 | serde_json::Value::Null, |
| 729 | ) |
| 730 | .await; |
| 731 | return Err(see_other(format!("/snippets/{}", snippet.id)).into()); |
| 732 | } |
| 733 | Err(message) => error = Some(message), |
| 734 | } |
| 735 | } |
| 736 | let active = step.unwrap_or_else(|| posted_tab(&draft, &form)); |
| 737 | let viewer = require_user(cx).await?; |
| 738 | let action = format!("/snippets/{snippet_id}/edit"); |
| 739 | Ok(view! { |
| 740 | snippet_form_page(lang: lang, viewer: &viewer, heading: g::t(lang, K::EditSnippet), action: action.as_str(), |
| 741 | draft: draft, error: error, active: active) |
| 742 | }) |
| 743 | } |
| 744 | |
| 745 | #[page("/snippets/{id}/revisions")] |
| 746 | async fn snippet_revisions(cx: &Cx) -> Result<impl View> { |
| 747 | let snippet_id = path_param::<Id>(cx).to_owned(); |
| 748 | let viewer = current_user(cx).await?; |
| 749 | let viewer_id = viewer.as_ref().map(|(user, _)| user.id.clone()); |
| 750 | let snippet = readable(cx, viewer_id.as_deref(), &snippet_id).await?; |
| 751 | let lang = i18n::lang(cx, viewer.as_ref().map(|(user, _)| user)); |
| 752 | let store = &app_context::<AppState>(cx).store; |
| 753 | let revisions: Vec<SnippetRevision> = store.list_snippet_revisions(&snippet.id).await?; |
| 754 | // Stored oldest first; the page shows the newest first with its number. |
| 755 | let rows: Vec<RevisionView> = revisions |
| 756 | .into_iter() |
| 757 | .enumerate() |
| 758 | .map(|(index, rev)| RevisionView { |
| 759 | number: index + 1, |
| 760 | author: rev.author_name, |
| 761 | at: rev.created_at, |
| 762 | file_names: rev |
| 763 | .files |
| 764 | .iter() |
| 765 | .map(|f| f.name.as_str()) |
| 766 | .collect::<Vec<_>>() |
| 767 | .join(", "), |
| 768 | }) |
| 769 | .rev() |
| 770 | .collect(); |
| 771 | Ok(view! { |
| 772 | shell(title: snippet.title.as_str(), viewer: viewer.as_ref(), |
| 773 | revisions_body(lang: lang, snippet_id: snippet.id.as_str(), title: snippet.title.as_str(), |
| 774 | revisions: rows) |
| 775 | ) |
| 776 | }) |
| 777 | } |
| 778 | |
| 779 | #[route(POST "/snippets/{id}/delete")] |
| 780 | async fn snippet_delete(cx: &Cx, Form(form): Form<HashMap<String, String>>) -> Result<SeeOther> { |
| 781 | let snippet_id = path_param::<Id>(cx).to_owned(); |
| 782 | let (user, _) = require_csrf(cx, form.get("csrf").map_or("", String::as_str)).await?; |
| 783 | let snippet = owned(cx, &user, &snippet_id).await?; |
| 784 | let store = &app_context::<AppState>(cx).store; |
| 785 | if store.delete_snippet(&user.id, &snippet.id).await? { |
| 786 | audit::record( |
| 787 | store, |
| 788 | Some(cx), |
| 789 | Actor::User(&user), |
| 790 | AuditAction::SnippetDeleted, |
| 791 | Target { |
| 792 | kind: "snippet", |
| 793 | id: &snippet.id, |
| 794 | name: &snippet.title, |
| 795 | }, |
| 796 | serde_json::Value::Null, |
| 797 | ) |
| 798 | .await; |
| 799 | } |
| 800 | Ok(see_other("/snippets")) |
| 801 | } |
| 802 | |
| 803 | #[cfg(test)] |
| 804 | mod tests { |
| 805 | use super::*; |
| 806 | |
| 807 | fn form(pairs: &[(&str, &str)]) -> HashMap<String, String> { |
| 808 | pairs |
| 809 | .iter() |
| 810 | .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) |
| 811 | .collect() |
| 812 | } |
| 813 | |
| 814 | #[test] |
| 815 | fn blank_cards_are_dropped_only_on_save() { |
| 816 | let draft = Draft::from_form(&form(&[ |
| 817 | ("title", "t"), |
| 818 | ("visibility", "public"), |
| 819 | ("name_0", "a.txt"), |
| 820 | ("content_0", "one\r\ntwo"), |
| 821 | ("name_1", ""), |
| 822 | ("content_1", " "), |
| 823 | ("name_2", "b.rs"), |
| 824 | ("content_2", ""), |
| 825 | ])); |
| 826 | assert_eq!( |
| 827 | draft.files.len(), |
| 828 | 3, |
| 829 | "every rendered card is kept while editing" |
| 830 | ); |
| 831 | let saved = draft.saved_files(); |
| 832 | assert_eq!(saved.len(), 2); |
| 833 | assert_eq!(saved[0].content, "one\ntwo"); |
| 834 | assert_eq!(saved[1].name, "b.rs"); |
| 835 | } |
| 836 | |
| 837 | #[test] |
| 838 | fn new_form_shows_one_empty_card() { |
| 839 | let draft = Draft::blank(); |
| 840 | let slots = draft.slots(); |
| 841 | assert_eq!(slots.len(), 1); |
| 842 | assert_eq!((slots[0].number, slots[0].name.as_str()), (0, "")); |
| 843 | } |
| 844 | |
| 845 | #[test] |
| 846 | fn add_appends_a_card_until_the_snippet_is_full() { |
| 847 | let mut draft = Draft::blank(); |
| 848 | for _ in 0..MAX_FILES + 3 { |
| 849 | draft.add_blank(); |
| 850 | } |
| 851 | assert_eq!(draft.slots().len(), MAX_FILES); |
| 852 | let numbers: Vec<usize> = draft.slots().iter().map(|s| s.number).collect(); |
| 853 | assert_eq!(numbers, (0..MAX_FILES).collect::<Vec<_>>()); |
| 854 | } |
| 855 | |
| 856 | #[test] |
| 857 | fn remove_keeps_the_other_field_numbers() { |
| 858 | let mut draft = Draft::blank(); |
| 859 | draft.add_blank(); |
| 860 | draft.add_blank(); |
| 861 | draft.add_blank(); |
| 862 | draft.files[1].1.name = "b.txt".into(); |
| 863 | draft.remove(0); |
| 864 | let numbers: Vec<usize> = draft.slots().iter().map(|s| s.number).collect(); |
| 865 | assert_eq!(numbers, vec![1, 2]); |
| 866 | assert_eq!(draft.slots()[0].name, "b.txt"); |
| 867 | } |
| 868 | |
| 869 | #[test] |
| 870 | fn add_and_remove_clicks_are_told_apart_from_a_save() { |
| 871 | let add = form(&[("op", "add"), ("name_0", "a")]); |
| 872 | let mut draft = Draft::from_form(&add); |
| 873 | assert_eq!(apply_slot_step(&mut draft, &add), Some(1)); |
| 874 | assert_eq!(draft.slots().len(), 2); |
| 875 | let remove = form(&[("remove", "1"), ("name_0", "a"), ("name_1", "")]); |
| 876 | let mut draft = Draft::from_form(&remove); |
| 877 | assert_eq!(apply_slot_step(&mut draft, &remove), Some(0)); |
| 878 | assert_eq!(draft.slots().len(), 1); |
| 879 | assert_eq!( |
| 880 | apply_slot_step(&mut Draft::blank(), &form(&[("title", "t")])), |
| 881 | None |
| 882 | ); |
| 883 | } |
| 884 | |
| 885 | #[test] |
| 886 | fn add_shows_the_new_last_file() { |
| 887 | let mut draft = Draft::blank(); |
| 888 | let add = form(&[("op", "add"), ("name_0", "a")]); |
| 889 | assert_eq!(apply_slot_step(&mut draft, &add), Some(0)); |
| 890 | assert_eq!(apply_slot_step(&mut draft, &add), Some(1)); |
| 891 | assert_eq!(draft.slots().len(), 2); |
| 892 | } |
| 893 | |
| 894 | #[test] |
| 895 | fn remove_shows_the_file_before_the_removed_one() { |
| 896 | let mut draft = Draft::blank(); |
| 897 | for _ in 0..3 { |
| 898 | draft.add_blank(); |
| 899 | } |
| 900 | // Files 0..=2 at positions 0..=2; removing the file at position 2 shows position 1. |
| 901 | let remove = form(&[("remove", "2")]); |
| 902 | assert_eq!(apply_slot_step(&mut draft, &remove), Some(1)); |
| 903 | // Removing the first file shows the new first one. |
| 904 | let remove = form(&[("remove", "0")]); |
| 905 | assert_eq!(apply_slot_step(&mut draft, &remove), Some(0)); |
| 906 | } |
| 907 | |
| 908 | #[test] |
| 909 | fn a_failed_save_keeps_the_posted_tab() { |
| 910 | let mut draft = Draft::blank(); |
| 911 | for _ in 0..3 { |
| 912 | draft.add_blank(); |
| 913 | } |
| 914 | let numbers: Vec<usize> = draft.slots().iter().map(|s| s.number).collect(); |
| 915 | assert_eq!(numbers, vec![0, 1, 2]); |
| 916 | assert_eq!(posted_tab(&draft, &form(&[("tab", "2")])), 2); |
| 917 | assert_eq!(posted_tab(&draft, &form(&[("tab", "9")])), 0); |
| 918 | assert_eq!(posted_tab(&draft, &form(&[])), 0); |
| 919 | } |
| 920 | |
| 921 | #[test] |
| 922 | fn leading_newline_survives_the_textarea() { |
| 923 | let mut draft = Draft::blank(); |
| 924 | draft.files = vec![( |
| 925 | 0, |
| 926 | SnippetFile { |
| 927 | name: "a".into(), |
| 928 | content: "\nx".into(), |
| 929 | }, |
| 930 | )]; |
| 931 | assert_eq!(draft.slots()[0].content, "\n\nx"); |
| 932 | } |
| 933 | } |