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.

Session conf. vs ref

+324 -175
+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