max / makenotwork
7 files changed,
+103 insertions,
-29 deletions
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use askama::Template; | |
| 4 | 4 | use axum::{ | |
| 5 | - | http::StatusCode, | |
| 5 | + | http::{HeaderValue, StatusCode}, | |
| 6 | 6 | response::{Html, IntoResponse, Response}, | |
| 7 | 7 | }; | |
| 8 | 8 | ||
| @@ -216,12 +216,31 @@ impl IntoResponse for AppError { | |||
| 216 | 216 | }; | |
| 217 | 217 | ||
| 218 | 218 | // Stash the message so json_error_layer can swap HTML → JSON on API routes | |
| 219 | - | response.extensions_mut().insert(ApiErrorMessage(message)); | |
| 219 | + | response.extensions_mut().insert(ApiErrorMessage(message.clone())); | |
| 220 | + | ||
| 221 | + | // Also expose the message as an `HX-Error` header so the global HTMX error | |
| 222 | + | // toast can surface it on EVERY route, not just /api routes where the body | |
| 223 | + | // is parseable JSON — a page route returns an HTML error body the toast | |
| 224 | + | // can't read, so it would otherwise show a generic string (Run 11 UX MINOR). | |
| 225 | + | if let Ok(value) = HeaderValue::from_str(&header_safe(&message)) { | |
| 226 | + | response.headers_mut().insert("HX-Error", value); | |
| 227 | + | } | |
| 220 | 228 | ||
| 221 | 229 | response | |
| 222 | 230 | } | |
| 223 | 231 | } | |
| 224 | 232 | ||
| 233 | + | /// Reduce a user message to a single-line, visible-ASCII header value (header | |
| 234 | + | /// values reject control bytes and non-ASCII). Truncated so a long validation | |
| 235 | + | /// message can't bloat the response headers. | |
| 236 | + | fn header_safe(message: &str) -> String { | |
| 237 | + | message | |
| 238 | + | .chars() | |
| 239 | + | .map(|c| if (' '..='~').contains(&c) { c } else { ' ' }) | |
| 240 | + | .take(256) | |
| 241 | + | .collect() | |
| 242 | + | } | |
| 243 | + | ||
| 225 | 244 | /// Error page template | |
| 226 | 245 | #[derive(Template)] | |
| 227 | 246 | #[template(path = "pages/error.html")] |
| @@ -311,12 +311,12 @@ pub(super) async fn save_preview( | |||
| 311 | 311 | _ => {} // draft -- leave as is | |
| 312 | 312 | } | |
| 313 | 313 | ||
| 314 | + | // item.id is a UUID so the parse can't fail, but fall back rather than | |
| 315 | + | // panic the worker for consistency with the other HX-Redirect sites (Run 11 UX MINOR). | |
| 314 | 316 | let mut response = Response::new(axum::body::Body::empty()); | |
| 315 | - | response.headers_mut().insert( | |
| 316 | - | "HX-Redirect", | |
| 317 | - | format!("/dashboard/item/{}", item.id) | |
| 318 | - | .parse() | |
| 319 | - | .expect("redirect path is valid"), | |
| 320 | - | ); | |
| 317 | + | let redirect = format!("/dashboard/item/{}", item.id) | |
| 318 | + | .parse() | |
| 319 | + | .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard")); | |
| 320 | + | response.headers_mut().insert("HX-Redirect", redirect); | |
| 321 | 321 | Ok(response) | |
| 322 | 322 | } |
| @@ -470,14 +470,14 @@ async fn save_preview( | |||
| 470 | 470 | } | |
| 471 | 471 | } | |
| 472 | 472 | ||
| 473 | - | // Redirect to the project dashboard | |
| 473 | + | // Redirect to the project dashboard. The slug is validated, so the parse | |
| 474 | + | // should always succeed — but fall back to the dashboard root rather than | |
| 475 | + | // panicking the worker if a pathological value ever slips through (Run 11 UX MINOR). | |
| 474 | 476 | let mut response = Response::new(axum::body::Body::empty()); | |
| 475 | - | response.headers_mut().insert( | |
| 476 | - | "HX-Redirect", | |
| 477 | - | format!("/dashboard/project/{}", project.slug) | |
| 478 | - | .parse() | |
| 479 | - | .expect("redirect path is valid"), | |
| 480 | - | ); | |
| 477 | + | let redirect = format!("/dashboard/project/{}", project.slug) | |
| 478 | + | .parse() | |
| 479 | + | .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard")); | |
| 480 | + | response.headers_mut().insert("HX-Redirect", redirect); | |
| 481 | 481 | Ok(response) | |
| 482 | 482 | } | |
| 483 | 483 |
| @@ -272,18 +272,26 @@ document.body.addEventListener('htmx:responseError', function(evt) { | |||
| 272 | 272 | var toast = document.createElement('div'); | |
| 273 | 273 | toast.className = 'toast toast-error'; | |
| 274 | 274 | var msg = document.createElement('span'); | |
| 275 | - | // Surface the server's specific message. API errors come back as | |
| 276 | - | // {"error": "..."} (json_error_layer); fall back to a generic string when the | |
| 277 | - | // body isn't that shape so we never render raw JSON or "[object Object]". | |
| 275 | + | // Surface the server's specific message. Every AppError response carries an | |
| 276 | + | // `HX-Error` header with the user message, so this works on page routes too | |
| 277 | + | // (whose body is an HTML error page, not JSON). API routes additionally return | |
| 278 | + | // {"error": "..."}; fall back to that, then to a generic string, so we never | |
| 279 | + | // render raw JSON or "[object Object]". | |
| 278 | 280 | var text = 'An error occurred.'; | |
| 279 | - | var body = evt.detail && evt.detail.xhr && evt.detail.xhr.responseText; | |
| 280 | - | if (body) { | |
| 281 | - | try { | |
| 282 | - | var parsed = JSON.parse(body); | |
| 283 | - | if (parsed && typeof parsed.error === 'string' && parsed.error) { | |
| 284 | - | text = parsed.error; | |
| 285 | - | } | |
| 286 | - | } catch (e) { /* non-JSON body (e.g. an HTML error page); keep the fallback */ } | |
| 281 | + | var xhr = evt.detail && evt.detail.xhr; | |
| 282 | + | var headerMsg = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Error'); | |
| 283 | + | if (headerMsg) { | |
| 284 | + | text = headerMsg; | |
| 285 | + | } else { | |
| 286 | + | var body = xhr && xhr.responseText; | |
| 287 | + | if (body) { | |
| 288 | + | try { | |
| 289 | + | var parsed = JSON.parse(body); | |
| 290 | + | if (parsed && typeof parsed.error === 'string' && parsed.error) { | |
| 291 | + | text = parsed.error; | |
| 292 | + | } | |
| 293 | + | } catch (e) { /* non-JSON body (e.g. an HTML error page); keep the fallback */ } | |
| 294 | + | } | |
| 287 | 295 | } | |
| 288 | 296 | msg.textContent = text; | |
| 289 | 297 | toast.appendChild(msg); |
| @@ -5802,7 +5802,8 @@ textarea:focus-visible { | |||
| 5802 | 5802 | } | |
| 5803 | 5803 | ||
| 5804 | 5804 | .fork-actions { | |
| 5805 | - | margin-top: 1.25rem; | |
| 5805 | + | margin-top: auto; | |
| 5806 | + | padding-top: 1.25rem; | |
| 5806 | 5807 | display: flex; | |
| 5807 | 5808 | flex-direction: column; | |
| 5808 | 5809 | align-items: center; |
| @@ -28,7 +28,7 @@ | |||
| 28 | 28 | <div id="buy-once-fields" class="hidden"> | |
| 29 | 29 | <div class="form-group"> | |
| 30 | 30 | <label for="price_dollars">Price ($)</label> | |
| 31 | - | <input type="number" name="price_dollars" id="price_dollars" min="0.50" step="0.01" | |
| 31 | + | <input type="number" name="price_dollars" id="price_dollars" min="0.50" max="10000" step="0.01" | |
| 32 | 32 | placeholder="9.99" value="{{ price_dollars }}" disabled> | |
| 33 | 33 | </div> | |
| 34 | 34 | </div> | |
| @@ -36,7 +36,7 @@ | |||
| 36 | 36 | <div id="pwyw-fields" class="hidden"> | |
| 37 | 37 | <div class="form-group"> | |
| 38 | 38 | <label for="pwyw_min_dollars">Minimum price ($, 0 for no minimum)</label> | |
| 39 | - | <input type="number" name="pwyw_min_dollars" id="pwyw_min_dollars" min="0" step="0.01" | |
| 39 | + | <input type="number" name="pwyw_min_dollars" id="pwyw_min_dollars" min="0" max="10000" step="0.01" | |
| 40 | 40 | placeholder="0.00" value="{{ pwyw_min_dollars }}" disabled> | |
| 41 | 41 | </div> | |
| 42 | 42 | </div> |
| @@ -123,6 +123,52 @@ async fn project_wizard_full_flow() { | |||
| 123 | 123 | assert!(is_public, "Project should be published"); | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | /// Regression (ultra-fuzz Run 11 UX F1): the $10k price cap must hold on the | |
| 127 | + | /// creation-wizard monetization step, not just the JSON API. A buy-once price | |
| 128 | + | /// above the cap is rejected, and no over-cap price is persisted. | |
| 129 | + | #[tokio::test] | |
| 130 | + | async fn project_wizard_buy_once_price_cap_enforced() { | |
| 131 | + | let mut h = TestHarness::new().await; | |
| 132 | + | let _user_id = h.create_creator("capwiz").await; | |
| 133 | + | ||
| 134 | + | h.client | |
| 135 | + | .post_form( | |
| 136 | + | "/dashboard/new-project/step/basics", | |
| 137 | + | "title=Cap+Test&slug=cap-test&project_type=blog&description=A+test", | |
| 138 | + | ) | |
| 139 | + | .await; | |
| 140 | + | h.client | |
| 141 | + | .post_form("/dashboard/new-project/cap-test/step/appearance", "") | |
| 142 | + | .await; | |
| 143 | + | ||
| 144 | + | // $20,000 — well over the $10,000 cap. | |
| 145 | + | let resp = h | |
| 146 | + | .client | |
| 147 | + | .post_form( | |
| 148 | + | "/dashboard/new-project/cap-test/step/monetization", | |
| 149 | + | "pricing_model=buy_once&price_dollars=20000", | |
| 150 | + | ) | |
| 151 | + | .await; | |
| 152 | + | assert!( | |
| 153 | + | !resp.status.is_success() || resp.text.to_lowercase().contains("10,000") | |
| 154 | + | || resp.text.to_lowercase().contains("exceed"), | |
| 155 | + | "over-cap wizard price should be rejected: {} {}", | |
| 156 | + | resp.status, | |
| 157 | + | resp.text | |
| 158 | + | ); | |
| 159 | + | ||
| 160 | + | // Nothing over the cap was persisted. | |
| 161 | + | let price: Option<i32> = | |
| 162 | + | sqlx::query_scalar("SELECT price_cents FROM projects WHERE slug = 'cap-test'") | |
| 163 | + | .fetch_one(&h.db) | |
| 164 | + | .await | |
| 165 | + | .unwrap(); | |
| 166 | + | assert!( | |
| 167 | + | price.unwrap_or(0) <= 1_000_000, | |
| 168 | + | "persisted price_cents {price:?} must not exceed the $10k cap" | |
| 169 | + | ); | |
| 170 | + | } | |
| 171 | + | ||
| 126 | 172 | #[tokio::test] | |
| 127 | 173 | async fn project_wizard_save_as_draft() { | |
| 128 | 174 | let mut h = TestHarness::new().await; |