Skip to main content

max / makenotwork

Frontend: kill media-picker XSS; render_string surfaces errors /audit Run 13 Frontend cold spots: - static/media-picker.js: rebuild the media grid with DOM APIs (createElement / textContent / dataset / addEventListener) instead of string-interpolated innerHTML. file_name / folder / cdn_url are user-controlled, so a crafted filename was a stored XSS sink (the onclick only escaped single quotes). Also removes the inline on* handlers. - render_string: the 7 partial `render_string()` helpers returned String and swallowed an Askama render failure to a blank/"Error" fragment with no error signal. They now return crate::error::Result<String> (via the sanctioned helpers::render_fragment), so a render failure surfaces as 500 + HX-Error instead of a silent blank swap. All 49 call sites thread the Result; handlers that returned Html<String>/Response directly (validate_*, validate_username, export_error_html, export_pending_html) now return Result. The blank-on-error swap is no longer expressible. Integration: auth + adversarial_auth + exports + broadcast + license_keys + custom_links green (46). clippy --lib clean, media-picker node --check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 21:40 UTC
Commit: b7b048ff9c324bf6d8463d722cb657ac13e3e2e1
Parent: c57008d
16 files changed, +147 insertions, -118 deletions
@@ -122,7 +122,7 @@ pub(in crate::routes::api) async fn export_content(
122 122
123 123 if files.is_empty() {
124 124 if is_htmx {
125 - return Ok(export_error_html("No content files to export."));
125 + return export_error_html("No content files to export.");
126 126 }
127 127 return Err(AppError::BadRequest("No content files to export.".to_string()));
128 128 }
@@ -164,7 +164,7 @@ pub(in crate::routes::api) async fn export_content(
164 164 user.email
165 165 );
166 166 if is_htmx {
167 - return Ok(super::export_pending_html(&message));
167 + return super::export_pending_html(&message);
168 168 }
169 169 Response::builder()
170 170 .status(axum::http::StatusCode::ACCEPTED)
@@ -109,28 +109,28 @@ async fn finish_csv(is_htmx: bool, filename: &str, mut rx: mpsc::Receiver<Bytes>
109 109 /// Return an inline error message for HTMX export requests instead of
110 110 /// letting the error propagate to the JSON error layer (which would swap
111 111 /// raw JSON text into the status div).
112 - pub(crate) fn export_error_html(message: &str) -> Response {
113 - axum::response::Html(
112 + pub(crate) fn export_error_html(message: &str) -> Result<Response> {
113 + Ok(axum::response::Html(
114 114 FormStatusTemplate {
115 115 success: false,
116 116 message: message.to_string(),
117 117 }
118 - .render_string(),
118 + .render_string()?,
119 119 )
120 - .into_response()
120 + .into_response())
121 121 }
122 122
123 123 /// HTMX success panel for a queued (backgrounded) export — the link is delivered
124 124 /// by email when the job finishes rather than inline.
125 - pub(crate) fn export_pending_html(message: &str) -> Response {
126 - axum::response::Html(
125 + pub(crate) fn export_pending_html(message: &str) -> Result<Response> {
126 + Ok(axum::response::Html(
127 127 FormStatusTemplate {
128 128 success: true,
129 129 message: message.to_string(),
130 130 }
131 - .render_string(),
131 + .render_string()?,
132 132 )
133 - .into_response()
133 + .into_response())
134 134 }
135 135
136 136 /// Build an HTTP response for a downloadable file attachment.
@@ -423,7 +423,7 @@ pub(in crate::routes::api) async fn update_item_text(
423 423 return Ok(axum::response::Html(SaveStatusTemplate {
424 424 success: true,
425 425 message: format!("{} words saved", word_count.unwrap_or(0)),
426 - }.render_string()).into_response());
426 + }.render_string()?).into_response());
427 427 }
428 428
429 429 Ok(Json(UpdateTextResponse {
@@ -68,7 +68,7 @@ pub(in crate::routes::api) async fn add_tag(
68 68 tag_id: form.tag_id.to_string(),
69 69 tag_name: tag.name,
70 70 is_primary: false,
71 - }.render_string()))
71 + }.render_string()?))
72 72 }
73 73
74 74 /// Remove a tag from an owned item.
@@ -349,7 +349,7 @@ pub(super) async fn update_license_settings(
349 349 return Ok(Html(SaveStatusTemplate {
350 350 success: true,
351 351 message: "License settings saved".to_string(),
352 - }.render_string()).into_response());
352 + }.render_string()?).into_response());
353 353 }
354 354
355 355 Ok(StatusCode::NO_CONTENT.into_response())
@@ -67,7 +67,7 @@ pub(super) async fn create_link(
67 67 id: link.id.to_string(),
68 68 title: link.title,
69 69 url: link.url,
70 - }.render_string()).into_response());
70 + }.render_string()?).into_response());
71 71 }
72 72
73 73 Ok(Json(LinkResponse {
@@ -37,7 +37,7 @@ pub(in crate::routes::api) async fn broadcast_send(
37 37 return Ok(Html(FormStatusTemplate {
38 38 success: false,
39 39 message: "Creator access required".to_string(),
40 - }.render_string()).into_response());
40 + }.render_string()?).into_response());
41 41 }
42 42
43 43 // Validate subject and body
@@ -48,14 +48,14 @@ pub(in crate::routes::api) async fn broadcast_send(
48 48 return Ok(Html(FormStatusTemplate {
49 49 success: false,
50 50 message: "Subject must be between 1 and 200 characters".to_string(),
51 - }.render_string()).into_response());
51 + }.render_string()?).into_response());
52 52 }
53 53
54 54 if body.is_empty() || body.chars().count() > 5000 {
55 55 return Ok(Html(FormStatusTemplate {
56 56 success: false,
57 57 message: "Body must be between 1 and 5000 characters".to_string(),
58 - }.render_string()).into_response());
58 + }.render_string()?).into_response());
59 59 }
60 60
61 61 // Rate limit: one broadcast per 24 hours
@@ -63,7 +63,7 @@ pub(in crate::routes::api) async fn broadcast_send(
63 63 return Ok(Html(FormStatusTemplate {
64 64 success: false,
65 65 message: "You can only send one broadcast per 24 hours".to_string(),
66 - }.render_string()).into_response());
66 + }.render_string()?).into_response());
67 67 }
68 68
69 69 // Get follower emails, enforcing the broadcast recipient cap at the type
@@ -79,7 +79,7 @@ pub(in crate::routes::api) async fn broadcast_send(
79 79 message: format!(
80 80 "Broadcast would reach {count} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account."
81 81 ),
82 - }.render_string()).into_response());
82 + }.render_string()?).into_response());
83 83 }
84 84 };
85 85 let count = recipients.len();
@@ -88,7 +88,7 @@ pub(in crate::routes::api) async fn broadcast_send(
88 88 return Ok(Html(FormStatusTemplate {
89 89 success: true,
90 90 message: "No followers to notify".to_string(),
91 - }.render_string()).into_response());
91 + }.render_string()?).into_response());
92 92 }
93 93
94 94 // Get creator name
@@ -154,5 +154,5 @@ pub(in crate::routes::api) async fn broadcast_send(
154 154 Ok(Html(FormStatusTemplate {
155 155 success: true,
156 156 message: format!("Broadcast sent to {} follower{}", count, if count == 1 { "" } else { "s" }),
157 - }.render_string()).into_response())
157 + }.render_string()?).into_response())
158 158 }
@@ -54,7 +54,7 @@ pub(in crate::routes::api) async fn add_to_library(
54 54 return Ok(Html(SaveStatusTemplate {
55 55 success: false,
56 56 message: "This item is not free".to_string(),
57 - }.render_string()).into_response());
57 + }.render_string()?).into_response());
58 58 }
59 59 return Err(AppError::BadRequest("This item is not free".to_string()));
60 60 }
@@ -181,7 +181,7 @@ pub(in crate::routes::api) async fn remove_from_library(
181 181 return Ok(Html(SaveStatusTemplate {
182 182 success: false,
183 183 message: "Could not remove item".to_string(),
184 - }.render_string()).into_response());
184 + }.render_string()?).into_response());
185 185 }
186 186 }
187 187
@@ -50,5 +50,5 @@ pub(in crate::routes::api) async fn update_preferences(
50 50 Ok(Html(SaveStatusTemplate {
51 51 success: true,
52 52 message: "Preferences saved".to_string(),
53 - }.render_string()).into_response())
53 + }.render_string()?).into_response())
54 54 }
@@ -67,7 +67,7 @@ pub(in crate::routes::api) async fn update_profile(
67 67 return Ok(Html(SaveStatusTemplate {
68 68 success: false,
69 69 message: e.user_message(),
70 - }.render_string()).into_response());
70 + }.render_string()?).into_response());
71 71 }
72 72 return Err(e);
73 73 }
@@ -84,7 +84,7 @@ pub(in crate::routes::api) async fn update_profile(
84 84 return Ok(Html(SaveStatusTemplate {
85 85 success: true,
86 86 message: "Profile saved".to_string(),
87 - }.render_string()).into_response());
87 + }.render_string()?).into_response());
88 88 }
89 89
90 90 Ok(Json(ProfileResponse {
@@ -119,7 +119,7 @@ pub(in crate::routes::api) async fn update_profile_theme(
119 119 return Ok(Html(SaveStatusTemplate {
120 120 success: true,
121 121 message: "Theme saved".to_string(),
122 - }.render_string()).into_response());
122 + }.render_string()?).into_response());
123 123 }
124 124 Ok(StatusCode::NO_CONTENT.into_response())
125 125 }
@@ -154,7 +154,7 @@ pub(in crate::routes::api) async fn update_password(
154 154 return Ok(Html(SaveStatusTemplate {
155 155 success: false,
156 156 message: "Current password is incorrect".to_string(),
157 - }.render_string()).into_response());
157 + }.render_string()?).into_response());
158 158 }
159 159 return Err(AppError::BadRequest("Current password is incorrect".to_string()));
160 160 }
@@ -166,7 +166,7 @@ pub(in crate::routes::api) async fn update_password(
166 166 return Ok(Html(SaveStatusTemplate {
167 167 success: false,
168 168 message: "New password must be at least 8 characters".to_string(),
169 - }.render_string()).into_response());
169 + }.render_string()?).into_response());
170 170 }
171 171 return Err(AppError::validation(
172 172 "New password must be at least 8 characters".to_string(),
@@ -177,7 +177,7 @@ pub(in crate::routes::api) async fn update_password(
177 177 return Ok(Html(SaveStatusTemplate {
178 178 success: false,
179 179 message: "Password must be 128 characters or fewer".to_string(),
180 - }.render_string()).into_response());
180 + }.render_string()?).into_response());
181 181 }
182 182 return Err(AppError::validation(
183 183 "Password must be 128 characters or fewer".to_string(),
@@ -236,7 +236,7 @@ pub(in crate::routes::api) async fn update_password(
236 236 return Ok(Html(SaveStatusTemplate {
237 237 success: true,
238 238 message: "Password updated".to_string(),
239 - }.render_string()).into_response());
239 + }.render_string()?).into_response());
240 240 }
241 241
242 242 Ok(StatusCode::NO_CONTENT.into_response())
@@ -450,7 +450,7 @@ pub(in crate::routes::api) async fn request_account_deletion(
450 450 return Ok(Html(FormStatusTemplate {
451 451 success: false,
452 452 message: "Username does not match".to_string(),
453 - }.render_string()).into_response());
453 + }.render_string()?).into_response());
454 454 }
455 455 return Err(AppError::BadRequest("Username does not match".to_string()));
456 456 }
@@ -473,7 +473,7 @@ pub(in crate::routes::api) async fn request_account_deletion(
473 473 return Ok(Html(FormStatusTemplate {
474 474 success: false,
475 475 message: "Failed to send email. Please try again.".to_string(),
476 - }.render_string()).into_response());
476 + }.render_string()?).into_response());
477 477 }
478 478 return Err(e);
479 479 }
@@ -484,7 +484,7 @@ pub(in crate::routes::api) async fn request_account_deletion(
484 484 return Ok(Html(FormStatusTemplate {
485 485 success: true,
486 486 message: "Confirmation email sent. Check your inbox.".to_string(),
487 - }.render_string()).into_response());
487 + }.render_string()?).into_response());
488 488 }
489 489
490 490 Ok(Json(SuccessMessageResponse {
@@ -34,5 +34,5 @@ pub(in crate::routes::api) async fn toggle_stripe_tax(
34 34 Ok(Html(SaveStatusTemplate {
35 35 success: true,
36 36 message: "Tax setting saved".to_string(),
37 - }.render_string()).into_response())
37 + }.render_string()?).into_response())
38 38 }
@@ -38,14 +38,14 @@ pub(in crate::routes::api) async fn submit_support_ticket(
38 38 return Ok(Html(FormStatusTemplate {
39 39 success: false,
40 40 message: "Subject must be between 1 and 200 characters.".to_string(),
41 - }.render_string()).into_response());
41 + }.render_string()?).into_response());
42 42 }
43 43
44 44 if message.is_empty() || message.chars().count() > 5000 {
45 45 return Ok(Html(FormStatusTemplate {
46 46 success: false,
47 47 message: "Message must be between 1 and 5000 characters.".to_string(),
48 - }.render_string()).into_response());
48 + }.render_string()?).into_response());
49 49 }
50 50
51 51 let valid_categories = ["bug", "billing", "account", "content", "security", "other"];
@@ -53,7 +53,7 @@ pub(in crate::routes::api) async fn submit_support_ticket(
53 53 return Ok(Html(FormStatusTemplate {
54 54 success: false,
55 55 message: "Please select a valid category.".to_string(),
56 - }.render_string()).into_response());
56 + }.render_string()?).into_response());
57 57 }
58 58
59 59 // Build ticket body
@@ -118,5 +118,5 @@ Makenotwork Support"#,
118 118 Ok(Html(FormStatusTemplate {
119 119 success: true,
120 120 message: "Your message has been submitted. Check your email for a confirmation. We'll get back to you soon.".to_string(),
121 - }.render_string()).into_response())
121 + }.render_string()?).into_response())
122 122 }
@@ -2,7 +2,7 @@
2 2
3 3 use axum::{
4 4 extract::State,
5 - response::{Html, IntoResponse},
5 + response::Html,
6 6 };
7 7 use serde::Deserialize;
8 8
@@ -73,30 +73,30 @@ pub async fn validate_project_slug(
73 73 State(state): State<AppState>,
74 74 auth: AuthUser,
75 75 axum::Form(form): axum::Form<SlugForm>,
76 - ) -> impl IntoResponse {
76 + ) -> crate::error::Result<Html<String>> {
77 77 if form.slug.is_empty() {
78 - return Html(String::new());
78 + return Ok(Html(String::new()));
79 79 }
80 80 if form.slug.len() < 2 {
81 - return Html(
81 + return Ok(Html(
82 82 SaveStatusTemplate {
83 83 success: false,
84 84 message: "Must be at least 2 characters".to_string(),
85 85 }
86 - .render_string(),
87 - );
86 + .render_string()?,
87 + ));
88 88 }
89 89
90 90 let slug = match Slug::new(&form.slug) {
91 91 Ok(s) => s,
92 92 Err(_) => {
93 - return Html(
93 + return Ok(Html(
94 94 SaveStatusTemplate {
95 95 success: false,
96 96 message: "Letters, numbers, and hyphens only".to_string(),
97 97 }
98 - .render_string(),
99 - );
98 + .render_string()?,
99 + ));
100 100 }
101 101 };
102 102
@@ -107,21 +107,21 @@ pub async fn validate_project_slug(
107 107
108 108 if is_taken {
109 109 let suggestions = suggest_slugs(&state.db, auth.0.id, &form.slug).await;
110 - Html(
110 + Ok(Html(
111 111 SlugStatusTemplate {
112 112 available: false,
113 113 suggestions,
114 114 }
115 - .render_string(),
116 - )
115 + .render_string()?,
116 + ))
117 117 } else {
118 - Html(
118 + Ok(Html(
119 119 SlugStatusTemplate {
120 120 available: true,
121 121 suggestions: Vec::new(),
122 122 }
123 - .render_string(),
124 - )
123 + .render_string()?,
124 + ))
125 125 }
126 126 }
127 127
@@ -131,30 +131,30 @@ pub async fn validate_collection_slug(
131 131 State(state): State<AppState>,
132 132 auth: AuthUser,
133 133 axum::Form(form): axum::Form<SlugForm>,
134 - ) -> impl IntoResponse {
134 + ) -> crate::error::Result<Html<String>> {
135 135 if form.slug.is_empty() {
136 - return Html(String::new());
136 + return Ok(Html(String::new()));
137 137 }
138 138 if form.slug.len() < 2 {
139 - return Html(
139 + return Ok(Html(
140 140 SaveStatusTemplate {
141 141 success: false,
142 142 message: "Must be at least 2 characters".to_string(),
143 143 }
144 - .render_string(),
145 - );
144 + .render_string()?,
145 + ));
146 146 }
147 147
148 148 let slug = match Slug::new(&form.slug) {
149 149 Ok(s) => s,
150 150 Err(_) => {
151 - return Html(
151 + return Ok(Html(
152 152 SaveStatusTemplate {
153 153 success: false,
154 154 message: "Letters, numbers, and hyphens only".to_string(),
155 155 }
156 - .render_string(),
157 - );
156 + .render_string()?,
157 + ));
158 158 }
159 159 };
160 160
@@ -164,7 +164,7 @@ pub async fn validate_collection_slug(
164 164 Err(e) => return slug_check_failed(e),
165 165 };
166 166
167 - Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string())
167 + Ok(Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()?))
168 168 }
169 169
170 170 /// Check blog post slug availability within a project.
@@ -173,42 +173,42 @@ pub async fn validate_blog_slug(
173 173 State(state): State<AppState>,
174 174 auth: AuthUser,
175 175 axum::Form(form): axum::Form<BlogSlugForm>,
176 - ) -> impl IntoResponse {
176 + ) -> crate::error::Result<Html<String>> {
177 177 if form.slug.is_empty() {
178 - return Html(String::new());
178 + return Ok(Html(String::new()));
179 179 }
180 180 if form.slug.len() < 2 {
181 - return Html(
181 + return Ok(Html(
182 182 SaveStatusTemplate {
183 183 success: false,
184 184 message: "Must be at least 2 characters".to_string(),
185 185 }
186 - .render_string(),
187 - );
186 + .render_string()?,
187 + ));
188 188 }
189 189
190 190 let slug = match Slug::new(&form.slug) {
191 191 Ok(s) => s,
192 192 Err(_) => {
193 - return Html(
193 + return Ok(Html(
194 194 SaveStatusTemplate {
195 195 success: false,
196 196 message: "Letters, numbers, and hyphens only".to_string(),
197 197 }
198 - .render_string(),
199 - );
198 + .render_string()?,
199 + ));
200 200 }
201 201 };
202 202
203 203 let project_id = match form.project_id.parse::<ProjectId>() {
204 204 Ok(id) => id,
205 - Err(_) => return Html(String::new()),
205 + Err(_) => return Ok(Html(String::new())),
206 206 };
207 207
208 208 // Verify the user owns this project
209 209 let project = match db::projects::get_project_by_id(&state.db, project_id).await {
210 210 Ok(Some(p)) if p.user_id == auth.0.id => p,
211 - _ => return Html(String::new()),
211 + _ => return Ok(Html(String::new())),
212 212 };
213 213
214 214 let is_taken = match db::blog_posts::blog_post_slug_exists(&state.db, project.id, &slug).await {
@@ -216,7 +216,7 @@ pub async fn validate_blog_slug(
216 216 Err(e) => return slug_check_failed(e),
217 217 };
218 218
219 - Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string())
219 + Ok(Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()?))
220 220 }
221 221
222 222 /// Fail-closed response for a slug-availability check whose DB query errored.
@@ -224,13 +224,13 @@ pub async fn validate_blog_slug(
224 224 /// creator into thinking a possibly-taken slug is free; the real save then
225 225 /// fails on the uniqueness constraint. Surface a retry instead (Run #21 UX LOW,
226 226 /// carry-forward from #18).
227 - fn slug_check_failed(e: crate::error::AppError) -> Html<String> {
227 + fn slug_check_failed(e: crate::error::AppError) -> crate::error::Result<Html<String>> {
228 228 tracing::warn!(error = ?e, "slug availability check failed; reporting retry");
229 - Html(
229 + Ok(Html(
230 230 SaveStatusTemplate {
231 231 success: false,
232 232 message: "Couldn't check availability right now — try again".to_string(),
233 233 }
234 - .render_string(),
235 - )
234 + .render_string()?,
235 + ))
236 236 }
@@ -110,7 +110,7 @@ async fn login_handler(
110 110 if is_htmx {
111 111 Ok(Html(LoginErrorTemplate {
112 112 message: msg.to_string(),
113 - }.render_string()).into_response())
113 + }.render_string()?).into_response())
114 114 } else {
115 115 // Full-page POST: re-render the login form with the username/email
116 116 // value preserved and the error inlined, instead of bouncing the
@@ -328,30 +328,30 @@ pub struct ValidateUsernameForm {
328 328 async fn validate_username(
329 329 State(state): State<AppState>,
330 330 Form(form): Form<ValidateUsernameForm>,
331 - ) -> impl IntoResponse {
331 + ) -> crate::error::Result<Html<String>> {
332 332 // Count characters, not bytes — Username::new uses chars().count() too,
333 333 // and `len()` on a multi-byte UTF-8 string over-counts (a 3-char ñ-bearing
334 334 // username trips the "too long" branch erroneously, and a 1-char emoji
335 335 // satisfies the `< 3` typing-guard with a single grapheme).
336 336 let char_count = form.username.chars().count();
337 337 if char_count < 3 {
338 - return Html(String::new());
338 + return Ok(Html(String::new()));
339 339 }
340 340
341 341 // Check if username is too long
342 342 if char_count > 50 {
343 - return Html(SaveStatusTemplate {
343 + return Ok(Html(SaveStatusTemplate {
344 344 success: false,
345 345 message: "Username too long".to_string(),
346 - }.render_string());
346 + }.render_string()?));
347 347 }
348 348
349 349 // Check if username has invalid characters
350 350 if !form.username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
351 - return Html(SaveStatusTemplate {
351 + return Ok(Html(SaveStatusTemplate {
352 352 success: false,
353 353 message: "Only letters, numbers, and underscores".to_string(),
354 - }.render_string());
354 + }.render_string()?));
355 355 }
356 356
357 357 // Anti-enumeration delay: every response takes >= 400ms regardless of outcome
@@ -362,24 +362,24 @@ async fn validate_username(
362 362 let username = match Username::new(&form.username) {
363 363 Ok(u) => u,
364 364 Err(_) => {
365 - return Html(SaveStatusTemplate {
365 + return Ok(Html(SaveStatusTemplate {
366 366 success: false,
367 367 message: "Invalid username format".to_string(),
368 - }.render_string());
368 + }.render_string()?));
369 369 }
370 370 };
371 371 // Treat a DB error as "unavailable, retry" rather than "available". Failing
372 372 // open here previously let users proceed past a transient lookup error and
373 373 // hit a confusing signup-side rejection or race.
374 374 match db::users::get_user_by_username(&state.db, &username).await {
375 - Ok(Some(_)) => Html(UsernameStatusTemplate { available: false }.render_string()),
376 - Ok(None) => Html(UsernameStatusTemplate { available: true }.render_string()),
375 + Ok(Some(_)) => Ok(Html(UsernameStatusTemplate { available: false }.render_string()?)),
376 + Ok(None) => Ok(Html(UsernameStatusTemplate { available: true }.render_string()?)),
377 377 Err(e) => {
378 378 tracing::warn!(error = ?e, "username availability lookup failed");
379 - Html(SaveStatusTemplate {
379 + Ok(Html(SaveStatusTemplate {
380 380 success: false,
381 381 message: "Couldn't check availability — please try again".to_string(),
382 - }.render_string())
382 + }.render_string()?))
383 383 }
384 384 }
385 385 }
@@ -68,8 +68,8 @@ pub struct FormStatusTemplate {
68 68 }
69 69
70 70 impl FormStatusTemplate {
71 - pub fn render_string(&self) -> String {
72 - self.render().unwrap_or_else(|_| "".to_string())
71 + pub fn render_string(&self) -> crate::error::Result<String> {
72 + crate::helpers::render_fragment(self)
73 73 }
74 74 }
75 75
@@ -103,8 +103,8 @@ pub struct LoginErrorTemplate {
103 103 }
104 104
105 105 impl LoginErrorTemplate {
106 - pub fn render_string(&self) -> String {
107 - self.render().unwrap_or_else(|_| "Error".to_string())
106 + pub fn render_string(&self) -> crate::error::Result<String> {
107 + crate::helpers::render_fragment(self)
108 108 }
109 109 }
110 110
@@ -116,8 +116,8 @@ pub struct UsernameStatusTemplate {
116 116 }
117 117
118 118 impl UsernameStatusTemplate {
119 - pub fn render_string(&self) -> String {
120 - self.render().unwrap_or_else(|_| "".to_string())
119 + pub fn render_string(&self) -> crate::error::Result<String> {
120 + crate::helpers::render_fragment(self)
121 121 }
122 122 }
123 123
@@ -130,8 +130,8 @@ pub struct SlugStatusTemplate {
130 130 }
131 131
132 132 impl SlugStatusTemplate {
133 - pub fn render_string(&self) -> String {
134 - self.render().unwrap_or_else(|_| "".to_string())
133 + pub fn render_string(&self) -> crate::error::Result<String> {
134 + crate::helpers::render_fragment(self)
135 135 }
136 136 }
137 137
@@ -144,8 +144,8 @@ pub struct SaveStatusTemplate {
144 144 }
145 145
146 146 impl SaveStatusTemplate {
147 - pub fn render_string(&self) -> String {
148 - self.render().unwrap_or_else(|_| "".to_string())
147 + pub fn render_string(&self) -> crate::error::Result<String> {
148 + crate::helpers::render_fragment(self)
149 149 }
150 150 }
151 151
@@ -670,8 +670,8 @@ pub struct TagTemplate {
670 670 }
671 671
672 672 impl TagTemplate {
673 - pub fn render_string(&self) -> String {
674 - self.render().unwrap_or_else(|_| "".to_string())
673 + pub fn render_string(&self) -> crate::error::Result<String> {
674 + crate::helpers::render_fragment(self)
675 675 }
676 676 }
677 677
@@ -692,8 +692,8 @@ pub struct LinkRowTemplate {
692 692 }
693 693
694 694 impl LinkRowTemplate {
695 - pub fn render_string(&self) -> String {
696 - self.render().unwrap_or_else(|_| "".to_string())
695 + pub fn render_string(&self) -> crate::error::Result<String> {
696 + crate::helpers::render_fragment(self)
697 697 }
698 698 }
699 699
@@ -66,29 +66,58 @@
66 66
67 67 function renderGrid(files) {
68 68 var grid = document.getElementById('media-picker-grid');
69 + grid.textContent = '';
69 70 if (!files || files.length === 0) {
70 - grid.innerHTML = '<p class="form-hint">No media files yet. Upload images in your Media Library tab first.</p>';
71 + var empty = document.createElement('p');
72 + empty.className = 'form-hint';
73 + empty.textContent = 'No media files yet. Upload images in your Media Library tab first.';
74 + grid.appendChild(empty);
71 75 return;
72 76 }
73 - var html = '';
77 + // Build the grid with DOM APIs (textContent / dataset / addEventListener),
78 + // never string-interpolated innerHTML: file_name / folder / cdn_url are
79 + // user-controlled and a crafted filename would otherwise be a stored XSS
80 + // (ultra-fuzz Run 13 Frontend). This also drops the inline on* handlers.
74 81 for (var i = 0; i < files.length; i++) {
75 82 var f = files[i];
76 - var ref = f.markdown_ref || f.file_name;
83 + var ref = f.markdown_ref || f.file_name || '';
84 + var name = f.file_name || '';
77 85 var isImage = (f.content_type || '').indexOf('image') === 0;
78 - html += '<div class="mp-card" data-name="' + (f.file_name || '') + '" data-folder="' + (f.folder || '') + '" ' +
79 - 'style="cursor:pointer; border:1px solid var(--border-color); padding:0.5rem; background:var(--light-background); transition:outline 0.1s;" ' +
80 - 'onclick="window._mediaPickerSelect(\'' + ref.replace(/'/g, "\\'") + '\')" ' +
81 - 'onmouseenter="this.style.outline=\'2px solid var(--accent-color)\'" onmouseleave="this.style.outline=\'none\'">';
86 +
87 + var card = document.createElement('div');
88 + card.className = 'mp-card';
89 + card.dataset.name = name;
90 + card.dataset.folder = f.folder || '';
91 + card.style.cssText = 'cursor:pointer; border:1px solid var(--border-color); padding:0.5rem; background:var(--light-background); transition:outline 0.1s;';
92 + (function(r) {
93 + card.addEventListener('click', function() { window._mediaPickerSelect(r); });
94 + })(ref);
95 + card.addEventListener('mouseenter', function() { this.style.outline = '2px solid var(--accent-color)'; });
96 + card.addEventListener('mouseleave', function() { this.style.outline = 'none'; });
97 +
98 + var thumb = document.createElement('div');
82 99 if (isImage) {
83 - html += '<div style="width:100%; height:80px; overflow:hidden; display:flex; align-items:center; justify-content:center; background:var(--surface-muted); margin-bottom:0.4rem;">' +
84 - '<img src="' + f.cdn_url + '" alt="' + (f.file_name || '') + '" style="max-width:100%; max-height:80px; object-fit:contain;" loading="lazy"></div>';
100 + thumb.style.cssText = 'width:100%; height:80px; overflow:hidden; display:flex; align-items:center; justify-content:center; background:var(--surface-muted); margin-bottom:0.4rem;';
101 + var img = document.createElement('img');
102 + img.src = f.cdn_url || '';
103 + img.alt = name;
104 + img.style.cssText = 'max-width:100%; max-height:80px; object-fit:contain;';
105 + img.loading = 'lazy';
106 + thumb.appendChild(img);
85 107 } else {
86 - html += '<div style="width:100%; height:80px; display:flex; align-items:center; justify-content:center; background:var(--surface-muted); margin-bottom:0.4rem; font-size:1.5rem; opacity:0.4;">&#128196;</div>';
108 + thumb.style.cssText = 'width:100%; height:80px; display:flex; align-items:center; justify-content:center; background:var(--surface-muted); margin-bottom:0.4rem; font-size:1.5rem; opacity:0.4;';
109 + thumb.textContent = '\u{1F4C4}';
87 110 }
88 - html += '<div style="font-size:0.75rem; word-break:break-all; line-height:1.2;" title="' + (f.file_name || '') + '">' + (f.file_name || '') + '</div>';
89 - html += '</div>';
111 + card.appendChild(thumb);
112 +
113 + var label = document.createElement('div');
114 + label.style.cssText = 'font-size:0.75rem; word-break:break-all; line-height:1.2;';
115 + label.title = name;
116 + label.textContent = name;
117 + card.appendChild(label);
118 +
119 + grid.appendChild(card);
90 120 }
91 - grid.innerHTML = html;
92 121 }
93 122
94 123 window._mediaPickerSelect = function(ref) {