this repo has no description

Session conf. vs ref

lewis c02136bc 2fe3a324

+15 -9
.sqlx/query-0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd.json .sqlx/query-d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a.json
··· 1 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 27 "ordinal": 4, 28 - "name": "deactivated_at", 29 - "type_info": "Timestamptz" 30 }, 31 { 32 "ordinal": 5, 33 - "name": "preferred_locale", 34 - "type_info": "Varchar" 35 }, 36 { 37 "ordinal": 6, 38 "name": "preferred_channel: crate::comms::CommsChannel", 39 "type_info": { 40 "Custom": { ··· 51 } 52 }, 53 { 54 - "ordinal": 7, 55 "name": "discord_verified", 56 "type_info": "Bool" 57 }, 58 { 59 - "ordinal": 8, 60 "name": "telegram_verified", 61 "type_info": "Bool" 62 }, 63 { 64 - "ordinal": 9, 65 "name": "signal_verified", 66 "type_info": "Bool" 67 } ··· 78 false, 79 true, 80 true, 81 false, 82 false, 83 false, 84 false 85 ] 86 }, 87 - "hash": "0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd" 88 }
··· 1 { 2 "db_name": "PostgreSQL", 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 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 27 "ordinal": 4, 28 + "name": "preferred_locale", 29 + "type_info": "Varchar" 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, 43 "name": "preferred_channel: crate::comms::CommsChannel", 44 "type_info": { 45 "Custom": { ··· 56 } 57 }, 58 { 59 + "ordinal": 8, 60 "name": "discord_verified", 61 "type_info": "Bool" 62 }, 63 { 64 + "ordinal": 9, 65 "name": "telegram_verified", 66 "type_info": "Bool" 67 }, 68 { 69 + "ordinal": 10, 70 "name": "signal_verified", 71 "type_info": "Bool" 72 } ··· 83 false, 84 true, 85 true, 86 + true, 87 false, 88 false, 89 false, 90 false 91 ] 92 }, 93 + "hash": "d4e4c9de4330cc017f457eaec4195b0cf35607d2d0ef6b73e9bb5e94e7742e7a" 94 }
+18 -6
.sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json .sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json
··· 1 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 27 "ordinal": 4, 28 "name": "preferred_locale", 29 "type_info": "Varchar" 30 }, 31 { 32 - "ordinal": 5, 33 "name": "preferred_channel: crate::comms::CommsChannel", 34 "type_info": { 35 "Custom": { ··· 46 } 47 }, 48 { 49 - "ordinal": 6, 50 "name": "discord_verified", 51 "type_info": "Bool" 52 }, 53 { 54 - "ordinal": 7, 55 "name": "telegram_verified", 56 "type_info": "Bool" 57 }, 58 { 59 - "ordinal": 8, 60 "name": "signal_verified", 61 "type_info": "Bool" 62 } ··· 72 false, 73 false, 74 true, 75 false, 76 false, 77 false, 78 false 79 ] 80 }, 81 - "hash": "7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c" 82 }
··· 1 { 2 "db_name": "PostgreSQL", 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 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 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, 38 "name": "preferred_locale", 39 "type_info": "Varchar" 40 }, 41 { 42 + "ordinal": 7, 43 "name": "preferred_channel: crate::comms::CommsChannel", 44 "type_info": { 45 "Custom": { ··· 56 } 57 }, 58 { 59 + "ordinal": 8, 60 "name": "discord_verified", 61 "type_info": "Bool" 62 }, 63 { 64 + "ordinal": 9, 65 "name": "telegram_verified", 66 "type_info": "Bool" 67 }, 68 { 69 + "ordinal": 10, 70 "name": "signal_verified", 71 "type_info": "Bool" 72 } ··· 82 false, 83 false, 84 true, 85 + true, 86 + true, 87 false, 88 false, 89 false, 90 false 91 ] 92 }, 93 + "hash": "c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782" 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 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 27 "ordinal": 4, 28 "name": "email_verified", 29 "type_info": "Bool" 30 }, 31 { 32 - "ordinal": 5, 33 "name": "discord_verified", 34 "type_info": "Bool" 35 }, 36 { 37 - "ordinal": 6, 38 "name": "telegram_verified", 39 "type_info": "Bool" 40 }, 41 { 42 - "ordinal": 7, 43 "name": "signal_verified", 44 "type_info": "Bool" 45 }, 46 { 47 - "ordinal": 8, 48 "name": "allow_legacy_login", 49 "type_info": "Bool" 50 }, 51 { 52 - "ordinal": 9, 53 "name": "preferred_comms_channel: crate::comms::CommsChannel", 54 "type_info": { 55 "Custom": { ··· 66 } 67 }, 68 { 69 - "ordinal": 10, 70 "name": "key_bytes", 71 "type_info": "Bytea" 72 }, 73 { 74 - "ordinal": 11, 75 "name": "encryption_version", 76 "type_info": "Int4" 77 }, 78 { 79 - "ordinal": 12, 80 "name": "totp_enabled", 81 "type_info": "Bool" 82 } ··· 91 false, 92 false, 93 true, 94 false, 95 false, 96 false, ··· 102 null 103 ] 104 }, 105 - "hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38" 106 }
··· 1 { 2 "db_name": "PostgreSQL", 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 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 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, 43 "name": "email_verified", 44 "type_info": "Bool" 45 }, 46 { 47 + "ordinal": 8, 48 "name": "discord_verified", 49 "type_info": "Bool" 50 }, 51 { 52 + "ordinal": 9, 53 "name": "telegram_verified", 54 "type_info": "Bool" 55 }, 56 { 57 + "ordinal": 10, 58 "name": "signal_verified", 59 "type_info": "Bool" 60 }, 61 { 62 + "ordinal": 11, 63 "name": "allow_legacy_login", 64 "type_info": "Bool" 65 }, 66 { 67 + "ordinal": 12, 68 "name": "preferred_comms_channel: crate::comms::CommsChannel", 69 "type_info": { 70 "Custom": { ··· 81 } 82 }, 83 { 84 + "ordinal": 13, 85 "name": "key_bytes", 86 "type_info": "Bytea" 87 }, 88 { 89 + "ordinal": 14, 90 "name": "encryption_version", 91 "type_info": "Int4" 92 }, 93 { 94 + "ordinal": 15, 95 "name": "totp_enabled", 96 "type_info": "Bool" 97 } ··· 106 false, 107 false, 108 true, 109 + true, 110 + true, 111 + true, 112 false, 113 false, 114 false, ··· 120 null 121 ] 122 }, 123 + "hash": "1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce" 124 }
+123 -52
src/api/server/session.rs
··· 43 } 44 45 #[derive(Deserialize)] 46 pub struct CreateSessionInput { 47 pub identifier: String, 48 pub password: String, 49 } 50 51 #[derive(Serialize)] ··· 55 pub refresh_jwt: String, 56 pub handle: String, 57 pub did: String, 58 } 59 60 pub async fn create_session( ··· 89 ); 90 let row = match sqlx::query!( 91 r#"SELECT 92 - u.id, u.did, u.handle, u.password_hash, 93 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 94 u.allow_legacy_login, 95 u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", ··· 157 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) 158 .into_response(); 159 } 160 let is_verified = 161 row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 162 let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did) ··· 207 return ApiError::InternalError.into_response(); 208 } 209 }; 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 - { 225 error!("Failed to insert session: {:?}", e); 226 return ApiError::InternalError.into_response(); 227 } ··· 245 } 246 } 247 let handle = full_handle(&row.handle, &pds_hostname); 248 Json(CreateSessionOutput { 249 access_jwt: access_meta.token, 250 refresh_jwt: refresh_meta.token, 251 handle, 252 did: row.did, 253 }) 254 .into_response() 255 } ··· 261 let permissions = auth_user.permissions(); 262 let can_read_email = permissions.allows_email_read(); 263 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 - { 275 Ok(Some(row)) => { 276 let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { 277 crate::comms::CommsChannel::Email => ("email", row.email_verified), ··· 282 let pds_hostname = 283 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 284 let handle = full_handle(&row.handle, &pds_hostname); 285 - let is_active = row.deactivated_at.is_none(); 286 let email_value = if can_read_email { 287 row.email.clone() 288 } else { 289 None 290 }; 291 - let email_verified_value = can_read_email && row.email_verified; 292 - Json(json!({ 293 "handle": handle, 294 "did": auth_user.did, 295 - "email": email_value, 296 - "emailVerified": email_verified_value, 297 "preferredChannel": preferred_channel, 298 "preferredChannelVerified": preferred_channel_verified, 299 "preferredLocale": row.preferred_locale, 300 - "isAdmin": row.is_admin, 301 - "active": is_active, 302 - "status": if is_active { "active" } else { "deactivated" }, 303 - "didDoc": {} 304 - })) 305 .into_response() 306 } 307 Ok(None) => ApiError::AuthenticationFailed.into_response(), ··· 498 error!("Failed to commit transaction: {:?}", e); 499 return ApiError::InternalError.into_response(); 500 } 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 - { 512 Ok(Some(u)) => { 513 let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { 514 crate::comms::CommsChannel::Email => ("email", u.email_verified), ··· 519 let pds_hostname = 520 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 521 let handle = full_handle(&u.handle, &pds_hostname); 522 - Json(json!({ 523 "accessJwt": new_access_meta.token, 524 "refreshJwt": new_refresh_meta.token, 525 "handle": handle, 526 "did": session_row.did, 527 "email": u.email, 528 - "emailVerified": u.email_verified, 529 "preferredChannel": preferred_channel, 530 "preferredChannelVerified": preferred_channel_verified, 531 "preferredLocale": u.preferred_locale, 532 "isAdmin": u.is_admin, 533 - "active": true 534 - })) 535 .into_response() 536 } 537 Ok(None) => {
··· 43 } 44 45 #[derive(Deserialize)] 46 + #[serde(rename_all = "camelCase")] 47 pub struct CreateSessionInput { 48 pub identifier: String, 49 pub password: String, 50 + #[serde(default)] 51 + pub allow_takendown: bool, 52 } 53 54 #[derive(Serialize)] ··· 58 pub refresh_jwt: String, 59 pub handle: String, 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>, 71 } 72 73 pub async fn create_session( ··· 102 ); 103 let row = match sqlx::query!( 104 r#"SELECT 105 + u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref, 106 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 107 u.allow_legacy_login, 108 u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", ··· 170 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) 171 .into_response(); 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 + } 185 let is_verified = 186 row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 187 let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did) ··· 232 return ApiError::InternalError.into_response(); 233 } 234 }; 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 { 254 error!("Failed to insert session: {:?}", e); 255 return ApiError::InternalError.into_response(); 256 } ··· 274 } 275 } 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 + }; 285 Json(CreateSessionOutput { 286 access_jwt: access_meta.token, 287 refresh_jwt: refresh_meta.token, 288 handle, 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, 295 }) 296 .into_response() 297 } ··· 303 let permissions = auth_user.permissions(); 304 let can_read_email = permissions.allows_email_read(); 305 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 { 321 Ok(Some(row)) => { 322 let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { 323 crate::comms::CommsChannel::Email => ("email", row.email_verified), ··· 328 let pds_hostname = 329 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 330 let handle = full_handle(&row.handle, &pds_hostname); 331 + let is_takendown = row.takedown_ref.is_some(); 332 + let is_active = row.deactivated_at.is_none() && !is_takendown; 333 let email_value = if can_read_email { 334 row.email.clone() 335 } else { 336 None 337 }; 338 + let email_confirmed_value = can_read_email && row.email_verified; 339 + let mut response = json!({ 340 "handle": handle, 341 "did": auth_user.did, 342 + "active": is_active, 343 "preferredChannel": preferred_channel, 344 "preferredChannelVerified": preferred_channel_verified, 345 "preferredLocale": row.preferred_locale, 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) 361 .into_response() 362 } 363 Ok(None) => ApiError::AuthenticationFailed.into_response(), ··· 554 error!("Failed to commit transaction: {:?}", e); 555 return ApiError::InternalError.into_response(); 556 } 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 { 572 Ok(Some(u)) => { 573 let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { 574 crate::comms::CommsChannel::Email => ("email", u.email_verified), ··· 579 let pds_hostname = 580 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 581 let handle = full_handle(&u.handle, &pds_hostname); 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!({ 585 "accessJwt": new_access_meta.token, 586 "refreshJwt": new_refresh_meta.token, 587 "handle": handle, 588 "did": session_row.did, 589 "email": u.email, 590 + "emailConfirmed": u.email_verified, 591 "preferredChannel": preferred_channel, 592 "preferredChannelVerified": preferred_channel_verified, 593 "preferredLocale": u.preferred_locale, 594 "isAdmin": u.is_admin, 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) 606 .into_response() 607 } 608 Ok(None) => {
+140 -42
src/appview/mod.rs
··· 29 resolved_at: Instant, 30 } 31 32 #[derive(Debug, Clone)] 33 pub struct ResolvedService { 34 pub url: String, ··· 37 38 pub struct DidResolver { 39 did_cache: RwLock<HashMap<String, CachedDid>>, 40 client: Client, 41 cache_ttl: Duration, 42 plc_directory_url: String, ··· 46 fn clone(&self) -> Self { 47 Self { 48 did_cache: RwLock::new(HashMap::new()), 49 client: self.client.clone(), 50 cache_ttl: self.cache_ttl, 51 plc_directory_url: self.plc_directory_url.clone(), ··· 74 75 Self { 76 did_cache: RwLock::new(HashMap::new()), 77 client, 78 cache_ttl: Duration::from_secs(cache_ttl_secs), 79 plc_directory_url, 80 } 81 } 82 83 pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> { 84 { 85 let cache = self.did_cache.read().await; ··· 140 } 141 142 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 - }; 185 186 debug!("Resolving did:web {} via {}", did, url); 187 ··· 286 None 287 } 288 289 pub async fn invalidate_cache(&self, did: &str) { 290 let mut cache = self.did_cache.write().await; 291 cache.remove(did); 292 } 293 } 294
··· 29 resolved_at: Instant, 30 } 31 32 + #[derive(Clone)] 33 + struct CachedDidDocument { 34 + document: serde_json::Value, 35 + resolved_at: Instant, 36 + } 37 + 38 #[derive(Debug, Clone)] 39 pub struct ResolvedService { 40 pub url: String, ··· 43 44 pub struct DidResolver { 45 did_cache: RwLock<HashMap<String, CachedDid>>, 46 + did_doc_cache: RwLock<HashMap<String, CachedDidDocument>>, 47 client: Client, 48 cache_ttl: Duration, 49 plc_directory_url: String, ··· 53 fn clone(&self) -> Self { 54 Self { 55 did_cache: RwLock::new(HashMap::new()), 56 + did_doc_cache: RwLock::new(HashMap::new()), 57 client: self.client.clone(), 58 cache_ttl: self.cache_ttl, 59 plc_directory_url: self.plc_directory_url.clone(), ··· 82 83 Self { 84 did_cache: RwLock::new(HashMap::new()), 85 + did_doc_cache: RwLock::new(HashMap::new()), 86 client, 87 cache_ttl: Duration::from_secs(cache_ttl_secs), 88 plc_directory_url, 89 } 90 } 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 + 139 pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> { 140 { 141 let cache = self.did_cache.read().await; ··· 196 } 197 198 async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> { 199 + let url = Self::build_did_web_url(did)?; 200 201 debug!("Resolving did:web {} via {}", did, url); 202 ··· 301 None 302 } 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 + 384 pub async fn invalidate_cache(&self, did: &str) { 385 let mut cache = self.did_cache.write().await; 386 cache.remove(did); 387 + drop(cache); 388 + let mut doc_cache = self.did_doc_cache.write().await; 389 + doc_cache.remove(did); 390 } 391 } 392