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>
6 files changed,
+33 insertions,
-33 deletions
| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 %}
|