this repo has no description

oauth error msg improvement, general code quality

lewis af5a2a16 8595bcec

+52
.sqlx/query-2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by\n FROM invite_codes ic\n JOIN users u ON ic.created_by_user = u.id\n WHERE ic.created_by_user = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "available_uses", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "disabled", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "for_account", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "created_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "created_by", 34 + "type_info": "Text" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Uuid" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + true, 46 + false, 47 + false, 48 + false 49 + ] 50 + }, 51 + "hash": "2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a" 52 + }
-14
.sqlx/query-413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237" 14 - }
···
+28
.sqlx/query-46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did FROM users WHERE id = ANY($1)", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "UuidArray" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e" 28 + }
-22
.sqlx/query-5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT code FROM invite_codes WHERE created_by_user = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "code", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3" 22 - }
···
-28
.sqlx/query-5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT u.did, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "used_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068" 28 - }
···
+3 -3
.sqlx/query-7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036.json .sqlx/query-888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "UPDATE invite_codes SET disabled = TRUE WHERE code = $1", 4 "describe": { 5 "columns": [], 6 "parameters": { 7 "Left": [ 8 - "Text" 9 ] 10 }, 11 "nullable": [] 12 }, 13 - "hash": "7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036" 14 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)", 4 "describe": { 5 "columns": [], 6 "parameters": { 7 "Left": [ 8 + "TextArray" 9 ] 10 }, 11 "nullable": [] 12 }, 13 + "hash": "888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600" 14 }
+34
.sqlx/query-ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT icu.code, u.did as used_by, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "used_by", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "used_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "TextArray" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076" 34 + }
+34
.sqlx/query-ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT icu.code, u.did, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ORDER BY icu.used_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "used_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "TextArray" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920" 34 + }
+14
.sqlx/query-eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "TextArray" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4" 14 + }
+1 -1
Cargo.toml
··· 92 tracing = "0.1" 93 tracing-subscriber = "0.3" 94 urlencoding = "2.1" 95 - uuid = { version = "1.19", features = ["v4", "v5", "fast-rng"] } 96 webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 97 webauthn-rs-proto = "0.5" 98 zip = { version = "7.0", default-features = false, features = ["deflate"] }
··· 92 tracing = "0.1" 93 tracing-subscriber = "0.3" 94 urlencoding = "2.1" 95 + uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng"] } 96 webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 97 webauthn-rs-proto = "0.5" 98 zip = { version = "7.0", default-features = false, features = ["deflate"] }
+4 -5
crates/tranquil-comms/src/locale.rs
··· 182 }; 183 184 pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { 185 - let mut result = template.to_string(); 186 - for (key, value) in vars { 187 - result = result.replace(&format!("{{{}}}", key), value); 188 - } 189 - result 190 } 191 192 #[cfg(test)]
··· 182 }; 183 184 pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { 185 + vars.iter() 186 + .fold(template.to_string(), |result, (key, value)| { 187 + result.replace(&format!("{{{}}}", key), value) 188 + }) 189 } 190 191 #[cfg(test)]
+22 -4
crates/tranquil-oauth/src/client.rs
··· 568 .get("y") 569 .and_then(|v| v.as_str()) 570 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 571 - let x_bytes = URL_SAFE_NO_PAD 572 .decode(x) 573 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 574 - let y_bytes = URL_SAFE_NO_PAD 575 .decode(y) 576 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 577 let mut point_bytes = vec![0x04]; 578 point_bytes.extend_from_slice(&x_bytes); 579 point_bytes.extend_from_slice(&y_bytes); ··· 604 .get("y") 605 .and_then(|v| v.as_str()) 606 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 607 - let x_bytes = URL_SAFE_NO_PAD 608 .decode(x) 609 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 610 - let y_bytes = URL_SAFE_NO_PAD 611 .decode(y) 612 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 613 let mut point_bytes = vec![0x04]; 614 point_bytes.extend_from_slice(&x_bytes); 615 point_bytes.extend_from_slice(&y_bytes);
··· 568 .get("y") 569 .and_then(|v| v.as_str()) 570 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 571 + let x_decoded = URL_SAFE_NO_PAD 572 .decode(x) 573 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 574 + let y_decoded = URL_SAFE_NO_PAD 575 .decode(y) 576 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 577 + if x_decoded.len() > 32 || y_decoded.len() > 32 { 578 + return Err(OAuthError::InvalidClient( 579 + "EC coordinate too long".to_string(), 580 + )); 581 + } 582 + let mut x_bytes = [0u8; 32]; 583 + let mut y_bytes = [0u8; 32]; 584 + x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded); 585 + y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded); 586 let mut point_bytes = vec![0x04]; 587 point_bytes.extend_from_slice(&x_bytes); 588 point_bytes.extend_from_slice(&y_bytes); ··· 613 .get("y") 614 .and_then(|v| v.as_str()) 615 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 616 + let x_decoded = URL_SAFE_NO_PAD 617 .decode(x) 618 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 619 + let y_decoded = URL_SAFE_NO_PAD 620 .decode(y) 621 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 622 + if x_decoded.len() > 48 || y_decoded.len() > 48 { 623 + return Err(OAuthError::InvalidClient( 624 + "EC coordinate too long".to_string(), 625 + )); 626 + } 627 + let mut x_bytes = [0u8; 48]; 628 + let mut y_bytes = [0u8; 48]; 629 + x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded); 630 + y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded); 631 let mut point_bytes = vec![0x04]; 632 point_bytes.extend_from_slice(&x_bytes); 633 point_bytes.extend_from_slice(&y_bytes);
+72 -14
crates/tranquil-oauth/src/dpop.rs
··· 218 crv 219 ))); 220 } 221 - let x_bytes = URL_SAFE_NO_PAD 222 .decode( 223 jwk.x 224 .as_ref() 225 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 226 ) 227 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 228 - let y_bytes = URL_SAFE_NO_PAD 229 .decode( 230 jwk.y 231 .as_ref() 232 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 233 ) 234 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 235 - let point = EncodedPoint::from_affine_coordinates( 236 - x_bytes.as_slice().into(), 237 - y_bytes.as_slice().into(), 238 - false, 239 - ); 240 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 241 let affine = 242 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 264 crv 265 ))); 266 } 267 - let x_bytes = URL_SAFE_NO_PAD 268 .decode( 269 jwk.x 270 .as_ref() 271 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 272 ) 273 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 274 - let y_bytes = URL_SAFE_NO_PAD 275 .decode( 276 jwk.y 277 .as_ref() 278 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 279 ) 280 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 281 - let point = EncodedPoint::from_affine_coordinates( 282 - x_bytes.as_slice().into(), 283 - y_bytes.as_slice().into(), 284 - false, 285 - ); 286 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 287 let affine = 288 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 397 }; 398 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 399 assert!(!thumbprint.is_empty()); 400 } 401 }
··· 218 crv 219 ))); 220 } 221 + let x_decoded = URL_SAFE_NO_PAD 222 .decode( 223 jwk.x 224 .as_ref() 225 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 226 ) 227 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 228 + let y_decoded = URL_SAFE_NO_PAD 229 .decode( 230 jwk.y 231 .as_ref() 232 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 233 ) 234 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 235 + let mut x_bytes = [0u8; 32]; 236 + let mut y_bytes = [0u8; 32]; 237 + if x_decoded.len() > 32 || y_decoded.len() > 32 { 238 + return Err(OAuthError::InvalidDpopProof( 239 + "EC coordinate too long".to_string(), 240 + )); 241 + } 242 + x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded); 243 + y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded); 244 + let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false); 245 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 246 let affine = 247 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 269 crv 270 ))); 271 } 272 + let x_decoded = URL_SAFE_NO_PAD 273 .decode( 274 jwk.x 275 .as_ref() 276 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 277 ) 278 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 279 + let y_decoded = URL_SAFE_NO_PAD 280 .decode( 281 jwk.y 282 .as_ref() 283 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 284 ) 285 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 286 + let mut x_bytes = [0u8; 48]; 287 + let mut y_bytes = [0u8; 48]; 288 + if x_decoded.len() > 48 || y_decoded.len() > 48 { 289 + return Err(OAuthError::InvalidDpopProof( 290 + "EC coordinate too long".to_string(), 291 + )); 292 + } 293 + x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded); 294 + y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded); 295 + let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false); 296 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 297 let affine = 298 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 407 }; 408 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 409 assert!(!thumbprint.is_empty()); 410 + } 411 + 412 + #[test] 413 + fn test_es256_short_coordinate_no_panic() { 414 + let short_31_bytes = vec![0x42u8; 31]; 415 + let short_30_bytes = vec![0x42u8; 30]; 416 + let x_b64 = URL_SAFE_NO_PAD.encode(&short_31_bytes); 417 + let y_b64 = URL_SAFE_NO_PAD.encode(&short_30_bytes); 418 + let jwk = DPoPJwk { 419 + kty: "EC".to_string(), 420 + crv: Some("P-256".to_string()), 421 + x: Some(x_b64), 422 + y: Some(y_b64), 423 + }; 424 + let result = verify_es256(&jwk, b"test", &[0u8; 64]); 425 + assert!(result.is_err(), "Invalid coordinates should return error, not panic"); 426 + } 427 + 428 + #[test] 429 + fn test_es256_valid_key_with_trimmed_coordinates() { 430 + use p256::ecdsa::{SigningKey, signature::Signer}; 431 + use p256::elliptic_curve::rand_core::OsRng; 432 + 433 + let signing_key = SigningKey::random(&mut OsRng); 434 + let verifying_key = signing_key.verifying_key(); 435 + let point = verifying_key.to_encoded_point(false); 436 + let x_bytes = point.x().unwrap(); 437 + let y_bytes = point.y().unwrap(); 438 + let x_trimmed: Vec<u8> = x_bytes.iter().copied().skip_while(|&b| b == 0).collect(); 439 + let y_trimmed: Vec<u8> = y_bytes.iter().copied().skip_while(|&b| b == 0).collect(); 440 + let x_b64 = URL_SAFE_NO_PAD.encode(&x_trimmed); 441 + let y_b64 = URL_SAFE_NO_PAD.encode(&y_trimmed); 442 + let jwk = DPoPJwk { 443 + kty: "EC".to_string(), 444 + crv: Some("P-256".to_string()), 445 + x: Some(x_b64), 446 + y: Some(y_b64), 447 + }; 448 + let message = b"test message for signature verification"; 449 + let signature: p256::ecdsa::Signature = signing_key.sign(message); 450 + let result = verify_es256(&jwk, message, signature.to_bytes().as_slice()); 451 + assert!( 452 + result.is_ok(), 453 + "Should verify signature with trimmed coordinates (x={}, y={}): {:?}", 454 + x_trimmed.len(), 455 + y_trimmed.len(), 456 + result 457 + ); 458 } 459 }
+101 -62
crates/tranquil-pds/src/api/admin/account/info.rs
··· 130 db: &sqlx::PgPool, 131 user_id: uuid::Uuid, 132 ) -> Option<Vec<InviteCodeInfo>> { 133 - let codes = sqlx::query_scalar!( 134 r#" 135 - SELECT code FROM invite_codes WHERE created_by_user = $1 136 "#, 137 user_id 138 ) 139 .fetch_all(db) 140 .await 141 .ok()?; 142 - if codes.is_empty() { 143 return None; 144 } 145 - let mut invites = Vec::new(); 146 - for code in codes { 147 - if let Some(info) = get_invite_code_info(db, &code).await { 148 - invites.push(info); 149 - } 150 - } 151 if invites.is_empty() { 152 None 153 } else { ··· 276 .map(|r| (r.used_by_user, r.code)) 277 .collect(); 278 279 - let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 280 - std::collections::HashMap::new(); 281 - for u in all_invite_uses { 282 - uses_by_code 283 - .entry(u.code.clone()) 284 - .or_default() 285 - .push(InviteCodeUseInfo { 286 - used_by: u.used_by.into(), 287 - used_at: u.used_at.to_rfc3339(), 288 }); 289 - } 290 291 - let mut codes_by_user: std::collections::HashMap<uuid::Uuid, Vec<InviteCodeInfo>> = 292 - std::collections::HashMap::new(); 293 - let mut code_info_map: std::collections::HashMap<String, InviteCodeInfo> = 294 - std::collections::HashMap::new(); 295 - for ic in all_invite_codes { 296 - let info = InviteCodeInfo { 297 - code: ic.code.clone(), 298 - available: ic.available_uses, 299 - disabled: ic.disabled.unwrap_or(false), 300 - for_account: ic.for_account.into(), 301 - created_by: ic.created_by.into(), 302 - created_at: ic.created_at.to_rfc3339(), 303 - uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), 304 - }; 305 - code_info_map.insert(ic.code.clone(), info.clone()); 306 - codes_by_user 307 - .entry(ic.created_by_user) 308 - .or_default() 309 - .push(info); 310 - } 311 312 - let mut infos = Vec::with_capacity(users.len()); 313 - for row in users { 314 - let invited_by = invited_by_map 315 - .get(&row.id) 316 - .and_then(|code| code_info_map.get(code).cloned()); 317 - let invites = codes_by_user.get(&row.id).cloned(); 318 - infos.push(AccountInfo { 319 - did: row.did.into(), 320 - handle: row.handle.into(), 321 - email: row.email, 322 - indexed_at: row.created_at.to_rfc3339(), 323 - invite_note: None, 324 - invites_disabled: row.invites_disabled.unwrap_or(false), 325 - email_confirmed_at: if row.email_verified { 326 - Some(row.created_at.to_rfc3339()) 327 - } else { 328 - None 329 - }, 330 - deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()), 331 - invited_by, 332 - invites, 333 - }); 334 - } 335 (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() 336 }
··· 130 db: &sqlx::PgPool, 131 user_id: uuid::Uuid, 132 ) -> Option<Vec<InviteCodeInfo>> { 133 + let invite_codes = sqlx::query!( 134 r#" 135 + SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by 136 + FROM invite_codes ic 137 + JOIN users u ON ic.created_by_user = u.id 138 + WHERE ic.created_by_user = $1 139 "#, 140 user_id 141 ) 142 .fetch_all(db) 143 .await 144 .ok()?; 145 + 146 + if invite_codes.is_empty() { 147 return None; 148 } 149 + 150 + let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect(); 151 + let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 152 + std::collections::HashMap::new(); 153 + sqlx::query!( 154 + r#" 155 + SELECT icu.code, u.did as used_by, icu.used_at 156 + FROM invite_code_uses icu 157 + JOIN users u ON icu.used_by_user = u.id 158 + WHERE icu.code = ANY($1) 159 + "#, 160 + &code_strings 161 + ) 162 + .fetch_all(db) 163 + .await 164 + .ok()? 165 + .into_iter() 166 + .for_each(|r| { 167 + uses_by_code 168 + .entry(r.code) 169 + .or_default() 170 + .push(InviteCodeUseInfo { 171 + used_by: r.used_by.into(), 172 + used_at: r.used_at.to_rfc3339(), 173 + }); 174 + }); 175 + 176 + let invites: Vec<InviteCodeInfo> = invite_codes 177 + .into_iter() 178 + .map(|ic| InviteCodeInfo { 179 + code: ic.code.clone(), 180 + available: ic.available_uses, 181 + disabled: ic.disabled.unwrap_or(false), 182 + for_account: ic.for_account.into(), 183 + created_by: ic.created_by.into(), 184 + created_at: ic.created_at.to_rfc3339(), 185 + uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), 186 + }) 187 + .collect(); 188 + 189 if invites.is_empty() { 190 None 191 } else { ··· 314 .map(|r| (r.used_by_user, r.code)) 315 .collect(); 316 317 + let uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 318 + all_invite_uses 319 + .into_iter() 320 + .fold(std::collections::HashMap::new(), |mut acc, u| { 321 + acc.entry(u.code.clone()).or_default().push(InviteCodeUseInfo { 322 + used_by: u.used_by.into(), 323 + used_at: u.used_at.to_rfc3339(), 324 + }); 325 + acc 326 }); 327 328 + let (codes_by_user, code_info_map): ( 329 + std::collections::HashMap<uuid::Uuid, Vec<InviteCodeInfo>>, 330 + std::collections::HashMap<String, InviteCodeInfo>, 331 + ) = all_invite_codes.into_iter().fold( 332 + (std::collections::HashMap::new(), std::collections::HashMap::new()), 333 + |(mut by_user, mut by_code), ic| { 334 + let info = InviteCodeInfo { 335 + code: ic.code.clone(), 336 + available: ic.available_uses, 337 + disabled: ic.disabled.unwrap_or(false), 338 + for_account: ic.for_account.into(), 339 + created_by: ic.created_by.into(), 340 + created_at: ic.created_at.to_rfc3339(), 341 + uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), 342 + }; 343 + by_code.insert(ic.code.clone(), info.clone()); 344 + by_user.entry(ic.created_by_user).or_default().push(info); 345 + (by_user, by_code) 346 + }, 347 + ); 348 349 + let infos: Vec<AccountInfo> = users 350 + .into_iter() 351 + .map(|row| { 352 + let invited_by = invited_by_map 353 + .get(&row.id) 354 + .and_then(|code| code_info_map.get(code).cloned()); 355 + let invites = codes_by_user.get(&row.id).cloned(); 356 + AccountInfo { 357 + did: row.did.into(), 358 + handle: row.handle.into(), 359 + email: row.email, 360 + indexed_at: row.created_at.to_rfc3339(), 361 + invite_note: None, 362 + invites_disabled: row.invites_disabled.unwrap_or(false), 363 + email_confirmed_at: if row.email_verified { 364 + Some(row.created_at.to_rfc3339()) 365 + } else { 366 + None 367 + }, 368 + deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()), 369 + invited_by, 370 + invites, 371 + } 372 + }) 373 + .collect(); 374 (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() 375 }
+11 -24
crates/tranquil-pds/src/api/admin/config.rs
··· 48 .fetch_all(&state.db) 49 .await?; 50 51 - let mut server_name = "Tranquil PDS".to_string(); 52 - let mut primary_color = None; 53 - let mut primary_color_dark = None; 54 - let mut secondary_color = None; 55 - let mut secondary_color_dark = None; 56 - let mut logo_cid = None; 57 - 58 - for (key, value) in rows { 59 - match key.as_str() { 60 - "server_name" => server_name = value, 61 - "primary_color" => primary_color = Some(value), 62 - "primary_color_dark" => primary_color_dark = Some(value), 63 - "secondary_color" => secondary_color = Some(value), 64 - "secondary_color_dark" => secondary_color_dark = Some(value), 65 - "logo_cid" => logo_cid = Some(value), 66 - _ => {} 67 - } 68 - } 69 70 Ok(Json(ServerConfigResponse { 71 - server_name, 72 - primary_color, 73 - primary_color_dark, 74 - secondary_color, 75 - secondary_color_dark, 76 - logo_cid, 77 })) 78 } 79
··· 48 .fetch_all(&state.db) 49 .await?; 50 51 + let config_map: std::collections::HashMap<String, String> = 52 + rows.into_iter().collect(); 53 54 Ok(Json(ServerConfigResponse { 55 + server_name: config_map 56 + .get("server_name") 57 + .cloned() 58 + .unwrap_or_else(|| "Tranquil PDS".to_string()), 59 + primary_color: config_map.get("primary_color").cloned(), 60 + primary_color_dark: config_map.get("primary_color_dark").cloned(), 61 + secondary_color: config_map.get("secondary_color").cloned(), 62 + secondary_color_dark: config_map.get("secondary_color_dark").cloned(), 63 + logo_cid: config_map.get("logo_cid").cloned(), 64 })) 65 } 66
+69 -54
crates/tranquil-pds/src/api/admin/invite.rs
··· 24 Json(input): Json<DisableInviteCodesInput>, 25 ) -> Response { 26 if let Some(codes) = &input.codes { 27 - for code in codes { 28 - let _ = sqlx::query!( 29 - "UPDATE invite_codes SET disabled = TRUE WHERE code = $1", 30 - code 31 - ) 32 - .execute(&state.db) 33 - .await; 34 - } 35 } 36 if let Some(accounts) = &input.accounts { 37 - for account in accounts { 38 - let user = sqlx::query!("SELECT id FROM users WHERE did = $1", account) 39 - .fetch_optional(&state.db) 40 - .await; 41 - if let Ok(Some(user_row)) = user { 42 - let _ = sqlx::query!( 43 - "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1", 44 - user_row.id 45 - ) 46 - .execute(&state.db) 47 - .await; 48 - } 49 - } 50 } 51 EmptyResponse::ok().into_response() 52 } ··· 70 pub uses: Vec<InviteCodeUseInfo>, 71 } 72 73 - #[derive(Serialize)] 74 #[serde(rename_all = "camelCase")] 75 pub struct InviteCodeUseInfo { 76 pub used_by: String, ··· 149 return ApiError::InternalError(None).into_response(); 150 } 151 }; 152 - let mut codes = Vec::new(); 153 - for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows { 154 - let creator_did = 155 - sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user) 156 - .fetch_optional(&state.db) 157 - .await 158 - .ok() 159 - .flatten() 160 - .unwrap_or_else(|| "unknown".to_string()); 161 - let uses_result = sqlx::query!( 162 r#" 163 - SELECT u.did, icu.used_at 164 FROM invite_code_uses icu 165 JOIN users u ON icu.used_by_user = u.id 166 - WHERE icu.code = $1 167 ORDER BY icu.used_at DESC 168 "#, 169 - code 170 ) 171 .fetch_all(&state.db) 172 - .await; 173 - let uses = match uses_result { 174 - Ok(use_rows) => use_rows 175 - .iter() 176 - .map(|u| InviteCodeUseInfo { 177 - used_by: u.did.clone(), 178 - used_at: u.used_at.to_rfc3339(), 179 - }) 180 - .collect(), 181 - Err(_) => Vec::new(), 182 - }; 183 - codes.push(InviteCodeInfo { 184 - code: code.clone(), 185 - available: *available_uses, 186 - disabled: disabled.unwrap_or(false), 187 - for_account: creator_did.clone(), 188 - created_by: creator_did, 189 - created_at: created_at.to_rfc3339(), 190 - uses, 191 }); 192 } 193 let next_cursor = if codes_rows.len() == limit as usize { 194 codes_rows.last().map(|(code, _, _, _, _)| code.clone()) 195 } else {
··· 24 Json(input): Json<DisableInviteCodesInput>, 25 ) -> Response { 26 if let Some(codes) = &input.codes { 27 + let _ = sqlx::query!( 28 + "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)", 29 + codes as &[String] 30 + ) 31 + .execute(&state.db) 32 + .await; 33 } 34 if let Some(accounts) = &input.accounts { 35 + let _ = sqlx::query!( 36 + "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))", 37 + accounts as &[String] 38 + ) 39 + .execute(&state.db) 40 + .await; 41 } 42 EmptyResponse::ok().into_response() 43 } ··· 61 pub uses: Vec<InviteCodeUseInfo>, 62 } 63 64 + #[derive(Clone, Serialize)] 65 #[serde(rename_all = "camelCase")] 66 pub struct InviteCodeUseInfo { 67 pub used_by: String, ··· 140 return ApiError::InternalError(None).into_response(); 141 } 142 }; 143 + 144 + let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|(_, _, _, uid, _)| *uid).collect(); 145 + let code_strings: Vec<String> = codes_rows.iter().map(|(c, _, _, _, _)| c.clone()).collect(); 146 + 147 + let mut creator_dids: std::collections::HashMap<uuid::Uuid, String> = 148 + std::collections::HashMap::new(); 149 + sqlx::query!( 150 + "SELECT id, did FROM users WHERE id = ANY($1)", 151 + &user_ids 152 + ) 153 + .fetch_all(&state.db) 154 + .await 155 + .unwrap_or_default() 156 + .into_iter() 157 + .for_each(|r| { 158 + creator_dids.insert(r.id, r.did); 159 + }); 160 + 161 + let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 162 + std::collections::HashMap::new(); 163 + if !code_strings.is_empty() { 164 + sqlx::query!( 165 r#" 166 + SELECT icu.code, u.did, icu.used_at 167 FROM invite_code_uses icu 168 JOIN users u ON icu.used_by_user = u.id 169 + WHERE icu.code = ANY($1) 170 ORDER BY icu.used_at DESC 171 "#, 172 + &code_strings 173 ) 174 .fetch_all(&state.db) 175 + .await 176 + .unwrap_or_default() 177 + .into_iter() 178 + .for_each(|r| { 179 + uses_by_code 180 + .entry(r.code) 181 + .or_default() 182 + .push(InviteCodeUseInfo { 183 + used_by: r.did, 184 + used_at: r.used_at.to_rfc3339(), 185 + }); 186 }); 187 } 188 + 189 + let codes: Vec<InviteCodeInfo> = codes_rows 190 + .iter() 191 + .map(|(code, available_uses, disabled, created_by_user, created_at)| { 192 + let creator_did = creator_dids 193 + .get(created_by_user) 194 + .cloned() 195 + .unwrap_or_else(|| "unknown".to_string()); 196 + InviteCodeInfo { 197 + code: code.clone(), 198 + available: *available_uses, 199 + disabled: disabled.unwrap_or(false), 200 + for_account: creator_did.clone(), 201 + created_by: creator_did, 202 + created_at: created_at.to_rfc3339(), 203 + uses: uses_by_code.get(code).cloned().unwrap_or_default(), 204 + } 205 + }) 206 + .collect(); 207 + 208 let next_cursor = if codes_rows.len() == limit as usize { 209 codes_rows.last().map(|(code, _, _, _, _)| code.clone()) 210 } else {
+26 -9
crates/tranquil-pds/src/api/error.rs
··· 22 InvalidRequest(String), 23 InvalidToken(Option<String>), 24 ExpiredToken(Option<String>), 25 TokenRequired, 26 AccountDeactivated, 27 AccountTakedown, ··· 127 | Self::InvalidCode(_) 128 | Self::InvalidPassword(_) 129 | Self::InvalidToken(_) 130 - | Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED, 131 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 132 Self::Forbidden 133 | Self::AdminRequired ··· 216 Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 217 Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), 218 Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), 219 - Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 220 Self::TokenRequired => Cow::Borrowed("TokenRequired"), 221 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 222 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), ··· 298 | Self::AuthenticationFailed(msg) 299 | Self::InvalidToken(msg) 300 | Self::ExpiredToken(msg) 301 | Self::RepoNotFound(msg) 302 | Self::BlobNotFound(msg) 303 | Self::InvalidHandle(msg) ··· 428 message: self.message(), 429 }; 430 let mut response = (self.status_code(), Json(body)).into_response(); 431 - if matches!(self, Self::ExpiredToken(_)) { 432 - response.headers_mut().insert( 433 - "WWW-Authenticate", 434 - "Bearer error=\"invalid_token\", error_description=\"Token has expired\"" 435 - .parse() 436 - .unwrap(), 437 - ); 438 } 439 response 440 } ··· 457 Self::AuthenticationFailed(None) 458 } 459 crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None), 460 } 461 } 462 }
··· 22 InvalidRequest(String), 23 InvalidToken(Option<String>), 24 ExpiredToken(Option<String>), 25 + OAuthExpiredToken(Option<String>), 26 TokenRequired, 27 AccountDeactivated, 28 AccountTakedown, ··· 128 | Self::InvalidCode(_) 129 | Self::InvalidPassword(_) 130 | Self::InvalidToken(_) 131 + | Self::PasskeyCounterAnomaly 132 + | Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED, 133 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 134 Self::Forbidden 135 | Self::AdminRequired ··· 218 Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 219 Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), 220 Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), 221 + Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 222 Self::TokenRequired => Cow::Borrowed("TokenRequired"), 223 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 224 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), ··· 300 | Self::AuthenticationFailed(msg) 301 | Self::InvalidToken(msg) 302 | Self::ExpiredToken(msg) 303 + | Self::OAuthExpiredToken(msg) 304 | Self::RepoNotFound(msg) 305 | Self::BlobNotFound(msg) 306 | Self::InvalidHandle(msg) ··· 431 message: self.message(), 432 }; 433 let mut response = (self.status_code(), Json(body)).into_response(); 434 + match &self { 435 + Self::ExpiredToken(_) => { 436 + response.headers_mut().insert( 437 + "WWW-Authenticate", 438 + "Bearer error=\"invalid_token\", error_description=\"Token has expired\"" 439 + .parse() 440 + .unwrap(), 441 + ); 442 + } 443 + Self::OAuthExpiredToken(_) => { 444 + response.headers_mut().insert( 445 + "WWW-Authenticate", 446 + "DPoP error=\"invalid_token\", error_description=\"Token has expired\"" 447 + .parse() 448 + .unwrap(), 449 + ); 450 + } 451 + _ => {} 452 } 453 response 454 } ··· 471 Self::AuthenticationFailed(None) 472 } 473 crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None), 474 + crate::auth::TokenValidationError::OAuthTokenExpired => { 475 + Self::OAuthExpiredToken(Some("Token has expired".to_string())) 476 + } 477 } 478 } 479 }
+1 -1
crates/tranquil-pds/src/api/moderation/mod.rs
··· 211 } 212 213 let created_at = chrono::Utc::now(); 214 - let report_id = created_at.timestamp_millis(); 215 let subject_json = json!(input.subject); 216 217 let insert = sqlx::query!(
··· 211 } 212 213 let created_at = chrono::Utc::now(); 214 + let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64; 215 let subject_json = json!(input.subject); 216 217 let insert = sqlx::query!(
+14 -25
crates/tranquil-pds/src/api/proxy.rs
··· 268 } 269 Err(e) => { 270 warn!("Token validation failed: {:?}", e); 271 - if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop 272 - { 273 - let www_auth = 274 - "DPoP error=\"invalid_token\", error_description=\"Token has expired\""; 275 - let mut response = 276 - ApiError::ExpiredToken(Some("Token has expired".into())).into_response(); 277 - *response.status_mut() = axum::http::StatusCode::UNAUTHORIZED; 278 - response 279 - .headers_mut() 280 - .insert("WWW-Authenticate", www_auth.parse().unwrap()); 281 - let nonce = crate::oauth::verify::generate_dpop_nonce(); 282 - response 283 - .headers_mut() 284 - .insert("DPoP-Nonce", nonce.parse().unwrap()); 285 - return response; 286 } 287 } 288 } ··· 291 if let Some(val) = auth_header_val { 292 request_builder = request_builder.header("Authorization", val); 293 } 294 - for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD { 295 - if let Some(val) = headers.get(*header_name) { 296 - request_builder = request_builder.header(*header_name, val); 297 - } 298 - } 299 if !body.is_empty() { 300 request_builder = request_builder.body(body); 301 } ··· 313 } 314 }; 315 let mut response_builder = Response::builder().status(status); 316 - for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD { 317 - if let Some(val) = headers.get(*header_name) { 318 - response_builder = response_builder.header(*header_name, val); 319 - } 320 - } 321 match response_builder.body(axum::body::Body::from(body)) { 322 Ok(r) => r, 323 Err(e) => {
··· 268 } 269 Err(e) => { 270 warn!("Token validation failed: {:?}", e); 271 + if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) { 272 + return ApiError::from(e).into_response(); 273 } 274 } 275 } ··· 278 if let Some(val) = auth_header_val { 279 request_builder = request_builder.header("Authorization", val); 280 } 281 + request_builder = crate::api::proxy_client::HEADERS_TO_FORWARD 282 + .iter() 283 + .filter_map(|name| headers.get(*name).map(|val| (*name, val))) 284 + .fold(request_builder, |builder, (name, val)| { 285 + builder.header(name, val) 286 + }); 287 if !body.is_empty() { 288 request_builder = request_builder.body(body); 289 } ··· 301 } 302 }; 303 let mut response_builder = Response::builder().status(status); 304 + response_builder = crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD 305 + .iter() 306 + .filter_map(|name| headers.get(*name).map(|val| (*name, val))) 307 + .fold(response_builder, |builder, (name, val)| { 308 + builder.header(name, val) 309 + }); 310 match response_builder.body(axum::body::Body::from(body)) { 311 Ok(r) => r, 312 Err(e) => {
+7 -9
crates/tranquil-pds/src/api/proxy_client.rs
··· 88 Ok(addrs) => addrs.collect(), 89 Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())), 90 }; 91 - for addr in &socket_addrs { 92 - if !is_unicast_ip(&addr.ip()) { 93 - warn!( 94 - "DNS resolution for {} returned non-unicast IP: {}", 95 - host, 96 - addr.ip() 97 - ); 98 - return Err(SsrfError::NonUnicastIp(addr.ip().to_string())); 99 - } 100 } 101 Ok(()) 102 }
··· 88 Ok(addrs) => addrs.collect(), 89 Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())), 90 }; 91 + if let Some(addr) = socket_addrs.iter().find(|addr| !is_unicast_ip(&addr.ip())) { 92 + warn!( 93 + "DNS resolution for {} returned non-unicast IP: {}", 94 + host, 95 + addr.ip() 96 + ); 97 + return Err(SsrfError::NonUnicastIp(addr.ip().to_string())); 98 } 99 Ok(()) 100 }
+1 -13
crates/tranquil-pds/src/api/repo/record/write.rs
··· 82 .await 83 .map_err(|e| { 84 tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 85 - let mut response = ApiError::from(e).into_response(); 86 - if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop { 87 - *response.status_mut() = axum::http::StatusCode::UNAUTHORIZED; 88 - let www_auth = 89 - "DPoP error=\"invalid_token\", error_description=\"Token has expired\""; 90 - response.headers_mut().insert( 91 - "WWW-Authenticate", 92 - www_auth.parse().unwrap(), 93 - ); 94 - let nonce = crate::oauth::verify::generate_dpop_nonce(); 95 - response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap()); 96 - } 97 - response 98 })?; 99 if repo.as_str() != auth_user.did.as_str() { 100 return Err(
··· 82 .await 83 .map_err(|e| { 84 tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 85 + ApiError::from(e).into_response() 86 })?; 87 if repo.as_str() != auth_user.did.as_str() { 88 return Err(
+30 -38
crates/tranquil-pds/src/api/validation.rs
··· 181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") { 182 return Err(EmailValidationError::InvalidLocalPart); 183 } 184 - for c in local.chars() { 185 - if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 186 - return Err(EmailValidationError::InvalidLocalPart); 187 - } 188 } 189 if domain.is_empty() { 190 return Err(EmailValidationError::EmptyDomain); ··· 195 if !domain.contains('.') { 196 return Err(EmailValidationError::MissingDomainDot); 197 } 198 - for label in domain.split('.') { 199 - if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { 200 - return Err(EmailValidationError::InvalidDomainLabel); 201 - } 202 - if label.starts_with('-') || label.ends_with('-') { 203 - return Err(EmailValidationError::InvalidDomainLabel); 204 - } 205 - for c in label.chars() { 206 - if !c.is_ascii_alphanumeric() && c != '-' { 207 - return Err(EmailValidationError::InvalidDomainLabel); 208 - } 209 - } 210 } 211 Ok(()) 212 } ··· 293 return Err(HandleValidationError::EndsWithInvalidChar); 294 } 295 296 - for c in handle.chars() { 297 - if !c.is_ascii_alphanumeric() && c != '-' { 298 - return Err(HandleValidationError::InvalidCharacters); 299 - } 300 } 301 302 if crate::moderation::has_explicit_slur(handle) { ··· 330 if local.contains("..") { 331 return false; 332 } 333 - for c in local.chars() { 334 - if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 335 - return false; 336 - } 337 } 338 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 339 return false; ··· 341 if !domain.contains('.') { 342 return false; 343 } 344 - for label in domain.split('.') { 345 - if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { 346 - return false; 347 - } 348 - if label.starts_with('-') || label.ends_with('-') { 349 - return false; 350 - } 351 - for c in label.chars() { 352 - if !c.is_ascii_alphanumeric() && c != '-' { 353 - return false; 354 - } 355 - } 356 - } 357 - true 358 } 359 360 #[cfg(test)]
··· 181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") { 182 return Err(EmailValidationError::InvalidLocalPart); 183 } 184 + if !local 185 + .chars() 186 + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 187 + { 188 + return Err(EmailValidationError::InvalidLocalPart); 189 } 190 if domain.is_empty() { 191 return Err(EmailValidationError::EmptyDomain); ··· 196 if !domain.contains('.') { 197 return Err(EmailValidationError::MissingDomainDot); 198 } 199 + if !domain.split('.').all(|label| { 200 + !label.is_empty() 201 + && label.len() <= MAX_DOMAIN_LABEL_LENGTH 202 + && !label.starts_with('-') 203 + && !label.ends_with('-') 204 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') 205 + }) { 206 + return Err(EmailValidationError::InvalidDomainLabel); 207 } 208 Ok(()) 209 } ··· 290 return Err(HandleValidationError::EndsWithInvalidChar); 291 } 292 293 + if !handle 294 + .chars() 295 + .all(|c| c.is_ascii_alphanumeric() || c == '-') 296 + { 297 + return Err(HandleValidationError::InvalidCharacters); 298 } 299 300 if crate::moderation::has_explicit_slur(handle) { ··· 328 if local.contains("..") { 329 return false; 330 } 331 + if !local 332 + .chars() 333 + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 334 + { 335 + return false; 336 } 337 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 338 return false; ··· 340 if !domain.contains('.') { 341 return false; 342 } 343 + domain.split('.').all(|label| { 344 + !label.is_empty() 345 + && label.len() <= MAX_DOMAIN_LABEL_LENGTH 346 + && !label.starts_with('-') 347 + && !label.ends_with('-') 348 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') 349 + }) 350 } 351 352 #[cfg(test)]
+5 -2
crates/tranquil-pds/src/auth/mod.rs
··· 61 KeyDecryptionFailed, 62 AuthenticationFailed, 63 TokenExpired, 64 } 65 66 impl fmt::Display for TokenValidationError { ··· 70 Self::AccountTakedown => write!(f, "AccountTakedown"), 71 Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"), 72 Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), 73 - Self::TokenExpired => write!(f, "ExpiredToken"), 74 } 75 } 76 } ··· 497 controller_did: None, 498 }) 499 } 500 - Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired), 501 Err(_) => Err(TokenValidationError::AuthenticationFailed), 502 } 503 }
··· 61 KeyDecryptionFailed, 62 AuthenticationFailed, 63 TokenExpired, 64 + OAuthTokenExpired, 65 } 66 67 impl fmt::Display for TokenValidationError { ··· 71 Self::AccountTakedown => write!(f, "AccountTakedown"), 72 Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"), 73 Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), 74 + Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"), 75 } 76 } 77 } ··· 498 controller_did: None, 499 }) 500 } 501 + Err(crate::oauth::OAuthError::ExpiredToken(_)) => { 502 + Err(TokenValidationError::OAuthTokenExpired) 503 + } 504 Err(_) => Err(TokenValidationError::AuthenticationFailed), 505 } 506 }
+21 -18
crates/tranquil-pds/src/util.rs
··· 106 pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> { 107 query 108 .map(|q| { 109 - let mut values = Vec::new(); 110 - for pair in q.split('&') { 111 - if let Some((k, v)) = pair.split_once('=') 112 - && k == key 113 - && let Ok(decoded) = urlencoding::decode(v) 114 - { 115 - let decoded = decoded.into_owned(); 116 if decoded.contains(',') { 117 - for part in decoded.split(',') { 118 - let trimmed = part.trim(); 119 - if !trimmed.is_empty() { 120 - values.push(trimmed.to_string()); 121 - } 122 - } 123 - } else if !decoded.is_empty() { 124 - values.push(decoded); 125 } 126 - } 127 - } 128 - values 129 }) 130 .unwrap_or_default() 131 }
··· 106 pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> { 107 query 108 .map(|q| { 109 + q.split('&') 110 + .filter_map(|pair| { 111 + pair.split_once('=') 112 + .filter(|(k, _)| *k == key) 113 + .and_then(|(_, v)| urlencoding::decode(v).ok()) 114 + .map(|decoded| decoded.into_owned()) 115 + }) 116 + .flat_map(|decoded| { 117 if decoded.contains(',') { 118 + decoded 119 + .split(',') 120 + .filter_map(|part| { 121 + let trimmed = part.trim(); 122 + (!trimmed.is_empty()).then(|| trimmed.to_string()) 123 + }) 124 + .collect::<Vec<_>>() 125 + } else if decoded.is_empty() { 126 + vec![] 127 + } else { 128 + vec![decoded] 129 } 130 + }) 131 + .collect() 132 }) 133 .unwrap_or_default() 134 }
+1 -1
crates/tranquil-pds/tests/common/mod.rs
··· 437 async fn spawn_app(database_url: String) -> String { 438 use tranquil_pds::rate_limit::RateLimiters; 439 let pool = PgPoolOptions::new() 440 - .max_connections(3) 441 .acquire_timeout(std::time::Duration::from_secs(30)) 442 .connect(&database_url) 443 .await
··· 437 async fn spawn_app(database_url: String) -> String { 438 use tranquil_pds::rate_limit::RateLimiters; 439 let pool = PgPoolOptions::new() 440 + .max_connections(10) 441 .acquire_timeout(std::time::Duration::from_secs(30)) 442 .connect(&database_url) 443 .await
+11 -7
crates/tranquil-pds/tests/helpers/mod.rs
··· 4 5 pub use crate::common::*; 6 7 #[allow(dead_code)] 8 pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { 9 let client = client(); 10 - let ts = Utc::now().timestamp_millis(); 11 - let handle = format!("{}-{}.test", handle_prefix, ts); 12 - let email = format!("{}-{}@test.com", handle_prefix, ts); 13 let password = "E2epass123!"; 14 let create_account_payload = json!({ 15 "handle": handle, ··· 51 text: &str, 52 ) -> (String, String) { 53 let collection = "app.bsky.feed.post"; 54 - let rkey = format!("e2e_social_{}", Utc::now().timestamp_millis()); 55 let now = Utc::now().to_rfc3339(); 56 let create_payload = json!({ 57 "repo": did, ··· 95 followee_did: &str, 96 ) -> (String, String) { 97 let collection = "app.bsky.graph.follow"; 98 - let rkey = format!("e2e_follow_{}", Utc::now().timestamp_millis()); 99 let now = Utc::now().to_rfc3339(); 100 let create_payload = json!({ 101 "repo": follower_did, ··· 140 subject_cid: &str, 141 ) -> (String, String) { 142 let collection = "app.bsky.feed.like"; 143 - let rkey = format!("e2e_like_{}", Utc::now().timestamp_millis()); 144 let now = Utc::now().to_rfc3339(); 145 let payload = json!({ 146 "repo": liker_did, ··· 182 subject_cid: &str, 183 ) -> (String, String) { 184 let collection = "app.bsky.feed.repost"; 185 - let rkey = format!("e2e_repost_{}", Utc::now().timestamp_millis()); 186 let now = Utc::now().to_rfc3339(); 187 let payload = json!({ 188 "repo": reposter_did,
··· 4 5 pub use crate::common::*; 6 7 + fn unique_id() -> String { 8 + uuid::Uuid::new_v4().simple().to_string()[..12].to_string() 9 + } 10 + 11 #[allow(dead_code)] 12 pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { 13 let client = client(); 14 + let uid = unique_id(); 15 + let handle = format!("{}-{}.test", handle_prefix, uid); 16 + let email = format!("{}-{}@test.com", handle_prefix, uid); 17 let password = "E2epass123!"; 18 let create_account_payload = json!({ 19 "handle": handle, ··· 55 text: &str, 56 ) -> (String, String) { 57 let collection = "app.bsky.feed.post"; 58 + let rkey = format!("e2e_social_{}", unique_id()); 59 let now = Utc::now().to_rfc3339(); 60 let create_payload = json!({ 61 "repo": did, ··· 99 followee_did: &str, 100 ) -> (String, String) { 101 let collection = "app.bsky.graph.follow"; 102 + let rkey = format!("e2e_follow_{}", unique_id()); 103 let now = Utc::now().to_rfc3339(); 104 let create_payload = json!({ 105 "repo": follower_did, ··· 144 subject_cid: &str, 145 ) -> (String, String) { 146 let collection = "app.bsky.feed.like"; 147 + let rkey = format!("e2e_like_{}", unique_id()); 148 let now = Utc::now().to_rfc3339(); 149 let payload = json!({ 150 "repo": liker_did, ··· 186 subject_cid: &str, 187 ) -> (String, String) { 188 let collection = "app.bsky.feed.repost"; 189 + let rkey = format!("e2e_repost_{}", unique_id()); 190 let now = Utc::now().to_rfc3339(); 191 let payload = json!({ 192 "repo": reposter_did,
+47 -48
crates/tranquil-scopes/src/parser.rs
··· 55 if self.accept.is_empty() || self.accept.contains("*/*") { 56 return true; 57 } 58 - for pattern in &self.accept { 59 - if pattern == mime { 60 - return true; 61 - } 62 - if let Some(prefix) = pattern.strip_suffix("/*") 63 - && mime.starts_with(prefix) 64 - && mime.chars().nth(prefix.len()) == Some('/') 65 - { 66 - return true; 67 - } 68 - } 69 - false 70 } 71 } 72 ··· 170 Some(rest.to_string()) 171 }; 172 173 - let mut actions = HashSet::new(); 174 - if let Some(action_values) = params.get("action") { 175 - for action_str in action_values { 176 - if let Some(action) = RepoAction::parse_str(action_str) { 177 - actions.insert(action); 178 - } 179 - } 180 - } 181 - if actions.is_empty() { 182 - actions.insert(RepoAction::Create); 183 - actions.insert(RepoAction::Update); 184 - actions.insert(RepoAction::Delete); 185 - } 186 187 return ParsedScope::Repo(RepoScope { 188 collection, ··· 191 } 192 193 if base == "repo" { 194 - let mut actions = HashSet::new(); 195 - if let Some(action_values) = params.get("action") { 196 - for action_str in action_values { 197 - if let Some(action) = RepoAction::parse_str(action_str) { 198 - actions.insert(action); 199 - } 200 - } 201 - } 202 - if actions.is_empty() { 203 - actions.insert(RepoAction::Create); 204 - actions.insert(RepoAction::Update); 205 - actions.insert(RepoAction::Delete); 206 - } 207 return ParsedScope::Repo(RepoScope { 208 collection: None, 209 actions, ··· 212 213 if base.starts_with("blob") { 214 let positional = base.strip_prefix("blob:").unwrap_or(""); 215 - let mut accept = HashSet::new(); 216 - 217 - if !positional.is_empty() { 218 - accept.insert(positional.to_string()); 219 - } 220 - if let Some(accept_values) = params.get("accept") { 221 - for v in accept_values { 222 - accept.insert(v.to_string()); 223 - } 224 - } 225 226 return ParsedScope::Blob(BlobScope { accept }); 227 }
··· 55 if self.accept.is_empty() || self.accept.contains("*/*") { 56 return true; 57 } 58 + self.accept.iter().any(|pattern| { 59 + pattern == mime 60 + || pattern 61 + .strip_suffix("/*") 62 + .is_some_and(|prefix| { 63 + mime.starts_with(prefix) && mime.chars().nth(prefix.len()) == Some('/') 64 + }) 65 + }) 66 } 67 } 68 ··· 166 Some(rest.to_string()) 167 }; 168 169 + let actions: HashSet<RepoAction> = params 170 + .get("action") 171 + .map(|action_values| { 172 + action_values 173 + .iter() 174 + .filter_map(|s| RepoAction::parse_str(s)) 175 + .collect() 176 + }) 177 + .filter(|set: &HashSet<RepoAction>| !set.is_empty()) 178 + .unwrap_or_else(|| { 179 + [RepoAction::Create, RepoAction::Update, RepoAction::Delete] 180 + .into_iter() 181 + .collect() 182 + }); 183 184 return ParsedScope::Repo(RepoScope { 185 collection, ··· 188 } 189 190 if base == "repo" { 191 + let actions: HashSet<RepoAction> = params 192 + .get("action") 193 + .map(|action_values| { 194 + action_values 195 + .iter() 196 + .filter_map(|s| RepoAction::parse_str(s)) 197 + .collect() 198 + }) 199 + .filter(|set: &HashSet<RepoAction>| !set.is_empty()) 200 + .unwrap_or_else(|| { 201 + [RepoAction::Create, RepoAction::Update, RepoAction::Delete] 202 + .into_iter() 203 + .collect() 204 + }); 205 return ParsedScope::Repo(RepoScope { 206 collection: None, 207 actions, ··· 210 211 if base.starts_with("blob") { 212 let positional = base.strip_prefix("blob:").unwrap_or(""); 213 + let accept: HashSet<String> = std::iter::once(positional) 214 + .filter(|s| !s.is_empty()) 215 + .map(String::from) 216 + .chain( 217 + params 218 + .get("accept") 219 + .into_iter() 220 + .flatten() 221 + .map(String::clone), 222 + ) 223 + .collect(); 224 225 return ParsedScope::Blob(BlobScope { accept }); 226 }
+78 -78
crates/tranquil-scopes/src/permissions.rs
··· 113 return Ok(()); 114 } 115 116 - for repo_scope in self.find_repo_scopes() { 117 - if !repo_scope.actions.contains(&action) { 118 - continue; 119 - } 120 - 121 - match &repo_scope.collection { 122 - None => return Ok(()), 123 - Some(coll) if coll == collection => return Ok(()), 124 - Some(coll) if coll.ends_with(".*") => { 125 - let prefix = coll.strip_suffix(".*").unwrap(); 126 - if collection.starts_with(prefix) 127 - && collection.chars().nth(prefix.len()) == Some('.') 128 - { 129 - return Ok(()); 130 } 131 } 132 - _ => {} 133 - } 134 - } 135 136 - Err(ScopeError::InsufficientScope { 137 - required: format!("repo:{}?action={}", collection, action_str(action)), 138 - message: format!( 139 - "Insufficient scope to {} records in {}", 140 - action_str(action), 141 - collection 142 - ), 143 - }) 144 } 145 146 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { ··· 148 return Ok(()); 149 } 150 151 - for blob_scope in self.find_blob_scopes() { 152 - if blob_scope.matches_mime(mime) { 153 - return Ok(()); 154 - } 155 } 156 - 157 - Err(ScopeError::InsufficientScope { 158 - required: format!("blob:{}", mime), 159 - message: format!("Insufficient scope to upload blob with mime type {}", mime), 160 - }) 161 } 162 163 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { ··· 169 return Ok(()); 170 } 171 172 - for rpc_scope in self.find_rpc_scopes() { 173 let lxm_matches = match &rpc_scope.lxm { 174 None => true, 175 Some(scope_lxm) if scope_lxm == lxm => true, ··· 186 Some(scope_aud) => scope_aud == aud, 187 }; 188 189 - if lxm_matches && aud_matches { 190 - return Ok(()); 191 - } 192 - } 193 194 - Err(ScopeError::InsufficientScope { 195 - required: format!("rpc:{}?aud={}", lxm, aud), 196 - message: format!("Insufficient scope to call {} on {}", lxm, aud), 197 - }) 198 } 199 200 pub fn assert_account( ··· 211 return Ok(()); 212 } 213 214 - for account_scope in self.find_account_scopes() { 215 - if account_scope.attr == attr && account_scope.action == action { 216 - return Ok(()); 217 - } 218 - if account_scope.attr == attr && account_scope.action == AccountAction::Manage { 219 - return Ok(()); 220 - } 221 - } 222 223 - Err(ScopeError::InsufficientScope { 224 - required: format!( 225 - "account:{}?action={}", 226 - attr_str(attr), 227 - action_str_account(action) 228 - ), 229 - message: format!( 230 - "Insufficient scope to {} account {}", 231 - action_str_account(action), 232 - attr_str(attr) 233 - ), 234 - }) 235 } 236 237 pub fn allows_email_read(&self) -> bool { ··· 264 return Ok(()); 265 } 266 267 - for identity_scope in self.find_identity_scopes() { 268 - if identity_scope.attr == IdentityAttr::Wildcard { 269 - return Ok(()); 270 - } 271 - if identity_scope.attr == attr { 272 - return Ok(()); 273 - } 274 } 275 - 276 - Err(ScopeError::InsufficientScope { 277 - required: format!("identity:{}", identity_attr_str(attr)), 278 - message: format!( 279 - "Insufficient scope to modify identity {}", 280 - identity_attr_str(attr) 281 - ), 282 - }) 283 } 284 285 pub fn allows_identity(&self, attr: IdentityAttr) -> bool {
··· 113 return Ok(()); 114 } 115 116 + let has_permission = self.find_repo_scopes().any(|repo_scope| { 117 + repo_scope.actions.contains(&action) 118 + && match &repo_scope.collection { 119 + None => true, 120 + Some(coll) if coll == collection => true, 121 + Some(coll) if coll.ends_with(".*") => { 122 + let prefix = coll.strip_suffix(".*").unwrap(); 123 + collection.starts_with(prefix) 124 + && collection.chars().nth(prefix.len()) == Some('.') 125 } 126 + _ => false, 127 } 128 + }); 129 130 + if has_permission { 131 + Ok(()) 132 + } else { 133 + Err(ScopeError::InsufficientScope { 134 + required: format!("repo:{}?action={}", collection, action_str(action)), 135 + message: format!( 136 + "Insufficient scope to {} records in {}", 137 + action_str(action), 138 + collection 139 + ), 140 + }) 141 + } 142 } 143 144 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { ··· 146 return Ok(()); 147 } 148 149 + if self.find_blob_scopes().any(|blob_scope| blob_scope.matches_mime(mime)) { 150 + Ok(()) 151 + } else { 152 + Err(ScopeError::InsufficientScope { 153 + required: format!("blob:{}", mime), 154 + message: format!("Insufficient scope to upload blob with mime type {}", mime), 155 + }) 156 } 157 } 158 159 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { ··· 165 return Ok(()); 166 } 167 168 + let has_permission = self.find_rpc_scopes().any(|rpc_scope| { 169 let lxm_matches = match &rpc_scope.lxm { 170 None => true, 171 Some(scope_lxm) if scope_lxm == lxm => true, ··· 182 Some(scope_aud) => scope_aud == aud, 183 }; 184 185 + lxm_matches && aud_matches 186 + }); 187 188 + if has_permission { 189 + Ok(()) 190 + } else { 191 + Err(ScopeError::InsufficientScope { 192 + required: format!("rpc:{}?aud={}", lxm, aud), 193 + message: format!("Insufficient scope to call {} on {}", lxm, aud), 194 + }) 195 + } 196 } 197 198 pub fn assert_account( ··· 209 return Ok(()); 210 } 211 212 + let has_permission = self.find_account_scopes().any(|account_scope| { 213 + account_scope.attr == attr 214 + && (account_scope.action == action 215 + || account_scope.action == AccountAction::Manage) 216 + }); 217 218 + if has_permission { 219 + Ok(()) 220 + } else { 221 + Err(ScopeError::InsufficientScope { 222 + required: format!( 223 + "account:{}?action={}", 224 + attr_str(attr), 225 + action_str_account(action) 226 + ), 227 + message: format!( 228 + "Insufficient scope to {} account {}", 229 + action_str_account(action), 230 + attr_str(attr) 231 + ), 232 + }) 233 + } 234 } 235 236 pub fn allows_email_read(&self) -> bool { ··· 263 return Ok(()); 264 } 265 266 + let has_permission = self 267 + .find_identity_scopes() 268 + .any(|identity_scope| { 269 + identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr 270 + }); 271 + 272 + if has_permission { 273 + Ok(()) 274 + } else { 275 + Err(ScopeError::InsufficientScope { 276 + required: format!("identity:{}", identity_attr_str(attr)), 277 + message: format!( 278 + "Insufficient scope to modify identity {}", 279 + identity_attr_str(attr) 280 + ), 281 + }) 282 } 283 } 284 285 pub fn allows_identity(&self, attr: IdentityAttr) -> bool {