forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
35.4 KiB
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
7use std::collections::HashMap;
8
9use forge_core::store::snippets::{
10 MAX_FILES, MAX_TOTAL_BYTES, Snippet, SnippetFile, SnippetRevision, SnippetSummary,
11 SnippetVisibility,
12};
13use forge_core::{AuditAction, Session, User};
14use topcoat::Result;
15use topcoat::context::{Cx, app_context};
16use topcoat::router::content::Form;
17use topcoat::router::error::{SeeOther, not_found, see_other};
18use topcoat::router::{page, path_param, route};
19use topcoat::view::{Unescaped, View, component, view};
20
21use crate::audit::{self, Actor, Target};
22use crate::auth::{current_user, require_csrf, require_user};
23use crate::i18n::community::{self as g, K};
24use crate::i18n::{self, Lang};
25use crate::pages::snippet_raw::snippet_raw_url;
26use crate::render::highlight::highlight_lines;
27use crate::render::markdown::render_markdown;
28use crate::state::AppState;
29use crate::views::{alert, shell, user_facing_message};
30
31path_param!(id);
32
33/// What the form shows for one file slot.
34struct 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)]
42struct 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
52impl 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.
156fn 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.
171fn 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.
180struct 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.
189struct RevisionView {
190 number: usize,
191 author: String,
192 at: String,
193 file_names: String,
194}
195
196/// One row of the list page.
197struct 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
206fn 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
214fn extension(name: &str) -> String {
215 name.rsplit_once('.')
216 .map(|(_, ext)| ext.to_ascii_lowercase())
217 .unwrap_or_default()
218}
219
220fn 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.
240async 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.
249async 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]
258async 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]
292async 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]
361async 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]
431async 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]
488async 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]
523async 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")]
547async 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")]
575async 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")]
585async 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}")]
642async 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")]
671async 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")]
685async 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")]
746async 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")]
780async 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)]
804mod 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}