max / makenotwork
4 files changed,
+81 insertions,
-62 deletions
| @@ -225,47 +225,63 @@ pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) - | |||
| 225 | 225 | .await?; | |
| 226 | 226 | let copy_title = format!("Copy of {}", &source.title); | |
| 227 | 227 | let copy_title: String = copy_title.chars().take(200).collect(); | |
| 228 | - | let mut slug = crate::helpers::slugify(©_title); | |
| 229 | - | if super::item_slug_exists(&mut *tx, source.project_id, &slug).await? { | |
| 230 | - | let base = slug.clone(); | |
| 231 | - | let mut counter = 2u32; | |
| 232 | - | loop { | |
| 233 | - | if counter > 100 { | |
| 234 | - | return Err(crate::error::AppError::BadRequest( | |
| 235 | - | "Too many copies with similar names. Rename an existing copy first.".to_string(), | |
| 236 | - | )); | |
| 228 | + | let base_slug = crate::helpers::slugify(©_title).to_string(); | |
| 229 | + | ||
| 230 | + | // Step 1: Clone item row with a collision-safe slug. A pre-check | |
| 231 | + | // (`item_slug_exists`) followed by an INSERT races two concurrent | |
| 232 | + | // duplicates onto the same slug — the loser hit a raw 23505 -> 500. Instead | |
| 233 | + | // attempt the INSERT under a savepoint and retry with a numeric suffix on a | |
| 234 | + | // unique violation, so the DB's unique index is the arbiter, not a | |
| 235 | + | // check-then-write window. Mirrors `helpers::insert_with_unique_slug`, | |
| 236 | + | // adapted to run inside this transaction: a bare 23505 aborts the tx, so | |
| 237 | + | // each attempt is wrapped in a savepoint and `ROLLBACK TO SAVEPOINT` | |
| 238 | + | // recovers the tx for the next try. | |
| 239 | + | let mut suffix = 1u32; | |
| 240 | + | let new_item = loop { | |
| 241 | + | let candidate = if suffix == 1 { | |
| 242 | + | base_slug.clone() | |
| 243 | + | } else { | |
| 244 | + | format!("{base_slug}-{suffix}") | |
| 245 | + | }; | |
| 246 | + | sqlx::query("SAVEPOINT dup_item_slug").execute(&mut *tx).await?; | |
| 247 | + | let res = sqlx::query_as::<_, DbItem>( | |
| 248 | + | r#" | |
| 249 | + | INSERT INTO items ( | |
| 250 | + | project_id, title, description, price_cents, item_type, thumbnail_url, | |
| 251 | + | sort_order, body, word_count, reading_time_minutes, duration_seconds, | |
| 252 | + | episode_number, enable_license_keys, default_max_activations, | |
| 253 | + | pwyw_enabled, pwyw_min_cents, is_public, slug | |
| 254 | + | ) | |
| 255 | + | SELECT | |
| 256 | + | project_id, LEFT('Copy of ' || title, 200), description, price_cents, | |
| 257 | + | item_type, thumbnail_url, sort_order, body, word_count, | |
| 258 | + | reading_time_minutes, duration_seconds, episode_number, | |
| 259 | + | enable_license_keys, default_max_activations, pwyw_enabled, | |
| 260 | + | pwyw_min_cents, false, $2 | |
| 261 | + | FROM items WHERE id = $1 | |
| 262 | + | RETURNING * | |
| 263 | + | "#, | |
| 264 | + | ) | |
| 265 | + | .bind(source_id) | |
| 266 | + | .bind(&candidate) | |
| 267 | + | .fetch_one(&mut *tx) | |
| 268 | + | .await; | |
| 269 | + | match res { | |
| 270 | + | Ok(item) => { | |
| 271 | + | sqlx::query("RELEASE SAVEPOINT dup_item_slug").execute(&mut *tx).await?; | |
| 272 | + | break item; | |
| 237 | 273 | } | |
| 238 | - | slug = crate::db::validated_types::Slug::from_trusted(format!("{}-{}", base, counter)); | |
| 239 | - | if !super::item_slug_exists(&mut *tx, source.project_id, &slug).await? { | |
| 240 | - | break; | |
| 274 | + | Err(e) => { | |
| 275 | + | sqlx::query("ROLLBACK TO SAVEPOINT dup_item_slug").execute(&mut *tx).await?; | |
| 276 | + | let app_err = crate::error::AppError::from(e); | |
| 277 | + | if crate::helpers::is_unique_violation(&app_err) && suffix < 100 { | |
| 278 | + | suffix += 1; | |
| 279 | + | continue; | |
| 280 | + | } | |
| 281 | + | return Err(app_err); | |
| 241 | 282 | } | |
| 242 | - | counter += 1; | |
| 243 | 283 | } | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | // Step 1: Clone item row | |
| 247 | - | let new_item = sqlx::query_as::<_, DbItem>( | |
| 248 | - | r#" | |
| 249 | - | INSERT INTO items ( | |
| 250 | - | project_id, title, description, price_cents, item_type, thumbnail_url, | |
| 251 | - | sort_order, body, word_count, reading_time_minutes, duration_seconds, | |
| 252 | - | episode_number, enable_license_keys, default_max_activations, | |
| 253 | - | pwyw_enabled, pwyw_min_cents, is_public, slug | |
| 254 | - | ) | |
| 255 | - | SELECT | |
| 256 | - | project_id, LEFT('Copy of ' || title, 200), description, price_cents, | |
| 257 | - | item_type, thumbnail_url, sort_order, body, word_count, | |
| 258 | - | reading_time_minutes, duration_seconds, episode_number, | |
| 259 | - | enable_license_keys, default_max_activations, pwyw_enabled, | |
| 260 | - | pwyw_min_cents, false, $2 | |
| 261 | - | FROM items WHERE id = $1 | |
| 262 | - | RETURNING * | |
| 263 | - | "#, | |
| 264 | - | ) | |
| 265 | - | .bind(source_id) | |
| 266 | - | .bind(&slug) | |
| 267 | - | .fetch_one(&mut *tx) | |
| 268 | - | .await?; | |
| 284 | + | }; | |
| 269 | 285 | ||
| 270 | 286 | // Step 2: Copy tags | |
| 271 | 287 | sqlx::query( |
| @@ -102,23 +102,6 @@ pub async fn create_item( | |||
| 102 | 102 | .await | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | - | /// Check whether a slug already exists for a given project. | |
| 106 | - | pub(super) async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>( | |
| 107 | - | executor: E, | |
| 108 | - | project_id: ProjectId, | |
| 109 | - | slug: &super::validated_types::Slug, | |
| 110 | - | ) -> Result<bool> { | |
| 111 | - | let exists: bool = sqlx::query_scalar( | |
| 112 | - | "SELECT EXISTS(SELECT 1 FROM items WHERE project_id = $1 AND slug = $2)", | |
| 113 | - | ) | |
| 114 | - | .bind(project_id) | |
| 115 | - | .bind(slug) | |
| 116 | - | .fetch_one(executor) | |
| 117 | - | .await?; | |
| 118 | - | ||
| 119 | - | Ok(exists) | |
| 120 | - | } | |
| 121 | - | ||
| 122 | 105 | /// Fetch an item by primary key. Returns `None` if not found. | |
| 123 | 106 | #[tracing::instrument(skip_all)] | |
| 124 | 107 | pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>> { |
| @@ -20,13 +20,22 @@ pub async fn begin_key_rotation( | |||
| 20 | 20 | new_encrypted_key: &str, | |
| 21 | 21 | expected_key_version: i32, | |
| 22 | 22 | ) -> Result<std::result::Result<crate::db::models::DbSyncKeyRotation, &'static str>> { | |
| 23 | - | // Verify key_version matches | |
| 23 | + | // The whole begin is a check-then-insert: without serialization two devices | |
| 24 | + | // can both observe "no existing rotation" and both INSERT, racing on the | |
| 25 | + | // unique constraint (one 500s, or two rotations exist). Run it in a | |
| 26 | + | // transaction and take a row lock on the `sync_keys` row up front — | |
| 27 | + | // `FOR UPDATE` serializes every concurrent begin for the same (app, user), | |
| 28 | + | // so the existing-rotation check and the insert are atomic. (Compare | |
| 29 | + | // `complete_key_rotation`, which already locks.) | |
| 30 | + | let mut tx = pool.begin().await?; | |
| 31 | + | ||
| 32 | + | // Verify key_version matches, holding the key row for the rest of the tx. | |
| 24 | 33 | let key_row: Option<(i32, i32)> = sqlx::query_as( | |
| 25 | - | "SELECT key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2", | |
| 34 | + | "SELECT key_version, key_id FROM sync_keys WHERE app_id = $1 AND user_id = $2 FOR UPDATE", | |
| 26 | 35 | ) | |
| 27 | 36 | .bind(app_id) | |
| 28 | 37 | .bind(user_id) | |
| 29 | - | .fetch_optional(pool) | |
| 38 | + | .fetch_optional(&mut *tx) | |
| 30 | 39 | .await?; | |
| 31 | 40 | ||
| 32 | 41 | let Some((current_version, current_key_id)) = key_row else { | |
| @@ -37,13 +46,13 @@ pub async fn begin_key_rotation( | |||
| 37 | 46 | return Ok(Err("key version mismatch")); | |
| 38 | 47 | } | |
| 39 | 48 | ||
| 40 | - | // Check for existing rotation | |
| 49 | + | // Check for existing rotation (serialized by the lock above). | |
| 41 | 50 | let existing = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>( | |
| 42 | 51 | "SELECT * FROM sync_key_rotations WHERE app_id = $1 AND user_id = $2", | |
| 43 | 52 | ) | |
| 44 | 53 | .bind(app_id) | |
| 45 | 54 | .bind(user_id) | |
| 46 | - | .fetch_optional(pool) | |
| 55 | + | .fetch_optional(&mut *tx) | |
| 47 | 56 | .await?; | |
| 48 | 57 | ||
| 49 | 58 | if let Some(rotation) = existing { | |
| @@ -60,7 +69,7 @@ pub async fn begin_key_rotation( | |||
| 60 | 69 | ) | |
| 61 | 70 | .bind(app_id) | |
| 62 | 71 | .bind(user_id) | |
| 63 | - | .fetch_one(pool) | |
| 72 | + | .fetch_one(&mut *tx) | |
| 64 | 73 | .await?; | |
| 65 | 74 | ||
| 66 | 75 | let new_key_id = current_key_id + 1; | |
| @@ -79,9 +88,10 @@ pub async fn begin_key_rotation( | |||
| 79 | 88 | .bind(current_version) | |
| 80 | 89 | .bind(new_key_id) | |
| 81 | 90 | .bind(target_seq) | |
| 82 | - | .fetch_one(pool) | |
| 91 | + | .fetch_one(&mut *tx) | |
| 83 | 92 | .await?; | |
| 84 | 93 | ||
| 94 | + | tx.commit().await?; | |
| 85 | 95 | Ok(Ok(rotation)) | |
| 86 | 96 | } | |
| 87 | 97 |
| @@ -213,6 +213,16 @@ pub(super) async fn create_guest_checkout( | |||
| 213 | 213 | }, | |
| 214 | 214 | ).await { | |
| 215 | 215 | Ok(_) => {} | |
| 216 | + | // 23505 backstop. Note: the pending-checkout partial unique index is | |
| 217 | + | // `(buyer_id, item_id) WHERE status = 'pending'`, and a guest row always | |
| 218 | + | // has `buyer_id = NULL` (NULLS DISTINCT), so this branch does NOT dedup | |
| 219 | + | // rapid guest re-submits the way it does for the authenticated path — | |
| 220 | + | // guests have no stable identity at creation time (guest_email arrives | |
| 221 | + | // later, from the Stripe webhook). Guest checkout flooding is instead | |
| 222 | + | // bounded by the endpoint's per-IP rate limit (GUEST_CHECKOUT_RATE_LIMIT_* | |
| 223 | + | // in api/mod.rs). This branch therefore only fires on the effectively | |
| 224 | + | // impossible stripe_checkout_session_id collision; true per-guest dedup | |
| 225 | + | // would need a client-supplied idempotency key (a protocol change). | |
| 216 | 226 | Err(AppError::Database(sqlx::Error::Database(ref db_err))) | |
| 217 | 227 | if db_err.code().as_deref() == Some("23505") => | |
| 218 | 228 | { |