Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

oauth error msg improvement, general code quality

+687 -480
+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 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "UPDATE invite_codes SET disabled = TRUE WHERE code = $1", 3 + "query": "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { 7 7 "Left": [ 8 - "Text" 8 + "TextArray" 9 9 ] 10 10 }, 11 11 "nullable": [] 12 12 }, 13 - "hash": "7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036" 13 + "hash": "888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600" 14 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 92 tracing = "0.1" 93 93 tracing-subscriber = "0.3" 94 94 urlencoding = "2.1" 95 - uuid = { version = "1.19", features = ["v4", "v5", "fast-rng"] } 95 + uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng"] } 96 96 webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 97 97 webauthn-rs-proto = "0.5" 98 98 zip = { version = "7.0", default-features = false, features = ["deflate"] }
+4 -5
crates/tranquil-comms/src/locale.rs
··· 182 182 }; 183 183 184 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 185 + vars.iter() 186 + .fold(template.to_string(), |result, (key, value)| { 187 + result.replace(&format!("{{{}}}", key), value) 188 + }) 190 189 } 191 190 192 191 #[cfg(test)]
+22 -4
crates/tranquil-oauth/src/client.rs
··· 568 568 .get("y") 569 569 .and_then(|v| v.as_str()) 570 570 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 571 - let x_bytes = URL_SAFE_NO_PAD 571 + let x_decoded = URL_SAFE_NO_PAD 572 572 .decode(x) 573 573 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 574 - let y_bytes = URL_SAFE_NO_PAD 574 + let y_decoded = URL_SAFE_NO_PAD 575 575 .decode(y) 576 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); 577 586 let mut point_bytes = vec![0x04]; 578 587 point_bytes.extend_from_slice(&x_bytes); 579 588 point_bytes.extend_from_slice(&y_bytes); ··· 604 613 .get("y") 605 614 .and_then(|v| v.as_str()) 606 615 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 607 - let x_bytes = URL_SAFE_NO_PAD 616 + let x_decoded = URL_SAFE_NO_PAD 608 617 .decode(x) 609 618 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 610 - let y_bytes = URL_SAFE_NO_PAD 619 + let y_decoded = URL_SAFE_NO_PAD 611 620 .decode(y) 612 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); 613 631 let mut point_bytes = vec![0x04]; 614 632 point_bytes.extend_from_slice(&x_bytes); 615 633 point_bytes.extend_from_slice(&y_bytes);
+72 -14
crates/tranquil-oauth/src/dpop.rs
··· 218 218 crv 219 219 ))); 220 220 } 221 - let x_bytes = URL_SAFE_NO_PAD 221 + let x_decoded = URL_SAFE_NO_PAD 222 222 .decode( 223 223 jwk.x 224 224 .as_ref() 225 225 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 226 226 ) 227 227 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 228 - let y_bytes = URL_SAFE_NO_PAD 228 + let y_decoded = URL_SAFE_NO_PAD 229 229 .decode( 230 230 jwk.y 231 231 .as_ref() 232 232 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 233 233 ) 234 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 - ); 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); 240 245 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 241 246 let affine = 242 247 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 264 269 crv 265 270 ))); 266 271 } 267 - let x_bytes = URL_SAFE_NO_PAD 272 + let x_decoded = URL_SAFE_NO_PAD 268 273 .decode( 269 274 jwk.x 270 275 .as_ref() 271 276 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 272 277 ) 273 278 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 274 - let y_bytes = URL_SAFE_NO_PAD 279 + let y_decoded = URL_SAFE_NO_PAD 275 280 .decode( 276 281 jwk.y 277 282 .as_ref() 278 283 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 279 284 ) 280 285 .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 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); 286 296 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 287 297 let affine = 288 298 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 397 407 }; 398 408 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 399 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 + ); 400 458 } 401 459 }
+101 -62
crates/tranquil-pds/src/api/admin/account/info.rs
··· 130 130 db: &sqlx::PgPool, 131 131 user_id: uuid::Uuid, 132 132 ) -> Option<Vec<InviteCodeInfo>> { 133 - let codes = sqlx::query_scalar!( 133 + let invite_codes = sqlx::query!( 134 134 r#" 135 - SELECT code FROM invite_codes WHERE created_by_user = $1 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 136 139 "#, 137 140 user_id 138 141 ) 139 142 .fetch_all(db) 140 143 .await 141 144 .ok()?; 142 - if codes.is_empty() { 145 + 146 + if invite_codes.is_empty() { 143 147 return None; 144 148 } 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 - } 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 + 151 189 if invites.is_empty() { 152 190 None 153 191 } else { ··· 276 314 .map(|r| (r.used_by_user, r.code)) 277 315 .collect(); 278 316 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(), 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 288 326 }); 289 - } 290 327 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 - } 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 + ); 311 348 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 - } 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(); 335 374 (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() 336 375 }
+11 -24
crates/tranquil-pds/src/api/admin/config.rs
··· 48 48 .fetch_all(&state.db) 49 49 .await?; 50 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 - } 51 + let config_map: std::collections::HashMap<String, String> = 52 + rows.into_iter().collect(); 69 53 70 54 Ok(Json(ServerConfigResponse { 71 - server_name, 72 - primary_color, 73 - primary_color_dark, 74 - secondary_color, 75 - secondary_color_dark, 76 - logo_cid, 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(), 77 64 })) 78 65 } 79 66
+69 -54
crates/tranquil-pds/src/api/admin/invite.rs
··· 24 24 Json(input): Json<DisableInviteCodesInput>, 25 25 ) -> Response { 26 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 - } 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; 35 33 } 36 34 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 - } 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; 50 41 } 51 42 EmptyResponse::ok().into_response() 52 43 } ··· 70 61 pub uses: Vec<InviteCodeUseInfo>, 71 62 } 72 63 73 - #[derive(Serialize)] 64 + #[derive(Clone, Serialize)] 74 65 #[serde(rename_all = "camelCase")] 75 66 pub struct InviteCodeUseInfo { 76 67 pub used_by: String, ··· 149 140 return ApiError::InternalError(None).into_response(); 150 141 } 151 142 }; 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!( 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!( 162 165 r#" 163 - SELECT u.did, icu.used_at 166 + SELECT icu.code, u.did, icu.used_at 164 167 FROM invite_code_uses icu 165 168 JOIN users u ON icu.used_by_user = u.id 166 - WHERE icu.code = $1 169 + WHERE icu.code = ANY($1) 167 170 ORDER BY icu.used_at DESC 168 171 "#, 169 - code 172 + &code_strings 170 173 ) 171 174 .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, 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 + }); 191 186 }); 192 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 + 193 208 let next_cursor = if codes_rows.len() == limit as usize { 194 209 codes_rows.last().map(|(code, _, _, _, _)| code.clone()) 195 210 } else {
+26 -9
crates/tranquil-pds/src/api/error.rs
··· 22 22 InvalidRequest(String), 23 23 InvalidToken(Option<String>), 24 24 ExpiredToken(Option<String>), 25 + OAuthExpiredToken(Option<String>), 25 26 TokenRequired, 26 27 AccountDeactivated, 27 28 AccountTakedown, ··· 127 128 | Self::InvalidCode(_) 128 129 | Self::InvalidPassword(_) 129 130 | Self::InvalidToken(_) 130 - | Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED, 131 + | Self::PasskeyCounterAnomaly 132 + | Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED, 131 133 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 132 134 Self::Forbidden 133 135 | Self::AdminRequired ··· 216 218 Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 217 219 Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), 218 220 Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), 219 - Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 221 + Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 220 222 Self::TokenRequired => Cow::Borrowed("TokenRequired"), 221 223 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 222 224 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), ··· 298 300 | Self::AuthenticationFailed(msg) 299 301 | Self::InvalidToken(msg) 300 302 | Self::ExpiredToken(msg) 303 + | Self::OAuthExpiredToken(msg) 301 304 | Self::RepoNotFound(msg) 302 305 | Self::BlobNotFound(msg) 303 306 | Self::InvalidHandle(msg) ··· 428 431 message: self.message(), 429 432 }; 430 433 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 - ); 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 + _ => {} 438 452 } 439 453 response 440 454 } ··· 457 471 Self::AuthenticationFailed(None) 458 472 } 459 473 crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None), 474 + crate::auth::TokenValidationError::OAuthTokenExpired => { 475 + Self::OAuthExpiredToken(Some("Token has expired".to_string())) 476 + } 460 477 } 461 478 } 462 479 }
+1 -1
crates/tranquil-pds/src/api/moderation/mod.rs
··· 211 211 } 212 212 213 213 let created_at = chrono::Utc::now(); 214 - let report_id = created_at.timestamp_millis(); 214 + let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64; 215 215 let subject_json = json!(input.subject); 216 216 217 217 let insert = sqlx::query!(
+14 -25
crates/tranquil-pds/src/api/proxy.rs
··· 268 268 } 269 269 Err(e) => { 270 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; 271 + if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) { 272 + return ApiError::from(e).into_response(); 286 273 } 287 274 } 288 275 } ··· 291 278 if let Some(val) = auth_header_val { 292 279 request_builder = request_builder.header("Authorization", val); 293 280 } 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 - } 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 + }); 299 287 if !body.is_empty() { 300 288 request_builder = request_builder.body(body); 301 289 } ··· 313 301 } 314 302 }; 315 303 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 - } 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 + }); 321 310 match response_builder.body(axum::body::Body::from(body)) { 322 311 Ok(r) => r, 323 312 Err(e) => {
+7 -9
crates/tranquil-pds/src/api/proxy_client.rs
··· 88 88 Ok(addrs) => addrs.collect(), 89 89 Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())), 90 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 - } 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())); 100 98 } 101 99 Ok(()) 102 100 }
+1 -13
crates/tranquil-pds/src/api/repo/record/write.rs
··· 82 82 .await 83 83 .map_err(|e| { 84 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 85 + ApiError::from(e).into_response() 98 86 })?; 99 87 if repo.as_str() != auth_user.did.as_str() { 100 88 return Err(
+30 -38
crates/tranquil-pds/src/api/validation.rs
··· 181 181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") { 182 182 return Err(EmailValidationError::InvalidLocalPart); 183 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 - } 184 + if !local 185 + .chars() 186 + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 187 + { 188 + return Err(EmailValidationError::InvalidLocalPart); 188 189 } 189 190 if domain.is_empty() { 190 191 return Err(EmailValidationError::EmptyDomain); ··· 195 196 if !domain.contains('.') { 196 197 return Err(EmailValidationError::MissingDomainDot); 197 198 } 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 - } 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); 210 207 } 211 208 Ok(()) 212 209 } ··· 293 290 return Err(HandleValidationError::EndsWithInvalidChar); 294 291 } 295 292 296 - for c in handle.chars() { 297 - if !c.is_ascii_alphanumeric() && c != '-' { 298 - return Err(HandleValidationError::InvalidCharacters); 299 - } 293 + if !handle 294 + .chars() 295 + .all(|c| c.is_ascii_alphanumeric() || c == '-') 296 + { 297 + return Err(HandleValidationError::InvalidCharacters); 300 298 } 301 299 302 300 if crate::moderation::has_explicit_slur(handle) { ··· 330 328 if local.contains("..") { 331 329 return false; 332 330 } 333 - for c in local.chars() { 334 - if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 335 - return false; 336 - } 331 + if !local 332 + .chars() 333 + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 334 + { 335 + return false; 337 336 } 338 337 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 339 338 return false; ··· 341 340 if !domain.contains('.') { 342 341 return false; 343 342 } 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 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 + }) 358 350 } 359 351 360 352 #[cfg(test)]
+5 -2
crates/tranquil-pds/src/auth/mod.rs
··· 61 61 KeyDecryptionFailed, 62 62 AuthenticationFailed, 63 63 TokenExpired, 64 + OAuthTokenExpired, 64 65 } 65 66 66 67 impl fmt::Display for TokenValidationError { ··· 70 71 Self::AccountTakedown => write!(f, "AccountTakedown"), 71 72 Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"), 72 73 Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), 73 - Self::TokenExpired => write!(f, "ExpiredToken"), 74 + Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"), 74 75 } 75 76 } 76 77 } ··· 497 498 controller_did: None, 498 499 }) 499 500 } 500 - Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired), 501 + Err(crate::oauth::OAuthError::ExpiredToken(_)) => { 502 + Err(TokenValidationError::OAuthTokenExpired) 503 + } 501 504 Err(_) => Err(TokenValidationError::AuthenticationFailed), 502 505 } 503 506 }
+21 -18
crates/tranquil-pds/src/util.rs
··· 106 106 pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> { 107 107 query 108 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(); 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| { 116 117 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); 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] 125 129 } 126 - } 127 - } 128 - values 130 + }) 131 + .collect() 129 132 }) 130 133 .unwrap_or_default() 131 134 }
+1 -1
crates/tranquil-pds/tests/common/mod.rs
··· 437 437 async fn spawn_app(database_url: String) -> String { 438 438 use tranquil_pds::rate_limit::RateLimiters; 439 439 let pool = PgPoolOptions::new() 440 - .max_connections(3) 440 + .max_connections(10) 441 441 .acquire_timeout(std::time::Duration::from_secs(30)) 442 442 .connect(&database_url) 443 443 .await
+11 -7
crates/tranquil-pds/tests/helpers/mod.rs
··· 4 4 5 5 pub use crate::common::*; 6 6 7 + fn unique_id() -> String { 8 + uuid::Uuid::new_v4().simple().to_string()[..12].to_string() 9 + } 10 + 7 11 #[allow(dead_code)] 8 12 pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { 9 13 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); 14 + let uid = unique_id(); 15 + let handle = format!("{}-{}.test", handle_prefix, uid); 16 + let email = format!("{}-{}@test.com", handle_prefix, uid); 13 17 let password = "E2epass123!"; 14 18 let create_account_payload = json!({ 15 19 "handle": handle, ··· 51 55 text: &str, 52 56 ) -> (String, String) { 53 57 let collection = "app.bsky.feed.post"; 54 - let rkey = format!("e2e_social_{}", Utc::now().timestamp_millis()); 58 + let rkey = format!("e2e_social_{}", unique_id()); 55 59 let now = Utc::now().to_rfc3339(); 56 60 let create_payload = json!({ 57 61 "repo": did, ··· 95 99 followee_did: &str, 96 100 ) -> (String, String) { 97 101 let collection = "app.bsky.graph.follow"; 98 - let rkey = format!("e2e_follow_{}", Utc::now().timestamp_millis()); 102 + let rkey = format!("e2e_follow_{}", unique_id()); 99 103 let now = Utc::now().to_rfc3339(); 100 104 let create_payload = json!({ 101 105 "repo": follower_did, ··· 140 144 subject_cid: &str, 141 145 ) -> (String, String) { 142 146 let collection = "app.bsky.feed.like"; 143 - let rkey = format!("e2e_like_{}", Utc::now().timestamp_millis()); 147 + let rkey = format!("e2e_like_{}", unique_id()); 144 148 let now = Utc::now().to_rfc3339(); 145 149 let payload = json!({ 146 150 "repo": liker_did, ··· 182 186 subject_cid: &str, 183 187 ) -> (String, String) { 184 188 let collection = "app.bsky.feed.repost"; 185 - let rkey = format!("e2e_repost_{}", Utc::now().timestamp_millis()); 189 + let rkey = format!("e2e_repost_{}", unique_id()); 186 190 let now = Utc::now().to_rfc3339(); 187 191 let payload = json!({ 188 192 "repo": reposter_did,
+47 -48
crates/tranquil-scopes/src/parser.rs
··· 55 55 if self.accept.is_empty() || self.accept.contains("*/*") { 56 56 return true; 57 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 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 + }) 70 66 } 71 67 } 72 68 ··· 170 166 Some(rest.to_string()) 171 167 }; 172 168 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 - } 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 + }); 186 183 187 184 return ParsedScope::Repo(RepoScope { 188 185 collection, ··· 191 188 } 192 189 193 190 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 - } 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 + }); 207 205 return ParsedScope::Repo(RepoScope { 208 206 collection: None, 209 207 actions, ··· 212 210 213 211 if base.starts_with("blob") { 214 212 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 - } 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(); 225 224 226 225 return ParsedScope::Blob(BlobScope { accept }); 227 226 }
+78 -78
crates/tranquil-scopes/src/permissions.rs
··· 113 113 return Ok(()); 114 114 } 115 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(()); 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('.') 130 125 } 126 + _ => false, 131 127 } 132 - _ => {} 133 - } 134 - } 128 + }); 135 129 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 - }) 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 + } 144 142 } 145 143 146 144 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { ··· 148 146 return Ok(()); 149 147 } 150 148 151 - for blob_scope in self.find_blob_scopes() { 152 - if blob_scope.matches_mime(mime) { 153 - return Ok(()); 154 - } 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 + }) 155 156 } 156 - 157 - Err(ScopeError::InsufficientScope { 158 - required: format!("blob:{}", mime), 159 - message: format!("Insufficient scope to upload blob with mime type {}", mime), 160 - }) 161 157 } 162 158 163 159 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { ··· 169 165 return Ok(()); 170 166 } 171 167 172 - for rpc_scope in self.find_rpc_scopes() { 168 + let has_permission = self.find_rpc_scopes().any(|rpc_scope| { 173 169 let lxm_matches = match &rpc_scope.lxm { 174 170 None => true, 175 171 Some(scope_lxm) if scope_lxm == lxm => true, ··· 186 182 Some(scope_aud) => scope_aud == aud, 187 183 }; 188 184 189 - if lxm_matches && aud_matches { 190 - return Ok(()); 191 - } 192 - } 185 + lxm_matches && aud_matches 186 + }); 193 187 194 - Err(ScopeError::InsufficientScope { 195 - required: format!("rpc:{}?aud={}", lxm, aud), 196 - message: format!("Insufficient scope to call {} on {}", lxm, aud), 197 - }) 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 + } 198 196 } 199 197 200 198 pub fn assert_account( ··· 211 209 return Ok(()); 212 210 } 213 211 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 - } 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 + }); 222 217 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 - }) 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 + } 235 234 } 236 235 237 236 pub fn allows_email_read(&self) -> bool { ··· 264 263 return Ok(()); 265 264 } 266 265 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 - } 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 + }) 274 282 } 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 283 } 284 284 285 285 pub fn allows_identity(&self, attr: IdentityAttr) -> bool {