this repo has no description

Session conf. vs ref

lewis c02136bc 2fe3a324

+15 -9
.sqlx/query-0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd.json .sqlx/query-d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 3 + "query": "SELECT\n handle, email, email_verified, is_admin, preferred_locale, deactivated_at, takedown_ref,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "deactivated_at", 29 - "type_info": "Timestamptz" 28 + "name": "preferred_locale", 29 + "type_info": "Varchar" 30 30 }, 31 31 { 32 32 "ordinal": 5, 33 - "name": "preferred_locale", 34 - "type_info": "Varchar" 33 + "name": "deactivated_at", 34 + "type_info": "Timestamptz" 35 35 }, 36 36 { 37 37 "ordinal": 6, 38 + "name": "takedown_ref", 39 + "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 38 43 "name": "preferred_channel: crate::comms::CommsChannel", 39 44 "type_info": { 40 45 "Custom": { ··· 51 56 } 52 57 }, 53 58 { 54 - "ordinal": 7, 59 + "ordinal": 8, 55 60 "name": "discord_verified", 56 61 "type_info": "Bool" 57 62 }, 58 63 { 59 - "ordinal": 8, 64 + "ordinal": 9, 60 65 "name": "telegram_verified", 61 66 "type_info": "Bool" 62 67 }, 63 68 { 64 - "ordinal": 9, 69 + "ordinal": 10, 65 70 "name": "signal_verified", 66 71 "type_info": "Bool" 67 72 } ··· 78 83 false, 79 84 true, 80 85 true, 86 + true, 81 87 false, 82 88 false, 83 89 false, 84 90 false 85 91 ] 86 92 }, 87 - "hash": "0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd" 93 + "hash": "d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a" 88 94 }
+18 -6
.sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json .sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n handle, email, email_verified, is_admin, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 3 + "query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 + "name": "deactivated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "takedown_ref", 34 + "type_info": "Text" 35 + }, 36 + { 37 + "ordinal": 6, 28 38 "name": "preferred_locale", 29 39 "type_info": "Varchar" 30 40 }, 31 41 { 32 - "ordinal": 5, 42 + "ordinal": 7, 33 43 "name": "preferred_channel: crate::comms::CommsChannel", 34 44 "type_info": { 35 45 "Custom": { ··· 46 56 } 47 57 }, 48 58 { 49 - "ordinal": 6, 59 + "ordinal": 8, 50 60 "name": "discord_verified", 51 61 "type_info": "Bool" 52 62 }, 53 63 { 54 - "ordinal": 7, 64 + "ordinal": 9, 55 65 "name": "telegram_verified", 56 66 "type_info": "Bool" 57 67 }, 58 68 { 59 - "ordinal": 8, 69 + "ordinal": 10, 60 70 "name": "signal_verified", 61 71 "type_info": "Bool" 62 72 } ··· 72 82 false, 73 83 false, 74 84 true, 85 + true, 86 + true, 75 87 false, 76 88 false, 77 89 false, 78 90 false 79 91 ] 80 92 }, 81 - "hash": "7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c" 93 + "hash": "c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782" 82 94 }
-22
.sqlx/query-e223898d53602c1c8b23eb08a4b96cf20ac349d1fa4e91334b225d3069209dcf.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT handle FROM users WHERE id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "handle", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "e223898d53602c1c8b23eb08a4b96cf20ac349d1fa4e91334b225d3069209dcf" 22 - }
-34
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "deactivated_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - true 31 - ] 32 - }, 33 - "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 - }
+28 -10
.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json .sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 3 + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 + "name": "email", 29 + "type_info": "Text" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "deactivated_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "takedown_ref", 39 + "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 28 43 "name": "email_verified", 29 44 "type_info": "Bool" 30 45 }, 31 46 { 32 - "ordinal": 5, 47 + "ordinal": 8, 33 48 "name": "discord_verified", 34 49 "type_info": "Bool" 35 50 }, 36 51 { 37 - "ordinal": 6, 52 + "ordinal": 9, 38 53 "name": "telegram_verified", 39 54 "type_info": "Bool" 40 55 }, 41 56 { 42 - "ordinal": 7, 57 + "ordinal": 10, 43 58 "name": "signal_verified", 44 59 "type_info": "Bool" 45 60 }, 46 61 { 47 - "ordinal": 8, 62 + "ordinal": 11, 48 63 "name": "allow_legacy_login", 49 64 "type_info": "Bool" 50 65 }, 51 66 { 52 - "ordinal": 9, 67 + "ordinal": 12, 53 68 "name": "preferred_comms_channel: crate::comms::CommsChannel", 54 69 "type_info": { 55 70 "Custom": { ··· 66 81 } 67 82 }, 68 83 { 69 - "ordinal": 10, 84 + "ordinal": 13, 70 85 "name": "key_bytes", 71 86 "type_info": "Bytea" 72 87 }, 73 88 { 74 - "ordinal": 11, 89 + "ordinal": 14, 75 90 "name": "encryption_version", 76 91 "type_info": "Int4" 77 92 }, 78 93 { 79 - "ordinal": 12, 94 + "ordinal": 15, 80 95 "name": "totp_enabled", 81 96 "type_info": "Bool" 82 97 } ··· 91 106 false, 92 107 false, 93 108 true, 109 + true, 110 + true, 111 + true, 94 112 false, 95 113 false, 96 114 false, ··· 102 120 null 103 121 ] 104 122 }, 105 - "hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38" 123 + "hash": "1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce" 106 124 }
+123 -52
src/api/server/session.rs
··· 43 43 } 44 44 45 45 #[derive(Deserialize)] 46 + #[serde(rename_all = "camelCase")] 46 47 pub struct CreateSessionInput { 47 48 pub identifier: String, 48 49 pub password: String, 50 + #[serde(default)] 51 + pub allow_takendown: bool, 49 52 } 50 53 51 54 #[derive(Serialize)] ··· 55 58 pub refresh_jwt: String, 56 59 pub handle: String, 57 60 pub did: String, 61 + #[serde(skip_serializing_if = "Option::is_none")] 62 + pub did_doc: Option<serde_json::Value>, 63 + #[serde(skip_serializing_if = "Option::is_none")] 64 + pub email: Option<String>, 65 + #[serde(skip_serializing_if = "Option::is_none")] 66 + pub email_confirmed: Option<bool>, 67 + #[serde(skip_serializing_if = "Option::is_none")] 68 + pub active: Option<bool>, 69 + #[serde(skip_serializing_if = "Option::is_none")] 70 + pub status: Option<String>, 58 71 } 59 72 60 73 pub async fn create_session( ··· 89 102 ); 90 103 let row = match sqlx::query!( 91 104 r#"SELECT 92 - u.id, u.did, u.handle, u.password_hash, 105 + u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref, 93 106 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 94 107 u.allow_legacy_login, 95 108 u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", ··· 157 170 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) 158 171 .into_response(); 159 172 } 173 + let is_takendown = row.takedown_ref.is_some(); 174 + if is_takendown && !input.allow_takendown { 175 + warn!("Login attempt for takendown account: {}", row.did); 176 + return ( 177 + StatusCode::UNAUTHORIZED, 178 + Json(json!({ 179 + "error": "AccountTakedown", 180 + "message": "Account has been taken down" 181 + })), 182 + ) 183 + .into_response(); 184 + } 160 185 let is_verified = 161 186 row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 162 187 let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did) ··· 207 232 return ApiError::InternalError.into_response(); 208 233 } 209 234 }; 210 - if let Err(e) = sqlx::query!( 211 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 212 - row.did, 213 - access_meta.jti, 214 - refresh_meta.jti, 215 - access_meta.expires_at, 216 - refresh_meta.expires_at, 217 - is_legacy_login, 218 - false, 219 - app_password_scopes, 220 - app_password_controller 221 - ) 222 - .execute(&state.db) 223 - .await 224 - { 235 + let did_for_doc = row.did.clone(); 236 + let did_resolver = state.did_resolver.clone(); 237 + let (insert_result, did_doc) = tokio::join!( 238 + sqlx::query!( 239 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 240 + row.did, 241 + access_meta.jti, 242 + refresh_meta.jti, 243 + access_meta.expires_at, 244 + refresh_meta.expires_at, 245 + is_legacy_login, 246 + false, 247 + app_password_scopes, 248 + app_password_controller 249 + ) 250 + .execute(&state.db), 251 + did_resolver.resolve_did_document(&did_for_doc) 252 + ); 253 + if let Err(e) = insert_result { 225 254 error!("Failed to insert session: {:?}", e); 226 255 return ApiError::InternalError.into_response(); 227 256 } ··· 245 274 } 246 275 } 247 276 let handle = full_handle(&row.handle, &pds_hostname); 277 + let is_active = row.deactivated_at.is_none() && !is_takendown; 278 + let status = if is_takendown { 279 + Some("takendown".to_string()) 280 + } else if row.deactivated_at.is_some() { 281 + Some("deactivated".to_string()) 282 + } else { 283 + None 284 + }; 248 285 Json(CreateSessionOutput { 249 286 access_jwt: access_meta.token, 250 287 refresh_jwt: refresh_meta.token, 251 288 handle, 252 289 did: row.did, 290 + did_doc, 291 + email: row.email, 292 + email_confirmed: Some(row.email_verified), 293 + active: Some(is_active), 294 + status, 253 295 }) 254 296 .into_response() 255 297 } ··· 261 303 let permissions = auth_user.permissions(); 262 304 let can_read_email = permissions.allows_email_read(); 263 305 264 - match sqlx::query!( 265 - r#"SELECT 266 - handle, email, email_verified, is_admin, deactivated_at, preferred_locale, 267 - preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 268 - discord_verified, telegram_verified, signal_verified 269 - FROM users WHERE did = $1"#, 270 - auth_user.did 271 - ) 272 - .fetch_optional(&state.db) 273 - .await 274 - { 306 + let did_for_doc = auth_user.did.clone(); 307 + let did_resolver = state.did_resolver.clone(); 308 + let (db_result, did_doc) = tokio::join!( 309 + sqlx::query!( 310 + r#"SELECT 311 + handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale, 312 + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 313 + discord_verified, telegram_verified, signal_verified 314 + FROM users WHERE did = $1"#, 315 + auth_user.did 316 + ) 317 + .fetch_optional(&state.db), 318 + did_resolver.resolve_did_document(&did_for_doc) 319 + ); 320 + match db_result { 275 321 Ok(Some(row)) => { 276 322 let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { 277 323 crate::comms::CommsChannel::Email => ("email", row.email_verified), ··· 282 328 let pds_hostname = 283 329 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 284 330 let handle = full_handle(&row.handle, &pds_hostname); 285 - let is_active = row.deactivated_at.is_none(); 331 + let is_takendown = row.takedown_ref.is_some(); 332 + let is_active = row.deactivated_at.is_none() && !is_takendown; 286 333 let email_value = if can_read_email { 287 334 row.email.clone() 288 335 } else { 289 336 None 290 337 }; 291 - let email_verified_value = can_read_email && row.email_verified; 292 - Json(json!({ 338 + let email_confirmed_value = can_read_email && row.email_verified; 339 + let mut response = json!({ 293 340 "handle": handle, 294 341 "did": auth_user.did, 295 - "email": email_value, 296 - "emailVerified": email_verified_value, 342 + "active": is_active, 297 343 "preferredChannel": preferred_channel, 298 344 "preferredChannelVerified": preferred_channel_verified, 299 345 "preferredLocale": row.preferred_locale, 300 - "isAdmin": row.is_admin, 301 - "active": is_active, 302 - "status": if is_active { "active" } else { "deactivated" }, 303 - "didDoc": {} 304 - })) 346 + "isAdmin": row.is_admin 347 + }); 348 + if can_read_email { 349 + response["email"] = json!(email_value); 350 + response["emailConfirmed"] = json!(email_confirmed_value); 351 + } 352 + if is_takendown { 353 + response["status"] = json!("takendown"); 354 + } else if row.deactivated_at.is_some() { 355 + response["status"] = json!("deactivated"); 356 + } 357 + if let Some(doc) = did_doc { 358 + response["didDoc"] = doc; 359 + } 360 + Json(response) 305 361 .into_response() 306 362 } 307 363 Ok(None) => ApiError::AuthenticationFailed.into_response(), ··· 498 554 error!("Failed to commit transaction: {:?}", e); 499 555 return ApiError::InternalError.into_response(); 500 556 } 501 - match sqlx::query!( 502 - r#"SELECT 503 - handle, email, email_verified, is_admin, preferred_locale, 504 - preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 505 - discord_verified, telegram_verified, signal_verified 506 - FROM users WHERE did = $1"#, 507 - session_row.did 508 - ) 509 - .fetch_optional(&state.db) 510 - .await 511 - { 557 + let did_for_doc = session_row.did.clone(); 558 + let did_resolver = state.did_resolver.clone(); 559 + let (db_result, did_doc) = tokio::join!( 560 + sqlx::query!( 561 + r#"SELECT 562 + handle, email, email_verified, is_admin, preferred_locale, deactivated_at, takedown_ref, 563 + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 564 + discord_verified, telegram_verified, signal_verified 565 + FROM users WHERE did = $1"#, 566 + session_row.did 567 + ) 568 + .fetch_optional(&state.db), 569 + did_resolver.resolve_did_document(&did_for_doc) 570 + ); 571 + match db_result { 512 572 Ok(Some(u)) => { 513 573 let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { 514 574 crate::comms::CommsChannel::Email => ("email", u.email_verified), ··· 519 579 let pds_hostname = 520 580 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 521 581 let handle = full_handle(&u.handle, &pds_hostname); 522 - Json(json!({ 582 + let is_takendown = u.takedown_ref.is_some(); 583 + let is_active = u.deactivated_at.is_none() && !is_takendown; 584 + let mut response = json!({ 523 585 "accessJwt": new_access_meta.token, 524 586 "refreshJwt": new_refresh_meta.token, 525 587 "handle": handle, 526 588 "did": session_row.did, 527 589 "email": u.email, 528 - "emailVerified": u.email_verified, 590 + "emailConfirmed": u.email_verified, 529 591 "preferredChannel": preferred_channel, 530 592 "preferredChannelVerified": preferred_channel_verified, 531 593 "preferredLocale": u.preferred_locale, 532 594 "isAdmin": u.is_admin, 533 - "active": true 534 - })) 595 + "active": is_active 596 + }); 597 + if let Some(doc) = did_doc { 598 + response["didDoc"] = doc; 599 + } 600 + if is_takendown { 601 + response["status"] = json!("takendown"); 602 + } else if u.deactivated_at.is_some() { 603 + response["status"] = json!("deactivated"); 604 + } 605 + Json(response) 535 606 .into_response() 536 607 } 537 608 Ok(None) => {
+140 -42
src/appview/mod.rs
··· 29 29 resolved_at: Instant, 30 30 } 31 31 32 + #[derive(Clone)] 33 + struct CachedDidDocument { 34 + document: serde_json::Value, 35 + resolved_at: Instant, 36 + } 37 + 32 38 #[derive(Debug, Clone)] 33 39 pub struct ResolvedService { 34 40 pub url: String, ··· 37 43 38 44 pub struct DidResolver { 39 45 did_cache: RwLock<HashMap<String, CachedDid>>, 46 + did_doc_cache: RwLock<HashMap<String, CachedDidDocument>>, 40 47 client: Client, 41 48 cache_ttl: Duration, 42 49 plc_directory_url: String, ··· 46 53 fn clone(&self) -> Self { 47 54 Self { 48 55 did_cache: RwLock::new(HashMap::new()), 56 + did_doc_cache: RwLock::new(HashMap::new()), 49 57 client: self.client.clone(), 50 58 cache_ttl: self.cache_ttl, 51 59 plc_directory_url: self.plc_directory_url.clone(), ··· 74 82 75 83 Self { 76 84 did_cache: RwLock::new(HashMap::new()), 85 + did_doc_cache: RwLock::new(HashMap::new()), 77 86 client, 78 87 cache_ttl: Duration::from_secs(cache_ttl_secs), 79 88 plc_directory_url, 80 89 } 81 90 } 82 91 92 + fn build_did_web_url(did: &str) -> Result<String, String> { 93 + let host = did 94 + .strip_prefix("did:web:") 95 + .ok_or("Invalid did:web format")?; 96 + 97 + let (host, path) = if host.contains(':') { 98 + let decoded = host.replace("%3A", ":"); 99 + let parts: Vec<&str> = decoded.splitn(2, '/').collect(); 100 + if parts.len() > 1 { 101 + (parts[0].to_string(), format!("/{}", parts[1])) 102 + } else { 103 + (decoded, String::new()) 104 + } 105 + } else { 106 + let parts: Vec<&str> = host.splitn(2, ':').collect(); 107 + if parts.len() > 1 && parts[1].contains('/') { 108 + let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); 109 + if path_parts.len() > 1 { 110 + ( 111 + format!("{}:{}", parts[0], path_parts[0]), 112 + format!("/{}", path_parts[1]), 113 + ) 114 + } else { 115 + (host.to_string(), String::new()) 116 + } 117 + } else { 118 + (host.to_string(), String::new()) 119 + } 120 + }; 121 + 122 + let scheme = 123 + if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') 124 + { 125 + "http" 126 + } else { 127 + "https" 128 + }; 129 + 130 + let url = if path.is_empty() { 131 + format!("{}://{}/.well-known/did.json", scheme, host) 132 + } else { 133 + format!("{}://{}{}/did.json", scheme, host, path) 134 + }; 135 + 136 + Ok(url) 137 + } 138 + 83 139 pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> { 84 140 { 85 141 let cache = self.did_cache.read().await; ··· 140 196 } 141 197 142 198 async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> { 143 - let host = did 144 - .strip_prefix("did:web:") 145 - .ok_or("Invalid did:web format")?; 146 - 147 - let (host, path) = if host.contains(':') { 148 - let decoded = host.replace("%3A", ":"); 149 - let parts: Vec<&str> = decoded.splitn(2, '/').collect(); 150 - if parts.len() > 1 { 151 - (parts[0].to_string(), format!("/{}", parts[1])) 152 - } else { 153 - (decoded, String::new()) 154 - } 155 - } else { 156 - let parts: Vec<&str> = host.splitn(2, ':').collect(); 157 - if parts.len() > 1 && parts[1].contains('/') { 158 - let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); 159 - if path_parts.len() > 1 { 160 - ( 161 - format!("{}:{}", parts[0], path_parts[0]), 162 - format!("/{}", path_parts[1]), 163 - ) 164 - } else { 165 - (host.to_string(), String::new()) 166 - } 167 - } else { 168 - (host.to_string(), String::new()) 169 - } 170 - }; 171 - 172 - let scheme = 173 - if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') 174 - { 175 - "http" 176 - } else { 177 - "https" 178 - }; 179 - 180 - let url = if path.is_empty() { 181 - format!("{}://{}/.well-known/did.json", scheme, host) 182 - } else { 183 - format!("{}://{}{}/did.json", scheme, host, path) 184 - }; 199 + let url = Self::build_did_web_url(did)?; 185 200 186 201 debug!("Resolving did:web {} via {}", did, url); 187 202 ··· 286 301 None 287 302 } 288 303 304 + pub async fn resolve_did_document(&self, did: &str) -> Option<serde_json::Value> { 305 + { 306 + let cache = self.did_doc_cache.read().await; 307 + if let Some(cached) = cache.get(did) 308 + && cached.resolved_at.elapsed() < self.cache_ttl 309 + { 310 + return Some(cached.document.clone()); 311 + } 312 + } 313 + 314 + let result = if did.starts_with("did:web:") { 315 + self.fetch_did_document_web(did).await 316 + } else if did.starts_with("did:plc:") { 317 + self.fetch_did_document_plc(did).await 318 + } else { 319 + warn!("Unsupported DID method for document resolution: {}", did); 320 + return None; 321 + }; 322 + 323 + match result { 324 + Ok(doc) => { 325 + let mut cache = self.did_doc_cache.write().await; 326 + cache.insert( 327 + did.to_string(), 328 + CachedDidDocument { 329 + document: doc.clone(), 330 + resolved_at: Instant::now(), 331 + }, 332 + ); 333 + Some(doc) 334 + } 335 + Err(e) => { 336 + warn!("Failed to resolve DID document for {}: {}", did, e); 337 + None 338 + } 339 + } 340 + } 341 + 342 + async fn fetch_did_document_web(&self, did: &str) -> Result<serde_json::Value, String> { 343 + let url = Self::build_did_web_url(did)?; 344 + 345 + let resp = self 346 + .client 347 + .get(&url) 348 + .send() 349 + .await 350 + .map_err(|e| format!("HTTP request failed: {}", e))?; 351 + 352 + if !resp.status().is_success() { 353 + return Err(format!("HTTP {}", resp.status())); 354 + } 355 + 356 + resp.json::<serde_json::Value>() 357 + .await 358 + .map_err(|e| format!("Failed to parse DID document: {}", e)) 359 + } 360 + 361 + async fn fetch_did_document_plc(&self, did: &str) -> Result<serde_json::Value, String> { 362 + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); 363 + 364 + let resp = self 365 + .client 366 + .get(&url) 367 + .send() 368 + .await 369 + .map_err(|e| format!("HTTP request failed: {}", e))?; 370 + 371 + if resp.status() == reqwest::StatusCode::NOT_FOUND { 372 + return Err("DID not found".to_string()); 373 + } 374 + 375 + if !resp.status().is_success() { 376 + return Err(format!("HTTP {}", resp.status())); 377 + } 378 + 379 + resp.json::<serde_json::Value>() 380 + .await 381 + .map_err(|e| format!("Failed to parse DID document: {}", e)) 382 + } 383 + 289 384 pub async fn invalidate_cache(&self, did: &str) { 290 385 let mut cache = self.did_cache.write().await; 291 386 cache.remove(did); 387 + drop(cache); 388 + let mut doc_cache = self.did_doc_cache.write().await; 389 + doc_cache.remove(did); 292 390 } 293 391 } 294 392