Skip to main content

max / makenotwork

Surface fragment render errors and de-fragilize two UX sinks (ultra-fuzz Run 12 UX) - Fragment renders no longer swallow to a blank string. Six HTMX handlers did `.render().unwrap_or_default()`, installing an empty fragment on failure with no HX-Error — worst on the ssh-keys add/remove paths, which fired a "success" toast over a vanished list. Add helpers::render_fragment (logs + AppError, so the error responder returns 500 + HX-Error) and route git_tokens, ssh_keys (x3), and content_insertions (x2) through it. (The broader render_string helper has 49 call sites; migrating them to render_fragment is a larger follow-up.) - user_media.html no longer interpolates a user filename into an onclick JS string literal; it uses data-filename + this.dataset.filename, matching the sibling insertion_list pattern, so safety no longer depends on a distal sanitizer. - join_wizard's account step back-nav now threads a real CSRF token instead of None, so the rendered form can actually submit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 14:44 UTC
Commit: eedac53b4cf4b7e6544cabc114b30e79af575588
Parent: c5a54ba
6 files changed, +33 insertions, -33 deletions
@@ -49,6 +49,18 @@ pub fn is_unique_violation(e: &crate::error::AppError) -> bool {
49 49 )
50 50 }
51 51
52 + /// Render an Askama fragment to an HTML string, surfacing a render failure as an
53 + /// `AppError` (mapped to 500 + an `HX-Error` header by the error responder) instead
54 + /// of the silent `.render().unwrap_or_default()` blank swap — which installs an
55 + /// empty HTMX fragment with no error signal (ultra-fuzz Run 12 doubledown S-A). Use
56 + /// this for HTMX fragment handlers that return `Result`.
57 + pub fn render_fragment(template: &impl askama::Template) -> crate::error::Result<String> {
58 + template.render().map_err(|e| {
59 + tracing::error!(error = ?e, "fragment render failed");
60 + crate::error::AppError::Internal(anyhow::anyhow!("template render failed"))
61 + })
62 + }
63 +
52 64 /// Map a Postgres unique-violation (23505) into a clean `AppError::Conflict(msg)`;
53 65 /// pass any other error through unchanged. A create/insert on a deterministic or
54 66 /// user-supplied unique key that legitimately re-runs (a retry, a duplicate the
@@ -1,6 +1,5 @@
1 1 //! Content insertion API: reusable clip library + per-item placement management.
2 2
3 - use askama::Template;
4 3 use axum::{
5 4 extract::{Path, State},
6 5 response::{Html, IntoResponse},
@@ -250,11 +249,9 @@ pub(super) async fn list_insertions(
250 249 })
251 250 .collect();
252 251
253 - Ok(Html(
254 - InsertionListTemplate { insertions: display }
255 - .render()
256 - .unwrap_or_default(),
257 - ))
252 + Ok(Html(crate::helpers::render_fragment(
253 + &InsertionListTemplate { insertions: display },
254 + )?))
258 255 }
259 256
260 257 /// Rename an insertion clip.
@@ -367,15 +364,13 @@ pub(super) async fn list_placements(
367 364 })
368 365 .collect();
369 366
370 - Ok(Html(
371 - crate::templates::PlacementListTemplate {
367 + Ok(Html(crate::helpers::render_fragment(
368 + &crate::templates::PlacementListTemplate {
372 369 item_id: item_id.to_string(),
373 370 placements: placement_display,
374 371 available_insertions: insertion_display,
375 - }
376 - .render()
377 - .unwrap_or_default(),
378 - ))
372 + },
373 + )?))
379 374 }
380 375
381 376 /// Create a placement (attach an insertion to an item).
@@ -111,15 +111,12 @@ pub(super) async fn revoke(
111 111 /// Render the tokens list partial, optionally with a freshly-minted plaintext
112 112 /// shown once at the top.
113 113 async fn render_list(state: &AppState, user_id: db::UserId, new_token: Option<&str>) -> Result<String> {
114 - use askama::Template;
115 114 let tokens = db::git_access_tokens::list_by_user(&state.db, user_id).await?;
116 115 let tokens: Vec<GitTokenView> = tokens.iter().map(GitTokenView::from).collect();
117 - Ok(crate::templates::GitTokensListTemplate {
116 + crate::helpers::render_fragment(&crate::templates::GitTokensListTemplate {
118 117 tokens,
119 118 new_token: new_token.map(str::to_string),
120 - }
121 - .render()
122 - .unwrap_or_default())
119 + })
123 120 }
124 121
125 122 /// View type for token display (never carries the hash or plaintext).
@@ -36,9 +36,7 @@ pub(super) async fn list_keys_html(
36 36 ) -> Result<impl IntoResponse> {
37 37 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
38 38 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
39 - let html = crate::templates::SshKeysListTemplate { ssh_keys }
40 - .render()
41 - .unwrap_or_default();
39 + let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
42 40 Ok(axum::response::Html(html))
43 41 }
44 42
@@ -102,9 +100,7 @@ pub(super) async fn add_key(
102 100 // Re-render the SSH keys section via HTMX
103 101 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
104 102 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
105 - let html = crate::templates::SshKeysListTemplate { ssh_keys }
106 - .render()
107 - .unwrap_or_default();
103 + let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
108 104 return Ok((
109 105 [("HX-Trigger", hx_toast("SSH key added", "success"))],
110 106 axum::response::Html(html),
@@ -142,9 +138,7 @@ pub(super) async fn delete_key(
142 138 if is_htmx_request(&headers) {
143 139 let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
144 140 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
145 - let html = crate::templates::SshKeysListTemplate { ssh_keys }
146 - .render()
147 - .unwrap_or_default();
141 + let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
148 142 return Ok((
149 143 [("HX-Trigger", hx_toast("SSH key removed", "success"))],
150 144 axum::response::Html(html),
@@ -182,8 +176,6 @@ fn rebuild_authorized_keys() {
182 176 });
183 177 }
184 178
185 - use askama::Template;
186 -
187 179 /// View type for SSH key display in templates.
188 180 #[derive(Clone)]
189 181 pub struct SshKeyView {
@@ -294,9 +294,11 @@ pub async fn step_account_create(
294 294 pub async fn step_load(
295 295 State(state): State<AppState>,
296 296 AuthUser(user): AuthUser,
297 + session: Session,
297 298 Path(step): Path<String>,
298 299 ) -> Result<Response> {
299 - render_step(&step, &state, user.id).await
300 + let csrf_token = get_csrf_token(&session).await;
301 + render_step(&step, &state, user.id, csrf_token).await
300 302 }
301 303
302 304 /// POST `/join/step/{step}`: save and return next step.
@@ -322,7 +324,7 @@ pub async fn step_save(
322 324 )
323 325 .await?;
324 326 }
325 - render_step("complete", &state, user.id).await
327 + render_step("complete", &state, user.id, None).await
326 328 }
327 329 _ => Err(AppError::NotFound),
328 330 }
@@ -337,12 +339,14 @@ fn render_step_profile() -> Response {
337 339 }
338 340
339 341 /// Render a step partial with the sidebar nav.
340 - async fn render_step(step: &str, state: &AppState, user_id: db::UserId) -> Result<Response> {
342 + async fn render_step(step: &str, state: &AppState, user_id: db::UserId, csrf_token: Option<String>) -> Result<Response> {
341 343 match step {
342 344 "account" => {
345 + // Thread a real CSRF token so a back-nav to the account step renders a
346 + // submittable form, not a token-less one that 403s (Run 12 UX MINOR).
343 347 Ok(WizardJoinAccountTemplate {
344 348 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
345 - csrf_token: None,
349 + csrf_token,
346 350 invite_code: None,
347 351 username: String::new(),
348 352 email: String::new(),
@@ -63,7 +63,7 @@
63 63 </div>
64 64 <div class="user-media-card-actions">
65 65 <button class="btn-small user-media-card-btn" onclick="mediaCopyRef('{{ file.markdown_ref }}')">Copy ref</button>
66 - <button class="btn-small btn-danger user-media-card-btn" onclick="mediaDeleteFile('{{ file.id }}', '{{ file.filename }}')">Delete</button>
66 + <button class="btn-small btn-danger user-media-card-btn" data-filename="{{ file.filename }}" onclick="mediaDeleteFile('{{ file.id }}', this.dataset.filename)">Delete</button>
67 67 </div>
68 68 </div>
69 69 {% endfor %}