diff --git a/.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json b/.sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json similarity index 67% rename from .sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json rename to .sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json index 72ef1d5..d7fdd62 100644 --- a/.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json +++ b/.sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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", + "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", "describe": { "columns": [ { @@ -25,31 +25,46 @@ }, { "ordinal": 4, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "takedown_ref", + "type_info": "Text" + }, + { + "ordinal": 7, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 8, "name": "discord_verified", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 9, "name": "telegram_verified", "type_info": "Bool" }, { - "ordinal": 7, + "ordinal": 10, "name": "signal_verified", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 11, "name": "allow_legacy_login", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 12, "name": "preferred_comms_channel: crate::comms::CommsChannel", "type_info": { "Custom": { @@ -66,17 +81,17 @@ } }, { - "ordinal": 10, + "ordinal": 13, "name": "key_bytes", "type_info": "Bytea" }, { - "ordinal": 11, + "ordinal": 14, "name": "encryption_version", "type_info": "Int4" }, { - "ordinal": 12, + "ordinal": 15, "name": "totp_enabled", "type_info": "Bool" } @@ -91,6 +106,9 @@ false, false, true, + true, + true, + true, false, false, false, @@ -102,5 +120,5 @@ null ] }, - "hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38" + "hash": "1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce" } diff --git a/.sqlx/query-0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd.json b/.sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json similarity index 75% rename from .sqlx/query-0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd.json rename to .sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json index f511c6b..fd29910 100644 --- a/.sqlx/query-0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd.json +++ b/.sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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", + "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", "describe": { "columns": [ { @@ -30,11 +30,16 @@ }, { "ordinal": 5, + "name": "takedown_ref", + "type_info": "Text" + }, + { + "ordinal": 6, "name": "preferred_locale", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "preferred_channel: crate::comms::CommsChannel", "type_info": { "Custom": { @@ -51,17 +56,17 @@ } }, { - "ordinal": 7, + "ordinal": 8, "name": "discord_verified", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "telegram_verified", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 10, "name": "signal_verified", "type_info": "Bool" } @@ -78,11 +83,12 @@ false, true, true, + true, false, false, false, false ] }, - "hash": "0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd" + "hash": "c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782" } diff --git a/.sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json b/.sqlx/query-d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a.json similarity index 69% rename from .sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json rename to .sqlx/query-d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a.json index 28de5f1..cb050dd 100644 --- a/.sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json +++ b/.sqlx/query-d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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", + "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", "describe": { "columns": [ { @@ -30,6 +30,16 @@ }, { "ordinal": 5, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "takedown_ref", + "type_info": "Text" + }, + { + "ordinal": 7, "name": "preferred_channel: crate::comms::CommsChannel", "type_info": { "Custom": { @@ -46,17 +56,17 @@ } }, { - "ordinal": 6, + "ordinal": 8, "name": "discord_verified", "type_info": "Bool" }, { - "ordinal": 7, + "ordinal": 9, "name": "telegram_verified", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 10, "name": "signal_verified", "type_info": "Bool" } @@ -72,11 +82,13 @@ false, false, true, + true, + true, false, false, false, false ] }, - "hash": "7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c" + "hash": "d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a" } diff --git a/.sqlx/query-e223898d53602c1c8b23eb08a4b96cf20ac349d1fa4e91334b225d3069209dcf.json b/.sqlx/query-e223898d53602c1c8b23eb08a4b96cf20ac349d1fa4e91334b225d3069209dcf.json deleted file mode 100644 index 22bbecc..0000000 --- a/.sqlx/query-e223898d53602c1c8b23eb08a4b96cf20ac349d1fa4e91334b225d3069209dcf.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT handle FROM users WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "handle", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "e223898d53602c1c8b23eb08a4b96cf20ac349d1fa4e91334b225d3069209dcf" -} diff --git a/.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json b/.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json deleted file mode 100644 index 3db314f..0000000 --- a/.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "handle", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "deactivated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" -} diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 972cf55..8cfb784 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -43,9 +43,12 @@ fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { } #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CreateSessionInput { pub identifier: String, pub password: String, + #[serde(default)] + pub allow_takendown: bool, } #[derive(Serialize)] @@ -55,6 +58,16 @@ pub struct CreateSessionOutput { pub refresh_jwt: String, pub handle: String, pub did: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub did_doc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_confirmed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, } pub async fn create_session( @@ -89,7 +102,7 @@ pub async fn create_session( ); let row = match sqlx::query!( r#"SELECT - u.id, u.did, u.handle, u.password_hash, + u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref, u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, u.allow_legacy_login, u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", @@ -157,6 +170,18 @@ pub async fn create_session( return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) .into_response(); } + let is_takendown = row.takedown_ref.is_some(); + if is_takendown && !input.allow_takendown { + warn!("Login attempt for takendown account: {}", row.did); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "AccountTakedown", + "message": "Account has been taken down" + })), + ) + .into_response(); + } let is_verified = row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did) @@ -207,21 +232,25 @@ pub async fn create_session( return ApiError::InternalError.into_response(); } }; - if let Err(e) = sqlx::query!( - "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)", - row.did, - access_meta.jti, - refresh_meta.jti, - access_meta.expires_at, - refresh_meta.expires_at, - is_legacy_login, - false, - app_password_scopes, - app_password_controller - ) - .execute(&state.db) - .await - { + let did_for_doc = row.did.clone(); + let did_resolver = state.did_resolver.clone(); + let (insert_result, did_doc) = tokio::join!( + sqlx::query!( + "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)", + row.did, + access_meta.jti, + refresh_meta.jti, + access_meta.expires_at, + refresh_meta.expires_at, + is_legacy_login, + false, + app_password_scopes, + app_password_controller + ) + .execute(&state.db), + did_resolver.resolve_did_document(&did_for_doc) + ); + if let Err(e) = insert_result { error!("Failed to insert session: {:?}", e); return ApiError::InternalError.into_response(); } @@ -245,11 +274,24 @@ pub async fn create_session( } } let handle = full_handle(&row.handle, &pds_hostname); + let is_active = row.deactivated_at.is_none() && !is_takendown; + let status = if is_takendown { + Some("takendown".to_string()) + } else if row.deactivated_at.is_some() { + Some("deactivated".to_string()) + } else { + None + }; Json(CreateSessionOutput { access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, handle, did: row.did, + did_doc, + email: row.email, + email_confirmed: Some(row.email_verified), + active: Some(is_active), + status, }) .into_response() } @@ -261,17 +303,21 @@ pub async fn get_session( let permissions = auth_user.permissions(); let can_read_email = permissions.allows_email_read(); - match sqlx::query!( - r#"SELECT - handle, email, email_verified, is_admin, deactivated_at, preferred_locale, - preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", - discord_verified, telegram_verified, signal_verified - FROM users WHERE did = $1"#, - auth_user.did - ) - .fetch_optional(&state.db) - .await - { + let did_for_doc = auth_user.did.clone(); + let did_resolver = state.did_resolver.clone(); + let (db_result, did_doc) = tokio::join!( + sqlx::query!( + r#"SELECT + handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale, + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", + discord_verified, telegram_verified, signal_verified + FROM users WHERE did = $1"#, + auth_user.did + ) + .fetch_optional(&state.db), + did_resolver.resolve_did_document(&did_for_doc) + ); + match db_result { Ok(Some(row)) => { let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { crate::comms::CommsChannel::Email => ("email", row.email_verified), @@ -282,26 +328,36 @@ pub async fn get_session( let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = full_handle(&row.handle, &pds_hostname); - let is_active = row.deactivated_at.is_none(); + let is_takendown = row.takedown_ref.is_some(); + let is_active = row.deactivated_at.is_none() && !is_takendown; let email_value = if can_read_email { row.email.clone() } else { None }; - let email_verified_value = can_read_email && row.email_verified; - Json(json!({ + let email_confirmed_value = can_read_email && row.email_verified; + let mut response = json!({ "handle": handle, "did": auth_user.did, - "email": email_value, - "emailVerified": email_verified_value, + "active": is_active, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "preferredLocale": row.preferred_locale, - "isAdmin": row.is_admin, - "active": is_active, - "status": if is_active { "active" } else { "deactivated" }, - "didDoc": {} - })) + "isAdmin": row.is_admin + }); + if can_read_email { + response["email"] = json!(email_value); + response["emailConfirmed"] = json!(email_confirmed_value); + } + if is_takendown { + response["status"] = json!("takendown"); + } else if row.deactivated_at.is_some() { + response["status"] = json!("deactivated"); + } + if let Some(doc) = did_doc { + response["didDoc"] = doc; + } + Json(response) .into_response() } Ok(None) => ApiError::AuthenticationFailed.into_response(), @@ -498,17 +554,21 @@ pub async fn refresh_session( error!("Failed to commit transaction: {:?}", e); return ApiError::InternalError.into_response(); } - match sqlx::query!( - r#"SELECT - handle, email, email_verified, is_admin, preferred_locale, - preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", - discord_verified, telegram_verified, signal_verified - FROM users WHERE did = $1"#, - session_row.did - ) - .fetch_optional(&state.db) - .await - { + let did_for_doc = session_row.did.clone(); + let did_resolver = state.did_resolver.clone(); + let (db_result, did_doc) = tokio::join!( + sqlx::query!( + r#"SELECT + handle, email, email_verified, is_admin, preferred_locale, deactivated_at, takedown_ref, + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", + discord_verified, telegram_verified, signal_verified + FROM users WHERE did = $1"#, + session_row.did + ) + .fetch_optional(&state.db), + did_resolver.resolve_did_document(&did_for_doc) + ); + match db_result { Ok(Some(u)) => { let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { crate::comms::CommsChannel::Email => ("email", u.email_verified), @@ -519,19 +579,30 @@ pub async fn refresh_session( let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = full_handle(&u.handle, &pds_hostname); - Json(json!({ + let is_takendown = u.takedown_ref.is_some(); + let is_active = u.deactivated_at.is_none() && !is_takendown; + let mut response = json!({ "accessJwt": new_access_meta.token, "refreshJwt": new_refresh_meta.token, "handle": handle, "did": session_row.did, "email": u.email, - "emailVerified": u.email_verified, + "emailConfirmed": u.email_verified, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "preferredLocale": u.preferred_locale, "isAdmin": u.is_admin, - "active": true - })) + "active": is_active + }); + if let Some(doc) = did_doc { + response["didDoc"] = doc; + } + if is_takendown { + response["status"] = json!("takendown"); + } else if u.deactivated_at.is_some() { + response["status"] = json!("deactivated"); + } + Json(response) .into_response() } Ok(None) => { diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 213321a..d58b840 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -29,6 +29,12 @@ struct CachedDid { resolved_at: Instant, } +#[derive(Clone)] +struct CachedDidDocument { + document: serde_json::Value, + resolved_at: Instant, +} + #[derive(Debug, Clone)] pub struct ResolvedService { pub url: String, @@ -37,6 +43,7 @@ pub struct ResolvedService { pub struct DidResolver { did_cache: RwLock>, + did_doc_cache: RwLock>, client: Client, cache_ttl: Duration, plc_directory_url: String, @@ -46,6 +53,7 @@ impl Clone for DidResolver { fn clone(&self) -> Self { Self { did_cache: RwLock::new(HashMap::new()), + did_doc_cache: RwLock::new(HashMap::new()), client: self.client.clone(), cache_ttl: self.cache_ttl, plc_directory_url: self.plc_directory_url.clone(), @@ -74,12 +82,60 @@ impl DidResolver { Self { did_cache: RwLock::new(HashMap::new()), + did_doc_cache: RwLock::new(HashMap::new()), client, cache_ttl: Duration::from_secs(cache_ttl_secs), plc_directory_url, } } + fn build_did_web_url(did: &str) -> Result { + let host = did + .strip_prefix("did:web:") + .ok_or("Invalid did:web format")?; + + let (host, path) = if host.contains(':') { + let decoded = host.replace("%3A", ":"); + let parts: Vec<&str> = decoded.splitn(2, '/').collect(); + if parts.len() > 1 { + (parts[0].to_string(), format!("/{}", parts[1])) + } else { + (decoded, String::new()) + } + } else { + let parts: Vec<&str> = host.splitn(2, ':').collect(); + if parts.len() > 1 && parts[1].contains('/') { + let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); + if path_parts.len() > 1 { + ( + format!("{}:{}", parts[0], path_parts[0]), + format!("/{}", path_parts[1]), + ) + } else { + (host.to_string(), String::new()) + } + } else { + (host.to_string(), String::new()) + } + }; + + let scheme = + if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') + { + "http" + } else { + "https" + }; + + let url = if path.is_empty() { + format!("{}://{}/.well-known/did.json", scheme, host) + } else { + format!("{}://{}{}/did.json", scheme, host, path) + }; + + Ok(url) + } + pub async fn resolve_did(&self, did: &str) -> Option { { let cache = self.did_cache.read().await; @@ -140,48 +196,7 @@ impl DidResolver { } async fn resolve_did_web(&self, did: &str) -> Result { - let host = did - .strip_prefix("did:web:") - .ok_or("Invalid did:web format")?; - - let (host, path) = if host.contains(':') { - let decoded = host.replace("%3A", ":"); - let parts: Vec<&str> = decoded.splitn(2, '/').collect(); - if parts.len() > 1 { - (parts[0].to_string(), format!("/{}", parts[1])) - } else { - (decoded, String::new()) - } - } else { - let parts: Vec<&str> = host.splitn(2, ':').collect(); - if parts.len() > 1 && parts[1].contains('/') { - let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); - if path_parts.len() > 1 { - ( - format!("{}:{}", parts[0], path_parts[0]), - format!("/{}", path_parts[1]), - ) - } else { - (host.to_string(), String::new()) - } - } else { - (host.to_string(), String::new()) - } - }; - - let scheme = - if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') - { - "http" - } else { - "https" - }; - - let url = if path.is_empty() { - format!("{}://{}/.well-known/did.json", scheme, host) - } else { - format!("{}://{}{}/did.json", scheme, host, path) - }; + let url = Self::build_did_web_url(did)?; debug!("Resolving did:web {} via {}", did, url); @@ -286,9 +301,92 @@ impl DidResolver { None } + pub async fn resolve_did_document(&self, did: &str) -> Option { + { + let cache = self.did_doc_cache.read().await; + if let Some(cached) = cache.get(did) + && cached.resolved_at.elapsed() < self.cache_ttl + { + return Some(cached.document.clone()); + } + } + + let result = if did.starts_with("did:web:") { + self.fetch_did_document_web(did).await + } else if did.starts_with("did:plc:") { + self.fetch_did_document_plc(did).await + } else { + warn!("Unsupported DID method for document resolution: {}", did); + return None; + }; + + match result { + Ok(doc) => { + let mut cache = self.did_doc_cache.write().await; + cache.insert( + did.to_string(), + CachedDidDocument { + document: doc.clone(), + resolved_at: Instant::now(), + }, + ); + Some(doc) + } + Err(e) => { + warn!("Failed to resolve DID document for {}: {}", did, e); + None + } + } + } + + async fn fetch_did_document_web(&self, did: &str) -> Result { + let url = Self::build_did_web_url(did)?; + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse DID document: {}", e)) + } + + async fn fetch_did_document_plc(&self, did: &str) -> Result { + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err("DID not found".to_string()); + } + + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse DID document: {}", e)) + } + pub async fn invalidate_cache(&self, did: &str) { let mut cache = self.did_cache.write().await; cache.remove(did); + drop(cache); + let mut doc_cache = self.did_doc_cache.write().await; + doc_cache.remove(did); } }