max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+174 insertions,
-38 deletions
| @@ -1,24 +1,31 @@ | |||
| 1 | + | // Landing "notify me", the enhanced path. The form posts to /notify on its | |
| 2 | + | // own with JS off; this submits the same route and the same encoding over | |
| 3 | + | // fetch so the visitor keeps their place on the page instead of reloading it. | |
| 4 | + | // Posting the form verbatim also means the CSRF token rides along without | |
| 5 | + | // being read out and re-attached by hand. | |
| 1 | 6 | function submitNotify(e) { | |
| 2 | 7 | e.preventDefault(); | |
| 3 | 8 | var form = document.getElementById('notify-form'); | |
| 4 | 9 | var status = document.getElementById('notify-status'); | |
| 5 | - | var email = form.querySelector('input[name="email"]').value; | |
| 6 | 10 | var btn = form.querySelector('button'); | |
| 7 | 11 | btn.disabled = true; | |
| 8 | 12 | btn.textContent = 'Sending...'; | |
| 9 | 13 | status.textContent = ''; | |
| 10 | - | fetch('/api/email-signup', { | |
| 14 | + | status.className = 'notify-status'; | |
| 15 | + | fetch(form.action, { | |
| 11 | 16 | method: 'POST', | |
| 12 | - | headers: {'Content-Type': 'application/json'}, | |
| 13 | - | body: JSON.stringify({email: email}) | |
| 17 | + | headers: {'Accept': 'application/json'}, | |
| 18 | + | body: new URLSearchParams(new FormData(form)) | |
| 14 | 19 | }).then(function(r) { | |
| 15 | - | if (r.ok) { | |
| 16 | - | status.textContent = 'You\'re on the list.'; | |
| 17 | - | status.className = 'notify-status success'; | |
| 18 | - | form.querySelector('input[name="email"]').value = ''; | |
| 19 | - | } else { | |
| 20 | - | return r.json().then(function(d) { throw new Error(d.error || 'Something went wrong'); }); | |
| 20 | + | // The route answers a redirect to /?notify=ok or /?notify=invalid. | |
| 21 | + | // fetch follows it, so the landed URL is what says which happened. | |
| 22 | + | if (!r.ok) throw new Error('Something went wrong'); | |
| 23 | + | if (r.url.indexOf('notify=invalid') !== -1) { | |
| 24 | + | throw new Error('That address didn\'t look right.'); | |
| 21 | 25 | } | |
| 26 | + | status.textContent = 'You\'re on the list.'; | |
| 27 | + | status.className = 'notify-status success'; | |
| 28 | + | form.querySelector('input[name="email"]').value = ''; | |
| 22 | 29 | }).catch(function(err) { | |
| 23 | 30 | status.textContent = err.message; | |
| 24 | 31 | status.className = 'notify-status error'; |
| @@ -210,11 +210,22 @@ | |||
| 210 | 210 | <div class="tier-section"> | |
| 211 | 211 | <h2 class="section-label">Stay in the loop</h2> | |
| 212 | 212 | <p class="landing-prose mb-section">Get notified when something ships.</p> | |
| 213 | - | <form class="notify-form" id="notify-form" data-submit="onNotifySubmit"> | |
| 213 | + | {# Real action and method, so this works with JS off. page-index.js | |
| 214 | + | intercepts the submit and posts the same encoding to the same | |
| 215 | + | route, which makes the script an enhancement (async, inline | |
| 216 | + | status, no reload) rather than the only path. It had neither | |
| 217 | + | attribute before, so a no-JS submit did a GET to / and the | |
| 218 | + | address went nowhere. #} | |
| 219 | + | <form class="notify-form" id="notify-form" method="post" action="/notify" data-submit="onNotifySubmit"> | |
| 220 | + | {% if let Some(token) = csrf_token %} | |
| 221 | + | <input type="hidden" name="_csrf" value="{{ token }}"> | |
| 222 | + | {% endif %} | |
| 214 | 223 | <input type="email" name="email" placeholder="you@example.com" required class="notify-input" aria-label="Email address" autocomplete="email"> | |
| 215 | 224 | <button type="submit" class="btn-primary">Notify Me</button> | |
| 216 | 225 | </form> | |
| 217 | - | <p class="notify-status" id="notify-status"></p> | |
| 226 | + | {# The no-JS outcome, carried back on the redirect. The script | |
| 227 | + | writes its own text into this element instead. #} | |
| 228 | + | <p class="notify-status{% if let Some(ok) = notify_ok %}{% if ok %} success{% else %} error{% endif %}{% endif %}" id="notify-status">{% if let Some(ok) = notify_ok %}{% if ok %}You're on the list.{% else %}That address didn't look right.{% endif %}{% endif %}</p> | |
| 218 | 229 | </div> | |
| 219 | 230 | ||
| 220 | 231 | <div class="secondary-links"> |
| @@ -490,3 +490,82 @@ | |||
| 490 | 490 | ); | |
| 491 | 491 | } | |
| 492 | 492 | } | |
| 493 | + | ||
| 494 | + | // ── Landing notify-me capture ── | |
| 495 | + | ||
| 496 | + | /// The form works with JS off. | |
| 497 | + | /// | |
| 498 | + | /// It had no action and no method, so a browser without JS submitted a GET to | |
| 499 | + | /// `/` and the address was dropped silently. This is the only email capture on | |
| 500 | + | /// the landing page, so a silent loss is a lost signup nobody can count. | |
| 501 | + | #[tokio::test] | |
| 502 | + | async fn notify_form_without_js_stores_the_address() { | |
| 503 | + | let mut h = TestHarness::new().await; | |
| 504 | + | h.client.fetch_csrf_token().await; | |
| 505 | + | ||
| 506 | + | let resp = h | |
| 507 | + | .client | |
| 508 | + | .post_form("/notify", "email=nojs@example.com") | |
| 509 | + | .await; | |
| 510 | + | assert_eq!( | |
| 511 | + | resp.status, 303, | |
| 512 | + | "expected a redirect back to the landing page" | |
| 513 | + | ); | |
| 514 | + | let location = resp | |
| 515 | + | .headers | |
| 516 | + | .get("location") | |
| 517 | + | .and_then(|v| v.to_str().ok()) | |
| 518 | + | .unwrap_or_default(); | |
| 519 | + | assert!( | |
| 520 | + | location.starts_with("/?notify=ok"), | |
| 521 | + | "redirected to {location}" | |
| 522 | + | ); | |
| 523 | + | ||
| 524 | + | let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") | |
| 525 | + | .bind("nojs@example.com") | |
| 526 | + | .fetch_one(&h.db) | |
| 527 | + | .await | |
| 528 | + | .expect("count signups"); | |
| 529 | + | assert_eq!(stored, 1, "the address never reached storage"); | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | /// A rejected address comes back as a sentence on the page, not a 422. This is | |
| 533 | + | /// the last thing on the landing page and an error code is a worse outcome | |
| 534 | + | /// than being told the address looked wrong. | |
| 535 | + | #[tokio::test] | |
| 536 | + | async fn notify_form_rejects_a_bad_address_without_erroring() { | |
| 537 | + | let mut h = TestHarness::new().await; | |
| 538 | + | h.client.fetch_csrf_token().await; | |
| 539 | + | ||
| 540 | + | let resp = h.client.post_form("/notify", "email=not-an-address").await; | |
| 541 | + | assert_eq!(resp.status, 303); | |
| 542 | + | assert!( | |
| 543 | + | resp.headers | |
| 544 | + | .get("location") | |
| 545 | + | .and_then(|v| v.to_str().ok()) | |
| 546 | + | .unwrap_or_default() | |
| 547 | + | .starts_with("/?notify=invalid") | |
| 548 | + | ); | |
| 549 | + | ||
| 550 | + | let landing = h.client.get("/?notify=invalid").await; | |
| 551 | + | assert!( | |
| 552 | + | landing.text.contains("That address didn't look right."), | |
| 553 | + | "the landing page said nothing about the rejection" | |
| 554 | + | ); | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /// The success message renders for a no-JS visitor coming back off the | |
| 558 | + | /// redirect, and not on a plain page load. | |
| 559 | + | #[tokio::test] | |
| 560 | + | async fn notify_status_renders_only_when_the_redirect_says_so() { | |
| 561 | + | let mut h = TestHarness::new().await; | |
| 562 | + | ||
| 563 | + | let plain = h.client.get("/").await; | |
| 564 | + | assert!( | |
| 565 | + | !plain.text.contains("You're on the list."), | |
| 566 | + | "status shown on a plain load" | |
| 567 | + | ); | |
| 568 | + | ||
| 569 | + | let after = h.client.get("/?notify=ok").await; | |
| 570 | + | assert!(after.text.contains("You're on the list.")); | |
| 571 | + | } |
| @@ -52,7 +52,7 @@ | |||
| 52 | 52 | response::{IntoResponse, Response}, | |
| 53 | 53 | routing::{get, options}, | |
| 54 | 54 | }; | |
| 55 | - | use serde::{Deserialize, Serialize}; | |
| 55 | + | use serde::Serialize; | |
| 56 | 56 | use serde_json::json; | |
| 57 | 57 | use tower_governor::GovernorLayer; | |
| 58 | 58 | ||
| @@ -154,23 +154,6 @@ | |||
| 154 | 154 | Ok(Json(json!({ "data": data }))) | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | - | // ── Email signup (public, no auth) ── | |
| 158 | - | ||
| 159 | - | #[derive(Deserialize)] | |
| 160 | - | struct EmailSignupForm { | |
| 161 | - | email: String, | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | #[tracing::instrument(skip_all, name = "api::email_signup")] | |
| 165 | - | async fn email_signup( | |
| 166 | - | State(db): State<PgPool>, | |
| 167 | - | Json(form): Json<EmailSignupForm>, | |
| 168 | - | ) -> Result<impl IntoResponse> { | |
| 169 | - | let email = db::Email::new(&form.email)?; | |
| 170 | - | db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?; | |
| 171 | - | Ok(Json(json!({"success": true}))) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | 157 | /// Register all JSON API routes for projects, items, links, tags, and account management. | |
| 175 | 158 | /// | |
| 176 | 159 | /// Routes are split into three tiers with different rate limits: | |
| @@ -545,11 +528,6 @@ | |||
| 545 | 528 | .route("/api/domains/{id}", delete_csrf(domains::remove_domain)) | |
| 546 | 529 | // Invite codes | |
| 547 | 530 | .route("/api/invites/create", post_csrf(users::create_invite)) | |
| 548 | - | // Email signup (public, landing page notify-me) | |
| 549 | - | .route( | |
| 550 | - | "/api/email-signup", | |
| 551 | - | post_csrf_skip("pre-auth landing signup, no session", email_signup), | |
| 552 | - | ) | |
| 553 | 531 | .route_layer(GovernorLayer::new(write_rate_limit)); | |
| 554 | 532 | ||
| 555 | 533 | // Password/code-verifying TOTP mutations, strict auth-strength rate limit |
| @@ -65,6 +65,11 @@ | |||
| 65 | 65 | /// landing-flagged changelog post. `None` suppresses the line entirely | |
| 66 | 66 | /// (no placeholder), same honesty rule the runway disclosure uses. | |
| 67 | 67 | pub last_shipped: Option<LandingVelocity>, | |
| 68 | + | /// Result of a no-JS notify submission, carried back through the redirect: | |
| 69 | + | /// `Some(true)` subscribed, `Some(false)` the address was rejected, `None` | |
| 70 | + | /// a plain page load. The JS path renders its status inline instead and | |
| 71 | + | /// leaves this `None`. | |
| 72 | + | pub notify_ok: Option<bool>, | |
| 68 | 73 | } | |
| 69 | 74 | ||
| 70 | 75 | /// One-line "Last shipped" velocity signal for the landing page. |
| @@ -2,7 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use crate::extractors::ValidatedQuery; | |
| 4 | 4 | use axum::{ | |
| 5 | - | extract::State, | |
| 5 | + | Form, | |
| 6 | + | extract::{Query, State}, | |
| 6 | 7 | http::HeaderMap, | |
| 7 | 8 | response::{IntoResponse, Redirect, Response}, | |
| 8 | 9 | }; | |
| @@ -33,6 +34,16 @@ | |||
| 33 | 34 | /// If the Host header belongs to a verified custom domain, renders that user's | |
| 34 | 35 | /// profile instead (the fallback handler only catches paths that don't match | |
| 35 | 36 | /// any named route, so `/` needs to be handled here). | |
| 37 | + | /// Outcome of a no-JS notify submission, round-tripped through the redirect. | |
| 38 | + | /// | |
| 39 | + | /// The JS path renders its own status inline and never sets this; it exists so | |
| 40 | + | /// a visitor without JS gets told what happened instead of landing back on an | |
| 41 | + | /// apparently unchanged page. | |
| 42 | + | #[derive(Deserialize)] | |
| 43 | + | pub(super) struct IndexQuery { | |
| 44 | + | notify: Option<String>, | |
| 45 | + | } | |
| 46 | + | ||
| 36 | 47 | #[tracing::instrument(skip_all, name = "landing::index")] | |
| 37 | 48 | #[allow(clippy::too_many_arguments)] | |
| 38 | 49 | pub(super) async fn index( | |
| @@ -41,6 +52,7 @@ | |||
| 41 | 52 | State(integrations): State<Integrations>, | |
| 42 | 53 | State(config): State<Config>, | |
| 43 | 54 | State(billing): State<Billing>, | |
| 55 | + | Query(q): Query<IndexQuery>, | |
| 44 | 56 | headers: HeaderMap, | |
| 45 | 57 | session: Session, | |
| 46 | 58 | MaybeUserUnverified(maybe_user): MaybeUserUnverified, | |
| @@ -128,6 +140,11 @@ | |||
| 128 | 140 | tier_prices: billing.tier_prices.clone(), | |
| 129 | 141 | landing_carousel, | |
| 130 | 142 | last_shipped, | |
| 143 | + | notify_ok: match q.notify.as_deref() { | |
| 144 | + | Some("ok") => Some(true), | |
| 145 | + | Some("invalid") => Some(false), | |
| 146 | + | _ => None, | |
| 147 | + | }, | |
| 131 | 148 | } | |
| 132 | 149 | .into_response()) | |
| 133 | 150 | } | |
| @@ -488,6 +505,39 @@ | |||
| 488 | 505 | } | |
| 489 | 506 | } | |
| 490 | 507 | ||
| 508 | + | #[derive(Deserialize)] | |
| 509 | + | pub(super) struct NotifyForm { | |
| 510 | + | email: String, | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | /// POST /notify: the landing page's "notify me" capture. | |
| 514 | + | /// | |
| 515 | + | /// The form posts here directly, so it works with JS off. The page script | |
| 516 | + | /// intercepts the submit and posts the same form encoding to the same route, | |
| 517 | + | /// which makes it a genuine enhancement (async, inline status, no reload) | |
| 518 | + | /// rather than the only path. Before this the form had no action and no | |
| 519 | + | /// method, so a browser without JS submitted a GET to `/` and the address was | |
| 520 | + | /// dropped without a word to anyone. | |
| 521 | + | /// | |
| 522 | + | /// Storage is `email_signups`, which already existed for exactly this with a | |
| 523 | + | /// `source` column, duplicate collapsing, and an admin view at | |
| 524 | + | /// /admin/signups, so nobody has to be told where the addresses went. | |
| 525 | + | /// | |
| 526 | + | /// A rejected address redirects rather than erroring: this is the last thing | |
| 527 | + | /// on the landing page, and a 422 on a marketing form is a worse outcome than | |
| 528 | + | /// a sentence saying the address looked wrong. | |
| 529 | + | #[tracing::instrument(skip_all, name = "landing::notify")] | |
| 530 | + | pub(super) async fn notify( | |
| 531 | + | State(db): State<PgPool>, | |
| 532 | + | Form(form): Form<NotifyForm>, | |
| 533 | + | ) -> Result<impl IntoResponse> { | |
| 534 | + | let Ok(email) = db::Email::new(&form.email) else { | |
| 535 | + | return Ok(Redirect::to("/?notify=invalid#notify-form")); | |
| 536 | + | }; | |
| 537 | + | db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?; | |
| 538 | + | Ok(Redirect::to("/?notify=ok#notify-form")) | |
| 539 | + | } | |
| 540 | + | ||
| 491 | 541 | /// Render a dial value for an input box: no trailing zeros on a whole number, | |
| 492 | 542 | /// at most two decimals otherwise. `12.6`, `0.30` and `25` all read as typed. | |
| 493 | 543 | fn fmt_dial(v: f64) -> String { |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | AppState, Billing, | |
| 25 | 25 | auth::MaybeUserUnverified, | |
| 26 | 26 | constants, | |
| 27 | - | csrf::{CsrfRouter, post_csrf_skip, with_csrf_skip}, | |
| 27 | + | csrf::{CsrfRouter, post_csrf, post_csrf_skip, with_csrf_skip}, | |
| 28 | 28 | db, | |
| 29 | 29 | error::Result, | |
| 30 | 30 | helpers::get_csrf_token, | |
| @@ -99,7 +99,7 @@ | |||
| 99 | 99 | "join-wizard step 1: pre-auth signup", | |
| 100 | 100 | join_wizard::step_account_create, | |
| 101 | 101 | ) | |
| 102 | - | .layer(GovernorLayer::new(join_rate_limit)), | |
| 102 | + | .layer(GovernorLayer::new(join_rate_limit.clone())), | |
| 103 | 103 | ) | |
| 104 | 104 | .route( | |
| 105 | 105 | "/join/step/{step}", | |
| @@ -142,6 +142,12 @@ | |||
| 142 | 142 | .route_get("/team", get(landing::team_page)) | |
| 143 | 143 | .route_get("/policy", get(landing::policy_page)) | |
| 144 | 144 | .route_get("/fan-plus", get(landing::fan_plus_page)) | |
| 145 | + | // Landing "notify me". CSRF-protected like the other public forms, and | |
| 146 | + | // rate limited because it is unauthenticated and writes a row. | |
| 147 | + | .route( | |
| 148 | + | "/notify", | |
| 149 | + | post_csrf(landing::notify).layer(GovernorLayer::new(join_rate_limit.clone())), | |
| 150 | + | ) | |
| 145 | 151 | .route_get("/creators", get(creators_page)) | |
| 146 | 152 | .route_get("/docs", get(docs::docs_index)) | |
| 147 | 153 | .route_get("/docs/search.json", get(docs::docs_search_index)) |