max / makenotwork
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;">📄</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) { |