Skip to main content

max / makenotwork

Harden server across 5 audit axes (ultra-fuzz Run #19 remediation) Drive every audit axis to A. Highlights: UX/theming: fix a real stored-XSS in custom pages — lightningcss does not escape `<`, so `content:"</style><script>"` reached the page; escape `<` to `\3c ` in the sanitized sheet. Drop non-hex base intents in theme-common. Security: replace the replayable HMAC password-reset link with a single-use DB token (migration 140), mirroring login_tokens; derive lockout `just_locked` from a CTE so re-locks notify; add an `aud` claim to SyncKit JWTs. Performance: contain CPU-layer panics so a crafted upload can't kill the scan pool (fail closed to HeldForReview); route >8 GB uploads to review instead of stranding them Pending; unify spool tempfile cleanup; background the admin shutdown fan-out; continuously delete expired tower_sessions rows; rate-limit the /health endpoints; prune password-reset tokens. Payments: resolve the recurring epoch-period bug structurally — the five subscription/billing writers now take the raw Stripe period and own the end>0 filter + conversion, so a handler cannot construct an epoch DateTime. Move webhook dedup to after successful processing (v1 and v2) so a crash can't strand an event. Ticket checkout amount mismatches; pass base price to promos. Storage: make custom-page publish transactional; mark draft previews no-store; correct the presign doc and clamp presign expiry; bind the draft-cleanup interval; add a (item_id, created_at) version index (migration 141). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 20:36 UTC
Commit: af2c94fc239e78b91681c5a8c553972c654b0c85
Parent: f3a0f76
37 files changed, +1105 insertions, -592 deletions
@@ -0,0 +1,25 @@
1 + -- Single-use, DB-backed password reset tokens.
2 + --
3 + -- The reset flow was a stateless HMAC bound to the user's current password
4 + -- hash: valid for the whole expiry window and replayable any number of times
5 + -- until the password actually changed. Anyone who obtained the URL before the
6 + -- user submitted it (browser history, referer, proxy/CDN logs) could reuse it.
7 + --
8 + -- This mirrors `login_tokens` exactly: the emailed token is random, only its
9 + -- SHA-256 hash is stored, and consumption is a single `UPDATE ... WHERE
10 + -- used_at IS NULL` so a replay (or a race) can never succeed twice.
11 + CREATE TABLE IF NOT EXISTS password_reset_tokens (
12 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14 + token_hash VARCHAR(64) NOT NULL,
15 + expires_at TIMESTAMPTZ NOT NULL,
16 + used_at TIMESTAMPTZ,
17 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
18 + );
19 +
20 + CREATE UNIQUE INDEX IF NOT EXISTS idx_password_reset_tokens_hash
21 + ON password_reset_tokens(token_hash);
22 + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
23 + ON password_reset_tokens(user_id);
24 + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires
25 + ON password_reset_tokens(expires_at);
@@ -0,0 +1,7 @@
1 + -- Covering index for the version listing's `WHERE item_id = $1 ORDER BY
2 + -- created_at DESC` access pattern (db::versions::get_versions / get_versions_
3 + -- paginated). The existing `idx_versions_item_id` is single-column, so the
4 + -- order-by fell back to an in-memory sort. Bounded by the per-item version cap,
5 + -- so this is a minor win, but it removes the sort on the item-page render path.
6 + CREATE INDEX IF NOT EXISTS idx_versions_item_created
7 + ON versions(item_id, created_at DESC);
@@ -194,7 +194,25 @@ fn scope_and_sanitize(
194 194 }
195 195 out.push_str(&reduced_motion);
196 196
197 - (out, rejections)
197 + (escape_lt_for_style_element(&out), rejections)
198 + }
199 +
200 + /// Make the sheet safe to inline raw inside an HTML `<style>` element.
201 + ///
202 + /// `<style>` is a raw-text element: the HTML tokenizer ends it at the first
203 + /// `</style` regardless of CSS syntax, so a creator's `content: "</style>..."`
204 + /// would otherwise break out and inject markup. lightningcss faithfully
205 + /// preserves the literal `<` inside CSS string tokens, so we escape every `<`
206 + /// in the final output to its CSS hex escape `\3c ` (the trailing space
207 + /// terminates the hex digits). `<` is not valid CSS syntax outside string/url
208 + /// tokens, so this rewrite is lossless where it matters and never produces a
209 + /// literal `<` for the HTML parser to act on. `</style>`, `<!--`, and `<script`
210 + /// all require a `<`, so neutralizing it closes the whole class.
211 + fn escape_lt_for_style_element(css: &str) -> String {
212 + if !css.contains('<') {
213 + return css.to_string();
214 + }
215 + css.replace('<', "\\3c ")
198 216 }
199 217
200 218 fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
@@ -658,6 +676,33 @@ mod tests {
658 676 let out = scoped("@media screen { body { background: red } }");
659 677 assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body"));
660 678 }
679 +
680 + #[test]
681 + fn style_tag_breakout_via_content_string_is_neutralized() {
682 + // The sanitized output is injected raw into `<style>{{ css|safe }}</style>`
683 + // (templates/custom/*.html). The most direct stored-XSS attempt is a
684 + // declaration whose value is a string closing the tag and opening a
685 + // script. The serializer must never emit a literal `</style>` (or a bare
686 + // `<script>`) — `<` inside a CSS string token has to come back escaped.
687 + for css in [
688 + r#".x { content: "</style><script>alert(1)</script>" }"#,
689 + r#".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }"#,
690 + r#".x { content: "\3c /style\3e <script>" }"#,
691 + // url() is dropped (external) but the string form must also be safe.
692 + r#".x { background: url("</style><script>x</script>") }"#,
693 + ] {
694 + let out = scoped(css);
695 + let lower = out.to_lowercase();
696 + assert!(
697 + !lower.contains("</style>"),
698 + "literal </style> escaped the block for input `{css}`: {out}"
699 + );
700 + assert!(
701 + !lower.contains("<script>"),
702 + "literal <script> escaped the block for input `{css}`: {out}"
703 + );
704 + }
705 + }
661 706 }
662 707
663 708 #[cfg(test)]
@@ -13,8 +13,14 @@
13 13 //! - [`css_sanitizer`] — a `lightningcss` pass that scopes all selectors to the
14 14 //! canvas, filters at-rules, validates `url()`, and strips system-slot hiding.
15 15 //!
16 - //! The render path (Phase 2) reads pre-sanitized output; sanitization is a
17 - //! write-time cost paid on save.
16 + //! Sanitization is **render-time**, not write-time: the editor stores the
17 + //! creator's *raw* HTML/CSS, and `sanitize_page` runs on every render of the
18 + //! public page (on the cookieless, `default-src 'none'` host). The save path
19 + //! runs the sanitizer only to *count* what would be stripped, for the editor's
20 + //! blocked-references panel — it does not persist sanitized output. So the XSS
21 + //! boundary is the render call, not the database: never inline stored
22 + //! `custom_html`/`custom_css` anywhere without running them through this module
23 + //! first.
18 24
19 25 mod css_sanitizer;
20 26 mod html_sanitizer;
@@ -28,10 +28,23 @@ pub async fn increment_failed_login(
28 28 max_attempts: i32,
29 29 lockout_minutes: i64,
30 30 ) -> Result<FailedLoginResult> {
31 + // Capture the pre-update row under a row lock so `just_locked` can be
32 + // derived from the *same* predicate the CASE uses to set the lock. Reading
33 + // the old values in a CTE avoids the trap where a bare `RETURNING` column is
34 + // the post-update value: the previous code computed `just_locked` as
35 + // `(failed_login_attempts = $2)` against the new count, which only matched
36 + // the exact-threshold attempt and read false on a *re-lock* after an expired
37 + // window (counter already >= threshold) — so the lockout notification was
38 + // silently skipped on every re-lock. Now `just_locked` is exactly "the lock
39 + // was (re)set on this call".
31 40 let row: (i32, bool) = sqlx::query_as(
32 41 r#"
33 - UPDATE users
34 - SET failed_login_attempts = failed_login_attempts + 1,
42 + WITH prev AS (
43 + SELECT failed_login_attempts AS old_attempts, locked_until AS old_lock
44 + FROM users WHERE id = $1 FOR UPDATE
45 + )
46 + UPDATE users u
47 + SET failed_login_attempts = u.failed_login_attempts + 1,
35 48 last_failed_login_at = NOW(),
36 49 locked_until = CASE
37 50 -- Set/refresh the lock only when reaching the threshold AND not
@@ -41,13 +54,17 @@ pub async fn increment_failed_login(
41 54 -- locked with one wrong password every <LOCKOUT_MINUTES. Now an
42 55 -- active lock simply runs out; a fresh failure after it expires
43 56 -- re-locks (the counter isn't reset until a successful login).
44 - WHEN failed_login_attempts + 1 >= $2
45 - AND (locked_until IS NULL OR locked_until <= NOW())
57 + WHEN u.failed_login_attempts + 1 >= $2
58 + AND (u.locked_until IS NULL OR u.locked_until <= NOW())
46 59 THEN NOW() + ($3 || ' minutes')::interval
47 - ELSE locked_until
60 + ELSE u.locked_until
48 61 END
49 - WHERE id = $1
50 - RETURNING failed_login_attempts, (failed_login_attempts = $2) AS just_locked
62 + FROM prev
63 + WHERE u.id = $1
64 + RETURNING
65 + u.failed_login_attempts,
66 + (prev.old_attempts + 1 >= $2
67 + AND (prev.old_lock IS NULL OR prev.old_lock <= NOW())) AS just_locked
51 68 "#,
52 69 )
53 70 .bind(user_id)
@@ -127,3 +144,114 @@ pub async fn consume_login_token(
127 144 Ok(token)
128 145 }
129 146
147 + /// Create a single-use password reset token (mirrors [`create_login_token`]).
148 + #[tracing::instrument(skip_all)]
149 + pub async fn create_password_reset_token(
150 + pool: &PgPool,
151 + user_id: UserId,
152 + token_hash: &str,
153 + expires_at: DateTime<Utc>,
154 + ) -> Result<()> {
155 + sqlx::query(
156 + r#"
157 + INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
158 + VALUES ($1, $2, $3)
159 + "#,
160 + )
161 + .bind(user_id)
162 + .bind(token_hash)
163 + .bind(expires_at)
164 + .execute(pool)
165 + .await?;
166 +
167 + Ok(())
168 + }
169 +
170 + /// Check whether a password reset token is currently valid (unused, unexpired),
171 + /// returning the user it belongs to — without consuming it. Used to decide
172 + /// whether to render the reset form. The token is only spent on submit via
173 + /// [`consume_password_reset_token`].
174 + #[tracing::instrument(skip_all)]
175 + pub async fn peek_password_reset_token(
176 + pool: &PgPool,
177 + token_hash: &str,
178 + ) -> Result<Option<UserId>> {
179 + let user_id = sqlx::query_scalar::<_, UserId>(
180 + r#"
181 + SELECT user_id FROM password_reset_tokens
182 + WHERE token_hash = $1
183 + AND used_at IS NULL
184 + AND expires_at > NOW()
185 + "#,
186 + )
187 + .bind(token_hash)
188 + .fetch_optional(pool)
189 + .await?;
190 +
191 + Ok(user_id)
192 + }
193 +
194 + /// Atomically consume a password reset token, returning the user it belongs to.
195 + ///
196 + /// Returns `None` if the token was already used, expired, or does not exist.
197 + /// The single UPDATE with `used_at IS NULL` in the WHERE clause guarantees a
198 + /// replay (or a concurrent double-submit) can never succeed twice.
199 + #[tracing::instrument(skip_all)]
200 + pub async fn consume_password_reset_token(
201 + pool: &PgPool,
202 + token_hash: &str,
203 + ) -> Result<Option<UserId>> {
204 + let user_id = sqlx::query_scalar::<_, UserId>(
205 + r#"
206 + UPDATE password_reset_tokens
207 + SET used_at = NOW()
208 + WHERE token_hash = $1
209 + AND used_at IS NULL
210 + AND expires_at > NOW()
211 + RETURNING user_id
212 + "#,
213 + )
214 + .bind(token_hash)
215 + .fetch_optional(pool)
216 + .await?;
217 +
218 + Ok(user_id)
219 + }
220 +
221 + /// Delete consumed or expired password reset tokens (housekeeping so the table
222 + /// doesn't accumulate dead rows). Keeps recently-used rows briefly for audit.
223 + #[tracing::instrument(skip_all)]
224 + pub async fn prune_password_reset_tokens(pool: &PgPool) -> Result<u64> {
225 + let result = sqlx::query(
226 + r#"
227 + DELETE FROM password_reset_tokens
228 + WHERE expires_at < NOW() - interval '7 days'
229 + OR used_at < NOW() - interval '7 days'
230 + "#,
231 + )
232 + .execute(pool)
233 + .await?;
234 +
235 + Ok(result.rows_affected())
236 + }
237 +
238 + /// Invalidate every outstanding reset token for a user. Called after a
239 + /// successful reset so any other links mailed to the same account (e.g. a
240 + /// double request) are dead, matching the old hash-binding's "completing a
241 + /// reset kills all outstanding links" behavior.
242 + #[tracing::instrument(skip_all)]
243 + pub async fn invalidate_password_reset_tokens(pool: &PgPool, user_id: UserId) -> Result<()> {
244 + sqlx::query(
245 + r#"
246 + UPDATE password_reset_tokens
247 + SET used_at = NOW()
248 + WHERE user_id = $1 AND used_at IS NULL
249 + "#,
250 + )
251 + .bind(user_id)
252 + .execute(pool)
253 + .await?;
254 +
255 + Ok(())
256 + }
257 +
@@ -99,21 +99,30 @@ pub async fn upsert_draft(
99 99 }
100 100
101 101 /// Delete a page's draft (after a successful save).
102 - pub async fn delete_draft(pool: &PgPool, owner_id: UserId, page_kind: &str, page_id: Uuid) -> Result<()> {
102 + pub async fn delete_draft<'e>(
103 + executor: impl sqlx::PgExecutor<'e>,
104 + owner_id: UserId,
105 + page_kind: &str,
106 + page_id: Uuid,
107 + ) -> Result<()> {
103 108 sqlx::query("DELETE FROM custom_page_drafts WHERE owner_id = $1 AND page_kind = $2 AND page_id = $3")
104 109 .bind(owner_id)
105 110 .bind(page_kind)
106 111 .bind(page_id)
107 - .execute(pool)
112 + .execute(executor)
108 113 .await?;
109 114 Ok(())
110 115 }
111 116
112 117 /// Delete drafts older than the given number of days (scheduled cleanup).
113 118 pub async fn delete_drafts_older_than(pool: &PgPool, days: i64) -> Result<u64> {
114 - let result = sqlx::query(&format!(
115 - "DELETE FROM custom_page_drafts WHERE created_at < now() - interval '{days} days'"
116 - ))
119 + // Bind the interval rather than interpolating it: `days` is job-supplied
120 + // today, but a bound `$1 * interval '1 day'` keeps this the one query in the
121 + // module that can't drift into string interpolation.
122 + let result = sqlx::query(
123 + "DELETE FROM custom_page_drafts WHERE created_at < now() - ($1 * interval '1 day')",
124 + )
125 + .bind(days)
117 126 .execute(pool)
118 127 .await?;
119 128 Ok(result.rows_affected())
@@ -157,8 +157,8 @@ pub async fn update_project(
157 157 /// `custom_pages_updated_at` (which also invalidates the edge caches of every
158 158 /// item page that inherits this project's CSS), and bump the cache generation.
159 159 /// Scoped to the owner so a non-owner can't write through this path.
160 - pub async fn update_project_custom_page(
161 - pool: &PgPool,
160 + pub async fn update_project_custom_page<'e>(
161 + executor: impl sqlx::PgExecutor<'e>,
162 162 id: ProjectId,
163 163 user_id: UserId,
164 164 custom_html: &str,
@@ -179,7 +179,7 @@ pub async fn update_project_custom_page(
179 179 .bind(user_id)
180 180 .bind(custom_html)
181 181 .bind(custom_css)
182 - .fetch_one(pool)
182 + .fetch_one(executor)
183 183 .await?;
184 184 Ok(project)
185 185 }
@@ -34,6 +34,18 @@
34 34 /// unless the new status is itself `canceled` (so a duplicate delete stays
35 35 /// idempotent). Reactivation never flows through here — it happens at checkout
36 36 /// via each family's `create_*`/`ON CONFLICT DO UPDATE` path.
37 + ///
38 + /// `period` is the **raw Stripe period** `(current_period_start,
39 + /// current_period_end)` as Unix seconds — exactly what `SubscriptionView::
40 + /// current_period()` / an invoice yields. The funnel owns the only legal
41 + /// conversion: a `None`, or a non-positive `end` (thin/zero webhook shapes),
42 + /// writes no period at all (the `COALESCE` keeps the existing value). This is
43 + /// the structural close to the recurring epoch-period bug (CHRONIC C): handlers
44 + /// never construct a `DateTime` for the period, so there is no way to hand the
45 + /// writer a `Some((epoch, epoch))` that would stamp 1970-01-01 onto an active
46 + /// row and trip the access gate's `current_period_end > NOW()` check. The
47 + /// `end > 0` guard lives here, once, instead of being copied (and forgotten) at
48 + /// each call site.
37 49 macro_rules! define_stripe_subscription_writer {
38 50 ($fn:ident, $table:literal, $row:ty) => {
39 51 #[doc = concat!("Guarded Stripe state write for `", $table, "`. See ")]
@@ -43,14 +55,14 @@ macro_rules! define_stripe_subscription_writer {
43 55 executor: impl sqlx::PgExecutor<'e>,
44 56 stripe_sub_id: &str,
45 57 status: Option<$crate::db::SubscriptionStatus>,
46 - period: Option<(
47 - ::chrono::DateTime<::chrono::Utc>,
48 - ::chrono::DateTime<::chrono::Utc>,
49 - )>,
58 + period: Option<(i64, i64)>,
50 59 ) -> $crate::error::Result<Option<$row>> {
51 60 let (period_start, period_end) = match period {
52 - Some((start, end)) => (Some(start), Some(end)),
53 - None => (None, None),
61 + Some((start, end)) if end > 0 => (
62 + ::chrono::DateTime::from_timestamp(start, 0),
63 + ::chrono::DateTime::from_timestamp(end, 0),
64 + ),
65 + _ => (None, None),
54 66 };
55 67 let row = sqlx::query_as::<_, $row>(concat!(
56 68 "UPDATE ", $table, " SET ",
@@ -108,13 +108,22 @@ pub async fn create_app_sync_subscription(
108 108 /// guard refuses to revive a canceled app-sub via an out-of-order webhook;
109 109 /// reactivation happens at checkout through `create_app_sync_subscription`'s
110 110 /// DO UPDATE path. Consistent with every other subscription family's setter.
111 + /// `current_period_end_unix` is the **raw Stripe** period end (Unix seconds), as
112 + /// `SubscriptionView::current_period()` / an invoice yields. The conversion is
113 + /// sealed here: a `None` or a non-positive value writes no period (the COALESCE
114 + /// keeps the existing value), so a thin/zero webhook can never stamp a 1970
115 + /// period onto an active row. Handlers pass the raw value and cannot construct a
116 + /// `DateTime`, closing the synckit arm of the epoch-period bug class (CHRONIC C).
111 117 #[tracing::instrument(skip_all)]
112 118 pub async fn update_app_sync_subscription_status(
113 119 pool: &PgPool,
114 120 stripe_subscription_id: &str,
115 121 status: &str,
116 - current_period_end: Option<DateTime<Utc>>,
122 + current_period_end_unix: Option<i64>,
117 123 ) -> Result<()> {
124 + let current_period_end = current_period_end_unix
125 + .filter(|&end| end > 0)
126 + .and_then(|end| DateTime::<Utc>::from_timestamp(end, 0));
118 127 sqlx::query(
119 128 r#"
120 129 UPDATE app_sync_subscriptions
@@ -158,15 +158,24 @@ pub async fn update_knobs(
158 158 /// `activate_billing`, not this path. See `crate::db::subscription_writer` for
159 159 /// the cross-family rationale.
160 160 #[tracing::instrument(skip_all)]
161 + /// `period` is the **raw Stripe** `(current_period_start, current_period_end)`
162 + /// as Unix seconds. The conversion is sealed here: a `None` or a non-positive
163 + /// `end` writes no period (the `COALESCE` keeps the existing value), so a thin/
164 + /// zero webhook can never stamp a 1970 period onto an active app. Handlers pass
165 + /// the raw value and cannot hand in a `DateTime` — the synckit-billing arm of
166 + /// the epoch-period bug class (CHRONIC C).
161 167 pub async fn apply_billing_update<'e>(
162 168 executor: impl sqlx::PgExecutor<'e>,
163 169 app_id: SyncAppId,
164 170 status: Option<&str>,
165 - period: Option<(DateTime<Utc>, DateTime<Utc>)>,
171 + period: Option<(i64, i64)>,
166 172 ) -> Result<bool> {
167 173 let (period_start, period_end) = match period {
168 - Some((start, end)) => (Some(start), Some(end)),
169 - None => (None, None),
174 + Some((start, end)) if end > 0 => (
175 + DateTime::<Utc>::from_timestamp(start, 0),
176 + DateTime::<Utc>::from_timestamp(end, 0),
177 + ),
178 + _ => (None, None),
170 179 };
171 180 let result = sqlx::query(
172 181 r#"
@@ -104,8 +104,8 @@ pub async fn update_user_profile(
104 104 /// Store a user's custom profile-page source (the original, pre-sanitization),
105 105 /// stamp `custom_pages_updated_at`, and bump the cache generation. The source is
106 106 /// re-sanitized on render; see [`crate::custom_pages`].
107 - pub async fn update_user_custom_page(
108 - pool: &PgPool,
107 + pub async fn update_user_custom_page<'e>(
108 + executor: impl sqlx::PgExecutor<'e>,
109 109 id: UserId,
110 110 custom_html: &str,
111 111 custom_css: &str,
@@ -124,7 +124,7 @@ pub async fn update_user_custom_page(
124 124 .bind(id)
125 125 .bind(custom_html)
126 126 .bind(custom_css)
127 - .fetch_one(pool)
127 + .fetch_one(executor)
128 128 .await?;
129 129 Ok(user)
130 130 }
@@ -20,28 +20,40 @@ pub struct DbWebhookEvent {
20 20 pub created_at: DateTime<Utc>,
21 21 }
22 22
23 - /// Try to mark a webhook event ID as processed. Returns `true` if this is the
24 - /// first time the event has been seen, `false` if it was already processed.
25 - /// Uses INSERT ... ON CONFLICT to atomically deduplicate.
23 + /// Whether a webhook event ID has already been processed.
24 + ///
25 + /// This is a read used to short-circuit a redelivered event. The matching write
26 + /// ([`mark_event_processed`]) happens only *after* the handler succeeds, so a
27 + /// crash mid-processing can never leave a "processed" marker with no work done —
28 + /// the event simply gets reprocessed on redelivery. (The handlers are
29 + /// idempotent, so a reprocess is safe; this read just avoids the redundant
30 + /// work.) The old `try_mark_event_processed` marked *before* processing and so
31 + /// could strand an event if the process died in the gap before the retry row
32 + /// was written — that ordering no longer exists.
26 33 #[tracing::instrument(skip_all)]
27 - pub async fn try_mark_event_processed(pool: &PgPool, event_id: &str) -> Result<bool> {
28 - let result = sqlx::query(
29 - "INSERT INTO processed_webhook_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING",
34 + pub async fn is_event_processed(pool: &PgPool, event_id: &str) -> Result<bool> {
35 + let exists = sqlx::query_scalar::<_, bool>(
36 + "SELECT EXISTS(SELECT 1 FROM processed_webhook_events WHERE event_id = $1)",
30 37 )
31 38 .bind(event_id)
32 - .execute(pool)
39 + .fetch_one(pool)
33 40 .await?;
34 41
35 - Ok(result.rows_affected() > 0)
42 + Ok(exists)
36 43 }
37 44
38 - /// Remove a webhook event from the processed set, allowing Stripe to retry delivery.
45 + /// Record a webhook event ID as processed. Call this only *after* the event's
46 + /// side effects are durably committed. `ON CONFLICT DO NOTHING` makes it
47 + /// idempotent, so a concurrent duplicate or a redelivery is harmless.
39 48 #[tracing::instrument(skip_all)]
40 - pub async fn unmark_event_processed(pool: &PgPool, event_id: &str) -> Result<()> {
41 - sqlx::query("DELETE FROM processed_webhook_events WHERE event_id = $1")
42 - .bind(event_id)
43 - .execute(pool)
44 - .await?;
49 + pub async fn mark_event_processed(pool: &PgPool, event_id: &str) -> Result<()> {
50 + sqlx::query(
51 + "INSERT INTO processed_webhook_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING",
52 + )
53 + .bind(event_id)
54 + .execute(pool)
55 + .await?;
56 +
45 57 Ok(())
46 58 }
47 59
@@ -54,68 +54,19 @@ impl std::str::FromStr for UnsubscribeAction {
54 54 }
55 55 }
56 56
57 - /// Generate HMAC-signed password reset URL
58 - pub fn generate_password_reset_url(
59 - host_url: &str,
60 - user_id: UserId,
61 - password_hash: &str,
62 - secret: &str,
63 - ) -> String {
64 - use hmac::{Hmac, Mac};
65 - use sha2::Sha256;
66 -
67 - let expires = chrono::Utc::now().timestamp() + constants::PASSWORD_RESET_EXPIRY_SECS;
68 - // Use full password hash to bind token to current password
69 - // This invalidates the link if password changes
70 - let message = format!("reset:{}:{}:{}", user_id, expires, password_hash);
71 -
72 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
73 - // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
74 - .expect("HMAC-SHA256 accepts any key length");
75 - mac.update(message.as_bytes());
76 - let signature = hex::encode(mac.finalize().into_bytes());
77 -
78 - format!(
79 - "{}/reset-password?user={}&expires={}&sig={}",
80 - host_url, user_id, expires, signature
81 - )
57 + /// Generate a single-use password reset token.
58 + ///
59 + /// Returns `(token, token_hash)`: the random `token` goes in the emailed URL,
60 + /// the SHA-256 `token_hash` is stored in `password_reset_tokens` and matched on
61 + /// use. Single-use (the row is consumed atomically on submit) replaces the old
62 + /// replayable HMAC link. Mirrors [`generate_login_token`].
63 + pub fn generate_password_reset_token() -> (String, String) {
64 + generate_opaque_token()
82 65 }
83 66
84 - /// Verify HMAC-signed password reset URL
85 - pub fn verify_password_reset_signature(
86 - user_id: UserId,
87 - expires: i64,
88 - password_hash: &str,
89 - signature: &str,
90 - secret: &str,
91 - ) -> bool {
92 - use hmac::{Hmac, Mac};
93 - use sha2::Sha256;
94 -
95 - // Check expiration
96 - if expires < chrono::Utc::now().timestamp() {
97 - return false;
98 - }
99 -
100 - // Use full password hash to match generation
101 - let message = format!("reset:{}:{}:{}", user_id, expires, password_hash);
102 -
103 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
104 - // SAFETY: HMAC-SHA256 accepts any key length; new_from_slice cannot fail here
105 - .expect("HMAC-SHA256 accepts any key length");
106 - mac.update(message.as_bytes());
107 -
108 - // Constant-time comparison
109 - let expected = hex::encode(mac.finalize().into_bytes());
110 - if expected.len() != signature.len() {
111 - return false;
112 - }
113 -
114 - let mut result = 0u8;
115 - for (a, b) in expected.bytes().zip(signature.bytes()) {
116 - result |= a ^ b;
117 - }
118 - result == 0
67 + /// Generate the password reset link URL for a freshly-minted token.
68 + pub fn generate_reset_link_url(host_url: &str, token: &str) -> String {
69 + format!("{}/reset-password?token={}", host_url, token)
119 70 }
120 71
121 72 /// Generate email verification URL
@@ -146,14 +97,19 @@ pub fn generate_verification_url(
146 97 /// Generate a one-time login token
147 98 /// Returns (token, token_hash) where token is sent to user and token_hash is stored in DB
148 99 pub fn generate_login_token() -> (String, String) {
149 - use sha2::{Sha256, Digest};
100 + generate_opaque_token()
101 + }
102 +
103 + /// Mint a random opaque token and its storage hash: `(token, sha256(token))`.
104 + /// The 256-bit token is sent to the user; only the hash is persisted, so a DB
105 + /// read can't reconstruct a usable link.
106 + fn generate_opaque_token() -> (String, String) {
107 + use sha2::{Digest, Sha256};
150 108
151 - // Generate random token
152 109 let mut token_bytes = [0u8; 32];
153 110 rand::RngCore::fill_bytes(&mut rand::rng(), &mut token_bytes);
154 111 let token = hex::encode(token_bytes);
155 112
156 - // Hash the token for storage
157 113 let mut hasher = Sha256::new();
158 114 hasher.update(token.as_bytes());
159 115 let token_hash = hex::encode(hasher.finalize());
@@ -161,6 +117,14 @@ pub fn generate_login_token() -> (String, String) {
161 117 (token, token_hash)
162 118 }
163 119
120 + /// Hash a reset/login token the same way it is stored, for lookup.
121 + pub fn hash_opaque_token(token: &str) -> String {
122 + use sha2::{Digest, Sha256};
123 + let mut hasher = Sha256::new();
124 + hasher.update(token.as_bytes());
125 + hex::encode(hasher.finalize())
126 + }
127 +
164 128 /// Generate login link URL
165 129 pub fn generate_login_link_url(host_url: &str, token: &str) -> String {
166 130 format!("{}/login-link?token={}", host_url, token)
@@ -390,78 +354,28 @@ mod tests {
390 354 use super::*;
391 355
392 356 #[test]
393 - fn test_password_reset_url_generation_and_verification() {
394 - let host = "https://makenot.work";
395 - let user_id = UserId::new();
396 - let password_hash = "argon2$abc123def456789012345678901234567890";
397 - let secret = "test-secret-key";
398 -
399 - let url = generate_password_reset_url(host, user_id, password_hash, secret);
400 - assert!(url.contains("/reset-password"));
401 - assert!(url.contains(&user_id.to_string()));
402 -
403 - // Extract params
404 - let url_parsed: url::Url = url.parse().unwrap();
405 - let expires: i64 = url_parsed
406 - .query_pairs()
407 - .find(|(k, _)| k == "expires")
408 - .unwrap()
409 - .1
410 - .parse()
411 - .unwrap();
412 - let sig = url_parsed
413 - .query_pairs()
414 - .find(|(k, _)| k == "sig")
415 - .unwrap()
416 - .1
417 - .to_string();
418 -
419 - assert!(verify_password_reset_signature(
420 - user_id,
421 - expires,
422 - password_hash,
423 - &sig,
424 - secret
425 - ));
426 - }
427 -
428 - #[test]
429 - fn password_reset_rejects_wrong_secret() {
430 - let user_id = UserId::new();
431 - let hash = "argon2$abc123";
432 - let secret = "real-secret";
433 -
434 - let url = generate_password_reset_url("https://example.com", user_id, hash, secret);
435 - let parsed: url::Url = url.parse().unwrap();
436 - let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
437 - let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
438 -
439 - assert!(!verify_password_reset_signature(user_id, expires, hash, &sig, "wrong-secret"));
357 + fn password_reset_token_round_trip() {
358 + let (token, token_hash) = generate_password_reset_token();
359 + // Token and hash differ; both are 64 hex chars (32 bytes).
360 + assert_ne!(token, token_hash);
361 + assert_eq!(token.len(), 64);
362 + assert_eq!(token_hash.len(), 64);
363 + // Hashing the emitted token reproduces the stored hash (lookup path).
364 + assert_eq!(hash_opaque_token(&token), token_hash);
440 365 }
441 366
442 367 #[test]
443 - fn password_reset_rejects_expired_token() {
444 - let user_id = UserId::new();
445 - let hash = "argon2$abc123";
446 - let secret = "test-secret";
447 -
448 - // Use an already-expired timestamp
449 - let expired = chrono::Utc::now().timestamp() - 1;
450 - assert!(!verify_password_reset_signature(user_id, expired, hash, "deadbeef", secret));
368 + fn password_reset_token_unique_each_call() {
369 + let (t1, h1) = generate_password_reset_token();
370 + let (t2, h2) = generate_password_reset_token();
371 + assert_ne!(t1, t2);
372 + assert_ne!(h1, h2);
451 373 }
452 374
453 375 #[test]
454 - fn password_reset_rejects_wrong_password_hash() {
455 - let user_id = UserId::new();
456 - let secret = "test-secret";
457 -
458 - let url = generate_password_reset_url("https://example.com", user_id, "hash-v1", secret);
459 - let parsed: url::Url = url.parse().unwrap();
460 - let expires: i64 = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.parse().unwrap();
461 - let sig = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
462 -
463 - // Signature was bound to "hash-v1", should fail with "hash-v2" (password changed)
464 - assert!(!verify_password_reset_signature(user_id, expires, "hash-v2", &sig, secret));
376 + fn reset_link_url_format() {
377 + let url = generate_reset_link_url("https://makenot.work", "abc123");
378 + assert_eq!(url, "https://makenot.work/reset-password?token=abc123");
465 379 }
466 380
467 381 #[test]
@@ -694,17 +608,6 @@ mod tests {
694 608 }
695 609
696 610 #[test]
697 - fn password_reset_url_expires_matches_constant() {
698 - let url = generate_password_reset_url(
699 - "https://example.com",
700 - UserId::new(),
701 - "argon2$dummy",
702 - "secret",
703 - );
704 - assert_within_expiry_window(extract_expires(&url), constants::PASSWORD_RESET_EXPIRY_SECS);
705 - }
706 -
707 - #[test]
708 611 fn verification_url_expires_matches_constant() {
709 612 let url = generate_verification_url(
710 613 "https://example.com",
@@ -777,24 +680,4 @@ mod tests {
777 680 assert!(verify_email_signature(user_id, expires_future, email, &sig, secret));
778 681 }
779 682
780 - #[test]
781 - fn verify_password_reset_signature_rejects_expired() {
782 - // Same boundary check for password reset path (L96 in this file).
783 - let user_id = UserId::new();
784 - let password_hash = "argon2$dummy";
785 - let secret = "secret";
786 - let expires_past = chrono::Utc::now().timestamp() - 60;
787 -
788 - use hmac::{Hmac, Mac};
789 - use sha2::Sha256;
790 - let message = format!("reset:{}:{}:{}", user_id, expires_past, password_hash);
791 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
792 - mac.update(message.as_bytes());
793 - let sig_past = hex::encode(mac.finalize().into_bytes());
794 -
795 - assert!(
796 - !verify_password_reset_signature(user_id, expires_past, password_hash, &sig_past, secret),
797 - "must reject expired password reset signature"
798 - );
799 - }
800 683 }
@@ -118,6 +118,25 @@ async fn main() {
118 118 .await
119 119 .expect("Failed to migrate session store");
120 120
121 + // Continuously delete expired rows from the tower-sessions table. A session
122 + // row is minted on every anonymous page render, so without this the table
123 + // grows without bound under bot/crawler traffic and is read on every
124 + // request. (The app's own `user_sessions` table is pruned by the daily
125 + // scheduler; this covers the tower-sessions store, which the scheduler does
126 + // not own.) Runs hourly on a background task.
127 + {
128 + use tower_sessions::ExpiredDeletion;
129 + let deletion_store = session_store.clone();
130 + tokio::task::spawn(async move {
131 + if let Err(e) = deletion_store
132 + .continuously_delete_expired(tokio::time::Duration::from_secs(3600))
133 + .await
134 + {
135 + tracing::error!(error = ?e, "tower-sessions expired-deletion task exited");
136 + }
137 + });
138 + }
139 +
121 140 // In release mode, require HTTPS for session cookies (override with INSECURE_COOKIES=1 for staging)
122 141 let secure_cookies =
123 142 !cfg!(debug_assertions) && std::env::var("INSECURE_COOKIES").unwrap_or_default() != "1";
@@ -169,27 +169,58 @@ async fn admin_shutdown_notice(
169 169 }
170 170
171 171 let all_users = db::users::get_all_user_emails(&state.db).await?;
172 - let mut sent = 0u32;
173 - let mut failed = 0u32;
174 -
175 - for (email, display_name) in &all_users {
176 - if let Err(e) = state.email
177 - .send_shutdown_notice(email, display_name.as_deref(), shutdown_date)
178 - .await
179 - {
180 - tracing::error!(error = ?e, email = %email, "failed to send shutdown notice");
181 - failed += 1;
182 - } else {
183 - sent += 1;
172 + let count = all_users.len();
173 +
174 + // Fan out on a background task with a bounded JoinSet (mirrors the broadcast
175 + // path). The previous version sent one email at a time, inline, scaling the
176 + // request's latency with the entire user base and tying up a handler for the
177 + // whole send. Return immediately; the task logs its own totals.
178 + let email_client = state.email.clone();
179 + let shutdown_date = shutdown_date.to_string();
180 + tokio::spawn(async move {
181 + let mut set = tokio::task::JoinSet::new();
182 + let chunk_delay =
183 + std::time::Duration::from_millis(crate::constants::BROADCAST_CHUNK_DELAY_MS);
184 + let sent = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
185 + let failed = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
186 +
187 + for (email, display_name) in all_users {
188 + if set.len() >= crate::constants::BROADCAST_PARALLELISM {
189 + let _ = set.join_next().await;
190 + }
191 + let email_client = email_client.clone();
192 + let shutdown_date = shutdown_date.clone();
193 + let sent = std::sync::Arc::clone(&sent);
194 + let failed = std::sync::Arc::clone(&failed);
195 + set.spawn(async move {
196 + use std::sync::atomic::Ordering;
197 + if let Err(e) = email_client
198 + .send_shutdown_notice(&email, display_name.as_deref(), &shutdown_date)
199 + .await
200 + {
201 + tracing::error!(error = ?e, email = %email, "failed to send shutdown notice");
202 + failed.fetch_add(1, Ordering::Relaxed);
203 + } else {
204 + sent.fetch_add(1, Ordering::Relaxed);
205 + }
206 + });
207 + tokio::time::sleep(chunk_delay).await;
184 208 }
185 - }
209 + while set.join_next().await.is_some() {}
186 210
187 - tracing::info!(sent = sent, failed = failed, shutdown_date = %shutdown_date, "shutdown notices sent");
211 + use std::sync::atomic::Ordering;
212 + tracing::info!(
213 + sent = sent.load(Ordering::Relaxed),
214 + failed = failed.load(Ordering::Relaxed),
215 + shutdown_date = %shutdown_date,
216 + "shutdown notices sent"
217 + );
218 + });
188 219
189 220 Ok((
190 221 axum::http::StatusCode::OK,
191 222 [("HX-Redirect", "/admin/users")],
192 - format!("Sent {} shutdown notices ({} failed)", sent, failed),
223 + format!("Sending shutdown notices to {count} users."),
193 224 ).into_response())
194 225 }
195 226
@@ -256,13 +256,17 @@ async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result<
256 256 }
257 257 }
258 258
259 + // Publish the page and clear its draft in one transaction: a crash between
260 + // the two previously left the page published but the stale draft alive, so
261 + // the editor reopened showing pre-save content over the saved page.
262 + let mut tx = state.db.begin().await?;
259 263 match target.page_kind {
260 264 KIND_USER => {
261 - db::users::update_user_custom_page(&state.db, target.owner_id, &form.custom_html, &form.custom_css).await?;
265 + db::users::update_user_custom_page(&mut *tx, target.owner_id, &form.custom_html, &form.custom_css).await?;
262 266 }
263 267 _ => {
264 268 db::projects::update_project_custom_page(
265 - &state.db,
269 + &mut *tx,
266 270 db::ProjectId::from_uuid(target.page_id),
267 271 target.owner_id,
268 272 &form.custom_html,
@@ -272,7 +276,8 @@ async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result<
272 276 }
273 277 }
274 278 // Promote the draft: clear it so the next visit reflects the published page.
275 - db::custom_pages::delete_draft(&state.db, target.owner_id, target.page_kind, target.page_id).await?;
279 + db::custom_pages::delete_draft(&mut *tx, target.owner_id, target.page_kind, target.page_id).await?;
280 + tx.commit().await?;
276 281
277 282 Ok(status_html(true, "Saved and published."))
278 283 }
@@ -11,8 +11,8 @@ use tower_sessions::Session;
11 11
12 12 use crate::{
13 13 auth::hash_password,
14 - db::{self, UserId},
15 - error::{AppError, Result},
14 + db::{self},
15 + error::Result,
16 16 helpers::{get_csrf_token, is_htmx_request},
17 17 templates::*,
18 18 AppState,
@@ -67,13 +67,23 @@ pub(super) async fn forgot_password_handler(
67 67 }
68 68 };
69 69
70 - // Generate password reset URL
71 - let reset_url = crate::email::generate_password_reset_url(
72 - &state.config.host_url,
73 - user.id,
74 - &user.password_hash,
75 - &state.config.signing_secret,
76 - );
70 + // Mint a single-use reset token, persist its hash, and email the link. The
71 + // raw token lives only in the URL; only its hash is stored, and the row is
72 + // consumed atomically on submit so the link cannot be replayed.
73 + let (token, token_hash) = crate::email::generate_password_reset_token();
74 + let expires_at = chrono::Utc::now()
75 + + chrono::Duration::seconds(crate::constants::PASSWORD_RESET_EXPIRY_SECS);
76 + if let Err(e) =
77 + db::auth::create_password_reset_token(&state.db, user.id, &token_hash, expires_at).await
78 + {
79 + tracing::error!(error = ?e, "failed to persist password reset token");
80 + // Still return the generic success response to avoid enumeration.
81 + if is_htmx {
82 + return Ok(success_alert.into_response());
83 + }
84 + return Ok(Redirect::to("/login").into_response());
85 + }
86 + let reset_url = crate::email::generate_reset_link_url(&state.config.host_url, &token);
77 87
78 88 // Send email
79 89 if let Err(e) = state
@@ -97,12 +107,13 @@ pub(super) async fn forgot_password_handler(
97 107 /// Query parameters for the password reset link.
98 108 #[derive(Debug, Deserialize)]
99 109 pub struct ResetPasswordQuery {
100 - pub user: Option<String>,
101 - pub expires: Option<i64>,
102 - pub sig: Option<String>,
110 + pub token: Option<String>,
103 111 }
104 112
105 - /// Render the password reset form after validating the signed link.
113 + /// Render the password reset form after checking the token is still valid.
114 + ///
115 + /// This only *peeks* — the token is spent on submit, not on viewing the form,
116 + /// so a prefetch (link scanner, browser preview) can't burn the user's link.
106 117 #[tracing::instrument(skip_all, name = "email_actions::reset_password_page")]
107 118 pub(super) async fn reset_password_page(
108 119 State(state): State<AppState>,
@@ -111,66 +122,27 @@ pub(super) async fn reset_password_page(
111 122 ) -> impl IntoResponse {
112 123 let csrf_token = get_csrf_token(&session).await;
113 124
114 - // Validate all required parameters are present
115 - let (user_id_str, expires, sig) = match (&query.user, query.expires, &query.sig) {
116 - (Some(u), Some(e), Some(s)) => (u.clone(), e, s.clone()),
117 - _ => {
118 - return ResetPasswordTemplate {
119 - csrf_token,
120 - valid: false,
121 - user_id: String::new(),
122 - expires: String::new(),
123 - sig: String::new(),
124 - error: None,
125 - };
126 - }
127 - };
128 -
129 - // Parse user ID
130 - let user_id: UserId = match user_id_str.parse() {
131 - Ok(id) => id,
132 - Err(_) => {
133 - return ResetPasswordTemplate {
134 - csrf_token,
135 - valid: false,
136 - user_id: String::new(),
137 - expires: String::new(),
138 - sig: String::new(),
139 - error: None,
140 - };
141 - }
125 + let invalid = |csrf_token| ResetPasswordTemplate {
126 + csrf_token,
127 + valid: false,
128 + token: String::new(),
129 + error: None,
142 130 };
143 131
144 - // Get user to verify signature against their password hash
145 - let user = match db::users::get_user_by_id(&state.db, user_id).await {
146 - Ok(Some(u)) => u,
147 - _ => {
148 - return ResetPasswordTemplate {
149 - csrf_token,
150 - valid: false,
151 - user_id: String::new(),
152 - expires: String::new(),
153 - sig: String::new(),
154 - error: None,
155 - };
156 - }
132 + let Some(token) = query.token.filter(|t| !t.is_empty()) else {
133 + return invalid(csrf_token);
157 134 };
158 135
159 - // Verify HMAC signature
160 - let valid = crate::email::verify_password_reset_signature(
161 - user_id,
162 - expires,
163 - &user.password_hash,
164 - &sig,
165 - &state.config.signing_secret,
136 + let token_hash = crate::email::hash_opaque_token(&token);
137 + let valid = matches!(
138 + db::auth::peek_password_reset_token(&state.db, &token_hash).await,
139 + Ok(Some(_))
166 140 );
167 141
168 142 ResetPasswordTemplate {
169 143 csrf_token,
170 144 valid,
171 - user_id: user_id_str,
172 - expires: expires.to_string(),
173 - sig,
145 + token: if valid { token } else { String::new() },
174 146 error: None,
175 147 }
176 148 }
@@ -178,9 +150,7 @@ pub(super) async fn reset_password_page(
178 150 /// Form input for submitting a new password via the reset flow.
179 151 #[derive(Debug, Deserialize)]
180 152 pub struct ResetPasswordForm {
181 - pub user: String,
182 - pub expires: String,
183 - pub sig: String,
153 + pub token: String,
184 154 pub password: String,
185 155 pub password_confirm: String,
186 156 }
@@ -200,13 +170,12 @@ pub(super) async fn reset_password_handler(
200 170 } else {
201 171 get_csrf_token(&session).await
202 172 };
203 - let recall_user = form.user.clone();
204 - let recall_expires = form.expires.clone();
205 - let recall_sig = form.sig.clone();
173 + let recall_token = form.token.clone();
206 174
207 - // Helper to return error. Non-HTMX path re-renders the reset form with
208 - // the signed link fields intact + the error inlined so the user can fix
209 - // their input without losing the email-delivered token.
175 + // Helper to return error. Non-HTMX path re-renders the reset form with the
176 + // token field intact + the error inlined so the user can fix their input
177 + // without losing the email-delivered token. These errors fire *before* the
178 + // token is consumed, so retrying still works.
210 179 let return_error = |msg: &str| -> Result<Response> {
211 180 if is_htmx {
212 181 Ok(AlertTemplate::new("error", msg).into_response())
@@ -214,9 +183,7 @@ pub(super) async fn reset_password_handler(
214 183 Ok(ResetPasswordTemplate {
215 184 csrf_token: recall_csrf_token.clone(),
216 185 valid: true,
217 - user_id: recall_user.clone(),
218 - expires: recall_expires.clone(),
219 - sig: recall_sig.clone(),
186 + token: recall_token.clone(),
220 187 error: Some(msg.to_string()),
221 188 }.into_response())
222 189 }
@@ -236,31 +203,31 @@ pub(super) async fn reset_password_handler(
236 203 return return_error("Password must be 128 characters or fewer");
237 204 }
238 205
239 - // Parse user ID and expires
240 - let user_id: UserId = form
241 - .user
242 - .parse()
243 - .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;
244 - let expires: i64 = form
245 - .expires
246 - .parse()
247 - .map_err(|_| AppError::BadRequest("Invalid expiry".to_string()))?;
248 -
249 - // Get user
250 - let user = db::users::get_user_by_id(&state.db, user_id)
251 - .await?
252 - .ok_or_else(|| AppError::BadRequest("Invalid reset link".to_string()))?;
253 -
254 - // Verify signature
255 - if !crate::email::verify_password_reset_signature(
256 - user_id,
257 - expires,
258 - &user.password_hash,
259 - &form.sig,
260 - &state.config.signing_secret,
261 - ) {
262 - return return_error("Reset link has expired or is invalid");
263 - }
206 + // Atomically consume the single-use token. A replay, an expired link, or a
207 + // forged token all fail here; the UPDATE...WHERE used_at IS NULL guarantees
208 + // a concurrent double-submit can never both succeed. Done only after the
209 + // cheap form validations so a mistyped confirmation doesn't burn the link.
210 + let token_hash = crate::email::hash_opaque_token(&form.token);
211 + let Some(user_id) =
212 + db::auth::consume_password_reset_token(&state.db, &token_hash).await?
213 + else {
214 + // Token is gone — re-rendering the form would be a dead end, so show the
215 + // expired/invalid state with a path to request a fresh link.
216 + if is_htmx {
217 + return Ok(AlertTemplate::new(
218 + "error",
219 + "This reset link has expired or has already been used. Please request a new one.",
220 + )
221 + .into_response());
222 + }
223 + return Ok(ResetPasswordTemplate {
224 + csrf_token: recall_csrf_token,
225 + valid: false,
226 + token: String::new(),
227 + error: None,
228 + }
229 + .into_response());
230 + };
264 231
265 232 // Check for breached password (advisory only, don't block)
266 233 if let Some(count) = crate::auth::check_password_breach(&form.password).await {
@@ -281,6 +248,10 @@ pub(super) async fn reset_password_handler(
281 248 let new_password_hash = hash_password(&form.password)?;
282 249 db::users::update_user_password(&state.db, user_id, &new_password_hash).await?;
283 250
251 + // Kill any other outstanding reset links for this user (e.g. a double
252 + // request): completing one reset invalidates them all.
253 + db::auth::invalidate_password_reset_tokens(&state.db, user_id).await?;
254 +
284 255 // Invalidate all sessions so stolen sessions can't survive a password reset
285 256 let revoked = db::sessions::delete_all_sessions_for_user(&state.db, user_id).await?;
286 257 for sid in &revoked {
@@ -53,8 +53,13 @@ pub fn public_routes() -> CsrfRouter<AppState> {
53 53 .route_get("/library/tabs/collections", get(landing::library_tab_collections))
54 54 .route_get("/library/tabs/contacts", get(landing::library_tab_contacts))
55 55 .route_get("/library/tabs/communities", get(landing::library_tab_communities))
56 - .route_get("/health", get(health::health))
57 - .route_get("/api/health", get(health::health_json))
56 + // Both health endpoints run ~8 COUNT(*) queries + an S3 connectivity
57 + // round-trip per hit, unauthenticated — a strictly more expensive
58 + // DoS-amplification surface than discover, which is already throttled.
59 + // The API-read limit (10/s sustained, burst 60) is generous enough for
60 + // any real uptime monitor while capping a flood.
61 + .route_get("/health", get(health::health).layer(GovernorLayer { config: search_rate_limit.clone() }))
62 + .route_get("/api/health", get(health::health_json).layer(GovernorLayer { config: search_rate_limit.clone() }))
58 63 .route_get("/robots.txt", get(sitemap::robots_txt))
59 64 .route_get("/sitemap.xml", get(sitemap::sitemap_xml))
60 65 // NOTE: GET /login is registered in auth_routes() alongside POST /login
@@ -107,7 +107,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
107 107 db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, Some(user.id), code_str).await?
108 108 {
109 109 use db::promo_codes::{PromoApplication, PromoIneligible};
110 - match db::promo_codes::apply_promo_to_item(&validated, item_uuid, item.project_id, item.price_cents)? {
110 + match db::promo_codes::apply_promo_to_item(&validated, item_uuid, item.project_id, base_price_cents)? {
111 111 PromoApplication::Apply(price) => final_price_cents = price,
112 112 PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => {
113 113 return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
@@ -3,7 +3,7 @@
3 3 use crate::{
4 4 db::{self, SubscriptionStatus},
5 5 error::{Result, ResultExt},
6 - helpers::{self, spawn_email, stripe_timestamp},
6 + helpers::{self, spawn_email},
7 7 AppState,
8 8 };
9 9
@@ -30,9 +30,8 @@ pub(super) async fn handle_invoice_payment_succeeded(
30 30 .context("fetch app sync subscription by stripe id")?
31 31 .is_some()
32 32 {
33 - let period_end = stripe_timestamp(invoice.period_end);
34 33 db::synckit::update_app_sync_subscription_status(
35 - &state.db, &stripe_sub_id, "active", Some(period_end),
34 + &state.db, &stripe_sub_id, "active", Some(invoice.period_end),
36 35 )
37 36 .await
38 37 .context("refresh app sync subscription period")?;
@@ -52,13 +51,11 @@ pub(super) async fn handle_invoice_payment_succeeded(
52 51
53 52 // SyncKit v2 developer subscription? Identified by the local sync_apps row.
54 53 if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
55 - let period_start = stripe_timestamp(invoice.period_start);
56 - let period_end = stripe_timestamp(invoice.period_end);
57 54 let mut tx = state.db.begin().await.context("begin synckit invoice.paid transaction")?;
58 55 // One guarded write for status + period; only reset usage if the app was
59 56 // live (a canceled app is refused, so a stray invoice.paid can't refresh
60 - // period or usage on it).
61 - let applied = db::synckit_billing::apply_billing_update(&mut *tx, app_id, Some("active"), Some((period_start, period_end))).await.context("synckit apply_billing_update")?;
57 + // period or usage on it). Raw Stripe period to the sealed writer.
58 + let applied = db::synckit_billing::apply_billing_update(&mut *tx, app_id, Some("active"), Some((invoice.period_start, invoice.period_end))).await.context("synckit apply_billing_update")?;
62 59 if applied {
63 60 db::synckit_billing::reset_period_usage(&mut *tx, app_id).await.context("synckit reset_period_usage")?;
64 61 }
@@ -74,10 +71,9 @@ pub(super) async fn handle_invoice_payment_succeeded(
74 71
75 72 // Check if this is a Fan+ subscription
76 73 if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? {
77 - // Refresh period (guarded: a canceled Fan+ sub is left untouched).
78 - let period_start = stripe_timestamp(invoice.period_start);
79 - let period_end = stripe_timestamp(invoice.period_end);
80 - db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((period_start, period_end))).await.context("refresh fan+ period")?;
74 + // Refresh period (guarded: a canceled Fan+ sub is left untouched). Raw
75 + // Stripe period to the sealed writer, which drops a non-positive end.
76 + db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh fan+ period")?;
81 77
82 78 // On renewal, generate a $5 platform-wide promo code and email it
83 79 if is_renewal {
@@ -154,9 +150,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
154 150
155 151 // Check if this is a creator tier subscription
156 152 if let Some(_ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
157 - let period_start = stripe_timestamp(invoice.period_start);
158 - let period_end = stripe_timestamp(invoice.period_end);
159 - db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((period_start, period_end))).await.context("refresh creator sub period")?;
153 + db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh creator sub period")?;
160 154
161 155 if let Err(e) = db::subscriptions::log_subscription_event(
162 156 &state.db, None, event_id, "invoice.payment_succeeded.creator_tier",
@@ -168,9 +162,7 @@ pub(super) async fn handle_invoice_payment_succeeded(
168 162 }
169 163
170 164 // Refresh period for fan subscriptions (guarded: canceled rows untouched).
171 - let period_start = stripe_timestamp(invoice.period_start);
172 - let period_end = stripe_timestamp(invoice.period_end);
173 - db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((period_start, period_end))).await.context("refresh subscription period")?;
165 + db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, None, Some((invoice.period_start, invoice.period_end))).await.context("refresh subscription period")?;
174 166
175 167 // Send renewal email only for renewals (not the first invoice)
176 168 let db_sub = db::subscriptions::get_subscription_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch subscription by stripe id")?;