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