max / makenotwork
5 files changed,
+230 insertions,
-11 deletions
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT id FROM items WHERE id = $1 FOR UPDATE", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Uuid" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | false | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "1276f7a168ba0de05ec546b9e5544ab18f446c46b65fe715a55d537bfad14d3c" | |
| 22 | + | } |
| @@ -0,0 +1,70 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT\n transaction_id AS \"transaction_id!: TransactionId\",\n item_id AS \"item_id!: ItemId\",\n title AS \"title!\",\n creator AS \"creator!\",\n item_type AS \"item_type!: super::ItemType\",\n purchased_at AS \"purchased_at!: chrono::DateTime<chrono::Utc>\",\n is_free AS \"is_free!\",\n license_key_code AS \"license_key_code?: KeyCode\",\n has_new_version AS \"has_new_version!\"\n FROM (\n SELECT DISTINCT ON (p.item_id)\n p.transaction_id,\n p.item_id,\n i.title,\n u.username as creator,\n i.item_type,\n p.purchased_at,\n -- Badge from what the buyer actually paid, not the item's current\n -- price: a later re-price to $0 must not retroactively badge a paid\n -- purchase \"Free\" (nor vice-versa). The purchases view carries the\n -- transaction's own amount_cents.\n (p.amount_cents = 0) as is_free,\n lk.key_code as license_key_code,\n (vc.total_versions > 0 AND vc.total_versions > COALESCE(dc.downloaded_count, 0)) as has_new_version\n FROM purchases p\n JOIN items i ON p.item_id = i.id\n JOIN projects proj ON i.project_id = proj.id\n JOIN users u ON proj.user_id = u.id\n LEFT JOIN license_keys lk ON lk.item_id = p.item_id AND lk.owner_id = p.buyer_id AND lk.revoked_at IS NULL\n LEFT JOIN LATERAL (\n SELECT COUNT(*) AS total_versions\n FROM versions v\n WHERE v.item_id = i.id AND v.s3_key IS NOT NULL\n ) vc ON true\n LEFT JOIN LATERAL (\n SELECT COUNT(*) AS downloaded_count\n FROM user_downloads ud\n WHERE ud.user_id = p.buyer_id AND ud.item_id = i.id\n ) dc ON true\n WHERE p.buyer_id = $1\n ORDER BY p.item_id, p.purchased_at DESC\n ) deduped\n ORDER BY purchased_at DESC\n LIMIT 20\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "transaction_id!: TransactionId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "item_id!: ItemId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "title!", | |
| 19 | + | "type_info": "Varchar" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "creator!", | |
| 24 | + | "type_info": "Varchar" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "item_type!: super::ItemType", | |
| 29 | + | "type_info": "Varchar" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "purchased_at!: chrono::DateTime<chrono::Utc>", | |
| 34 | + | "type_info": "Timestamptz" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "is_free!", | |
| 39 | + | "type_info": "Bool" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "license_key_code?: KeyCode", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "has_new_version!", | |
| 49 | + | "type_info": "Bool" | |
| 50 | + | } | |
| 51 | + | ], | |
| 52 | + | "parameters": { | |
| 53 | + | "Left": [ | |
| 54 | + | "Uuid" | |
| 55 | + | ] | |
| 56 | + | }, | |
| 57 | + | "nullable": [ | |
| 58 | + | true, | |
| 59 | + | true, | |
| 60 | + | false, | |
| 61 | + | false, | |
| 62 | + | false, | |
| 63 | + | true, | |
| 64 | + | null, | |
| 65 | + | false, | |
| 66 | + | null | |
| 67 | + | ] | |
| 68 | + | }, | |
| 69 | + | "hash": "ae71cb5e4a10f885299bb2ffdb18f48f731c1442e2f728bba01094e29b729aa5" | |
| 70 | + | } |
| @@ -0,0 +1,73 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations)\n VALUES ($1, $2, NULL, $3, $4)\n RETURNING id AS \"id: LicenseKeyId\", item_id AS \"item_id: ItemId\",\n owner_id AS \"owner_id: UserId\", transaction_id AS \"transaction_id: TransactionId\",\n key_code AS \"key_code: KeyCode\", max_activations, activation_count,\n revoked_at AS \"revoked_at: chrono::DateTime<chrono::Utc>\",\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: LicenseKeyId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "item_id: ItemId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "owner_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "transaction_id: TransactionId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "key_code: KeyCode", | |
| 29 | + | "type_info": "Varchar" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "max_activations", | |
| 34 | + | "type_info": "Int4" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "activation_count", | |
| 39 | + | "type_info": "Int4" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "revoked_at: chrono::DateTime<chrono::Utc>", | |
| 44 | + | "type_info": "Timestamptz" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 49 | + | "type_info": "Timestamptz" | |
| 50 | + | } | |
| 51 | + | ], | |
| 52 | + | "parameters": { | |
| 53 | + | "Left": [ | |
| 54 | + | "Uuid", | |
| 55 | + | "Uuid", | |
| 56 | + | "Varchar", | |
| 57 | + | "Int4" | |
| 58 | + | ] | |
| 59 | + | }, | |
| 60 | + | "nullable": [ | |
| 61 | + | false, | |
| 62 | + | false, | |
| 63 | + | false, | |
| 64 | + | true, | |
| 65 | + | false, | |
| 66 | + | true, | |
| 67 | + | false, | |
| 68 | + | true, | |
| 69 | + | false | |
| 70 | + | ] | |
| 71 | + | }, | |
| 72 | + | "hash": "fdbe0e6f055c5e2c3a08910d56f95c8f543d379e68af6c5f3bc2212a5e65db90" | |
| 73 | + | } |
| @@ -444,6 +444,58 @@ pub async fn deactivate_machine( | |||
| 444 | 444 | } | |
| 445 | 445 | } | |
| 446 | 446 | ||
| 447 | + | /// Create a manually-generated key for an item, atomically enforcing a per-item | |
| 448 | + | /// cap. Locks the item row `FOR UPDATE` so concurrent manual issuance for the | |
| 449 | + | /// same item serializes — the prior count-then-insert let N concurrent generates | |
| 450 | + | /// each read `count = cap-1` and all insert, exceeding the cap (fuzz 2026-07-06 | |
| 451 | + | /// C6-1). Returns `None` if the cap is already reached (caller maps to a 400). | |
| 452 | + | #[tracing::instrument(skip_all)] | |
| 453 | + | pub async fn create_manual_key_capped( | |
| 454 | + | pool: &PgPool, | |
| 455 | + | item_id: ItemId, | |
| 456 | + | owner_id: UserId, | |
| 457 | + | key_code: &KeyCode, | |
| 458 | + | max_activations: Option<i32>, | |
| 459 | + | cap: i64, | |
| 460 | + | ) -> Result<Option<DbLicenseKey>> { | |
| 461 | + | let mut tx = pool.begin().await?; | |
| 462 | + | // Serialize concurrent manual issuance for this item so the count below is | |
| 463 | + | // stable through the insert (mirrors lock_project_for_splits' cap pattern). | |
| 464 | + | sqlx::query!(r#"SELECT id FROM items WHERE id = $1 FOR UPDATE"#, item_id as ItemId) | |
| 465 | + | .fetch_one(&mut *tx) | |
| 466 | + | .await?; | |
| 467 | + | let count = sqlx::query_scalar!( | |
| 468 | + | r#"SELECT COUNT(*) AS "count!" FROM license_keys WHERE item_id = $1"#, | |
| 469 | + | item_id as ItemId, | |
| 470 | + | ) | |
| 471 | + | .fetch_one(&mut *tx) | |
| 472 | + | .await?; | |
| 473 | + | if count >= cap { | |
| 474 | + | tx.rollback().await?; | |
| 475 | + | return Ok(None); | |
| 476 | + | } | |
| 477 | + | let key = sqlx::query_as!( | |
| 478 | + | DbLicenseKey, | |
| 479 | + | r#" | |
| 480 | + | INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations) | |
| 481 | + | VALUES ($1, $2, NULL, $3, $4) | |
| 482 | + | RETURNING id AS "id: LicenseKeyId", item_id AS "item_id: ItemId", | |
| 483 | + | owner_id AS "owner_id: UserId", transaction_id AS "transaction_id: TransactionId", | |
| 484 | + | key_code AS "key_code: KeyCode", max_activations, activation_count, | |
| 485 | + | revoked_at AS "revoked_at: chrono::DateTime<chrono::Utc>", | |
| 486 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>" | |
| 487 | + | "#, | |
| 488 | + | item_id as ItemId, | |
| 489 | + | owner_id as UserId, | |
| 490 | + | key_code as &KeyCode, | |
| 491 | + | max_activations, | |
| 492 | + | ) | |
| 493 | + | .fetch_one(&mut *tx) | |
| 494 | + | .await?; | |
| 495 | + | tx.commit().await?; | |
| 496 | + | Ok(Some(key)) | |
| 497 | + | } | |
| 498 | + | ||
| 447 | 499 | /// Revoke a license key and deactivate all its activations. | |
| 448 | 500 | /// | |
| 449 | 501 | /// Wrapped in a transaction so the key revocation and activation |
| @@ -420,26 +420,28 @@ pub(super) async fn generate_key( | |||
| 420 | 420 | )); | |
| 421 | 421 | } | |
| 422 | 422 | ||
| 423 | - | // Cap at 1000 manually-generated keys per item | |
| 424 | - | let existing_count = db::license_keys::count_keys_by_item(&state.db, item_id).await?; | |
| 425 | - | if existing_count >= 1000 { | |
| 426 | - | return Err(AppError::BadRequest( | |
| 427 | - | "Maximum of 1000 license keys per item reached".to_string(), | |
| 428 | - | )); | |
| 429 | - | } | |
| 430 | - | ||
| 431 | 423 | let key_code = helpers::generate_key_code(); | |
| 432 | 424 | let max_activations = req.max_activations.or(item.default_max_activations); | |
| 433 | 425 | ||
| 434 | - | let _key = db::license_keys::create_license_key( | |
| 426 | + | // Cap at 1000 manually-generated keys per item, enforced atomically under an | |
| 427 | + | // item-row lock so concurrent generates can't race past it (fuzz 2026-07-06 C6-1). | |
| 428 | + | let _key = match db::license_keys::create_manual_key_capped( | |
| 435 | 429 | &state.db, | |
| 436 | 430 | item_id, | |
| 437 | 431 | user.id, | |
| 438 | - | None, | |
| 439 | 432 | &key_code, | |
| 440 | 433 | max_activations, | |
| 434 | + | 1000, | |
| 441 | 435 | ) | |
| 442 | - | .await?; | |
| 436 | + | .await? | |
| 437 | + | { | |
| 438 | + | Some(key) => key, | |
| 439 | + | None => { | |
| 440 | + | return Err(AppError::BadRequest( | |
| 441 | + | "Maximum of 1000 license keys per item reached".to_string(), | |
| 442 | + | )); | |
| 443 | + | } | |
| 444 | + | }; | |
| 443 | 445 | ||
| 444 | 446 | if is_htmx_request(&headers) { | |
| 445 | 447 | let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?; |