this repo has no description
1use crate::api::ApiError; 2use crate::state::AppState; 3use axum::{ 4 Json, 5 extract::State, 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8}; 9use chrono::{DateTime, Utc}; 10use serde::{Deserialize, Serialize}; 11use serde_json::json; 12 13#[derive(Serialize)] 14#[serde(rename_all = "camelCase")] 15pub struct GetMigrationStatusOutput { 16 pub did: String, 17 pub did_type: String, 18 pub migrated: bool, 19 #[serde(skip_serializing_if = "Option::is_none")] 20 pub migrated_to_pds: Option<String>, 21 #[serde(skip_serializing_if = "Option::is_none")] 22 pub migrated_at: Option<DateTime<Utc>>, 23} 24 25pub async fn get_migration_status( 26 State(state): State<AppState>, 27 headers: axum::http::HeaderMap, 28) -> Response { 29 let extracted = match crate::auth::extract_auth_token_from_header( 30 headers.get("Authorization").and_then(|h| h.to_str().ok()), 31 ) { 32 Some(t) => t, 33 None => return ApiError::AuthenticationRequired.into_response(), 34 }; 35 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 36 let http_uri = format!( 37 "https://{}/xrpc/com.tranquil.account.getMigrationStatus", 38 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 39 ); 40 let auth_user = match crate::auth::validate_token_with_dpop( 41 &state.db, 42 &extracted.token, 43 extracted.is_dpop, 44 dpop_proof, 45 "GET", 46 &http_uri, 47 true, 48 ) 49 .await 50 { 51 Ok(user) => user, 52 Err(e) => return ApiError::from(e).into_response(), 53 }; 54 let user = match sqlx::query!( 55 "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 56 auth_user.did 57 ) 58 .fetch_optional(&state.db) 59 .await 60 { 61 Ok(Some(row)) => row, 62 Ok(None) => return ApiError::AccountNotFound.into_response(), 63 Err(e) => { 64 tracing::error!("DB error getting migration status: {:?}", e); 65 return ApiError::InternalError.into_response(); 66 } 67 }; 68 let did_type = if user.did.starts_with("did:plc:") { 69 "plc" 70 } else if user.did.starts_with("did:web:") { 71 "web" 72 } else { 73 "unknown" 74 }; 75 let migrated = user.migrated_to_pds.is_some(); 76 ( 77 StatusCode::OK, 78 Json(GetMigrationStatusOutput { 79 did: user.did, 80 did_type: did_type.to_string(), 81 migrated, 82 migrated_to_pds: user.migrated_to_pds, 83 migrated_at: user.migrated_at, 84 }), 85 ) 86 .into_response() 87} 88 89#[derive(Deserialize)] 90#[serde(rename_all = "camelCase")] 91pub struct UpdateMigrationForwardingInput { 92 pub pds_url: String, 93} 94 95#[derive(Serialize)] 96#[serde(rename_all = "camelCase")] 97pub struct UpdateMigrationForwardingOutput { 98 pub success: bool, 99 pub migrated_to_pds: String, 100 pub migrated_at: DateTime<Utc>, 101} 102 103pub async fn update_migration_forwarding( 104 State(state): State<AppState>, 105 headers: axum::http::HeaderMap, 106 Json(input): Json<UpdateMigrationForwardingInput>, 107) -> Response { 108 let extracted = match crate::auth::extract_auth_token_from_header( 109 headers.get("Authorization").and_then(|h| h.to_str().ok()), 110 ) { 111 Some(t) => t, 112 None => return ApiError::AuthenticationRequired.into_response(), 113 }; 114 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 115 let http_uri = format!( 116 "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding", 117 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 118 ); 119 let auth_user = match crate::auth::validate_token_with_dpop( 120 &state.db, 121 &extracted.token, 122 extracted.is_dpop, 123 dpop_proof, 124 "POST", 125 &http_uri, 126 true, 127 ) 128 .await 129 { 130 Ok(user) => user, 131 Err(e) => return ApiError::from(e).into_response(), 132 }; 133 if !auth_user.did.starts_with("did:web:") { 134 return ( 135 StatusCode::BAD_REQUEST, 136 Json(json!({ 137 "error": "InvalidRequest", 138 "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates." 139 })), 140 ) 141 .into_response(); 142 } 143 let pds_url = input.pds_url.trim(); 144 if pds_url.is_empty() { 145 return ApiError::InvalidRequest("pds_url is required".into()).into_response(); 146 } 147 if !pds_url.starts_with("https://") { 148 return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response(); 149 } 150 let pds_url_clean = pds_url.trim_end_matches('/'); 151 let now = Utc::now(); 152 let result = sqlx::query!( 153 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 154 pds_url_clean, 155 now, 156 auth_user.did 157 ) 158 .execute(&state.db) 159 .await; 160 match result { 161 Ok(_) => { 162 tracing::info!( 163 "Updated migration forwarding for {} to {}", 164 auth_user.did, 165 pds_url_clean 166 ); 167 ( 168 StatusCode::OK, 169 Json(UpdateMigrationForwardingOutput { 170 success: true, 171 migrated_to_pds: pds_url_clean.to_string(), 172 migrated_at: now, 173 }), 174 ) 175 .into_response() 176 } 177 Err(e) => { 178 tracing::error!("DB error updating migration forwarding: {:?}", e); 179 ApiError::InternalError.into_response() 180 } 181 } 182} 183 184pub async fn clear_migration_forwarding( 185 State(state): State<AppState>, 186 headers: axum::http::HeaderMap, 187) -> Response { 188 let extracted = match crate::auth::extract_auth_token_from_header( 189 headers.get("Authorization").and_then(|h| h.to_str().ok()), 190 ) { 191 Some(t) => t, 192 None => return ApiError::AuthenticationRequired.into_response(), 193 }; 194 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 195 let http_uri = format!( 196 "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding", 197 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 198 ); 199 let auth_user = match crate::auth::validate_token_with_dpop( 200 &state.db, 201 &extracted.token, 202 extracted.is_dpop, 203 dpop_proof, 204 "POST", 205 &http_uri, 206 true, 207 ) 208 .await 209 { 210 Ok(user) => user, 211 Err(e) => return ApiError::from(e).into_response(), 212 }; 213 if !auth_user.did.starts_with("did:web:") { 214 return ( 215 StatusCode::BAD_REQUEST, 216 Json(json!({ 217 "error": "InvalidRequest", 218 "message": "Migration forwarding is only available for did:web accounts" 219 })), 220 ) 221 .into_response(); 222 } 223 let result = sqlx::query!( 224 "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 225 auth_user.did 226 ) 227 .execute(&state.db) 228 .await; 229 match result { 230 Ok(_) => { 231 tracing::info!("Cleared migration forwarding for {}", auth_user.did); 232 (StatusCode::OK, Json(json!({ "success": true }))).into_response() 233 } 234 Err(e) => { 235 tracing::error!("DB error clearing migration forwarding: {:?}", e); 236 ApiError::InternalError.into_response() 237 } 238 } 239} 240 241#[derive(Debug, Clone, Serialize, Deserialize)] 242#[serde(rename_all = "camelCase")] 243pub struct VerificationMethod { 244 pub id: String, 245 #[serde(rename = "type")] 246 pub method_type: String, 247 pub public_key_multibase: String, 248} 249 250#[derive(Deserialize)] 251#[serde(rename_all = "camelCase")] 252pub struct UpdateDidDocumentInput { 253 pub verification_methods: Option<Vec<VerificationMethod>>, 254 pub also_known_as: Option<Vec<String>>, 255 pub service_endpoint: Option<String>, 256} 257 258#[derive(Serialize)] 259#[serde(rename_all = "camelCase")] 260pub struct UpdateDidDocumentOutput { 261 pub success: bool, 262 pub did_document: serde_json::Value, 263} 264 265pub async fn update_did_document( 266 State(state): State<AppState>, 267 headers: axum::http::HeaderMap, 268 Json(input): Json<UpdateDidDocumentInput>, 269) -> Response { 270 let extracted = match crate::auth::extract_auth_token_from_header( 271 headers.get("Authorization").and_then(|h| h.to_str().ok()), 272 ) { 273 Some(t) => t, 274 None => return ApiError::AuthenticationRequired.into_response(), 275 }; 276 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 277 let http_uri = format!( 278 "https://{}/xrpc/com.tranquil.account.updateDidDocument", 279 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 280 ); 281 let auth_user = match crate::auth::validate_token_with_dpop( 282 &state.db, 283 &extracted.token, 284 extracted.is_dpop, 285 dpop_proof, 286 "POST", 287 &http_uri, 288 true, 289 ) 290 .await 291 { 292 Ok(user) => user, 293 Err(e) => return ApiError::from(e).into_response(), 294 }; 295 296 if !auth_user.did.starts_with("did:web:") { 297 return ( 298 StatusCode::BAD_REQUEST, 299 Json(json!({ 300 "error": "InvalidRequest", 301 "message": "DID document updates are only available for did:web accounts" 302 })), 303 ) 304 .into_response(); 305 } 306 307 let user = match sqlx::query!( 308 "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 309 auth_user.did 310 ) 311 .fetch_optional(&state.db) 312 .await 313 { 314 Ok(Some(row)) => row, 315 Ok(None) => return ApiError::AccountNotFound.into_response(), 316 Err(e) => { 317 tracing::error!("DB error getting user: {:?}", e); 318 return ApiError::InternalError.into_response(); 319 } 320 }; 321 322 if user.migrated_to_pds.is_none() { 323 return ( 324 StatusCode::BAD_REQUEST, 325 Json(json!({ 326 "error": "InvalidRequest", 327 "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first." 328 })), 329 ) 330 .into_response(); 331 } 332 333 if let Some(ref methods) = input.verification_methods { 334 if methods.is_empty() { 335 return ApiError::InvalidRequest("verification_methods cannot be empty".into()) 336 .into_response(); 337 } 338 for method in methods { 339 if method.id.is_empty() { 340 return ApiError::InvalidRequest("verification method id is required".into()) 341 .into_response(); 342 } 343 if method.method_type != "Multikey" { 344 return ApiError::InvalidRequest( 345 "verification method type must be 'Multikey'".into(), 346 ) 347 .into_response(); 348 } 349 if !method.public_key_multibase.starts_with('z') { 350 return ApiError::InvalidRequest( 351 "publicKeyMultibase must start with 'z' (base58btc)".into(), 352 ) 353 .into_response(); 354 } 355 if method.public_key_multibase.len() < 40 { 356 return ApiError::InvalidRequest( 357 "publicKeyMultibase appears too short for a valid key".into(), 358 ) 359 .into_response(); 360 } 361 } 362 } 363 364 if let Some(ref handles) = input.also_known_as { 365 for handle in handles { 366 if !handle.starts_with("at://") { 367 return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) 368 .into_response(); 369 } 370 } 371 } 372 373 if let Some(ref endpoint) = input.service_endpoint { 374 let endpoint = endpoint.trim(); 375 if !endpoint.starts_with("https://") { 376 return ApiError::InvalidRequest("serviceEndpoint must start with https://".into()) 377 .into_response(); 378 } 379 } 380 381 let verification_methods_json = input 382 .verification_methods 383 .as_ref() 384 .map(|v| serde_json::to_value(v).unwrap_or_default()); 385 386 let also_known_as: Option<Vec<String>> = input.also_known_as.clone(); 387 388 let now = Utc::now(); 389 390 let upsert_result = sqlx::query!( 391 r#" 392 INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at) 393 VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4) 394 ON CONFLICT (user_id) DO UPDATE SET 395 verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END, 396 also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END, 397 updated_at = $4 398 "#, 399 user.id, 400 verification_methods_json, 401 also_known_as.as_deref(), 402 now 403 ) 404 .execute(&state.db) 405 .await; 406 407 if let Err(e) = upsert_result { 408 tracing::error!("DB error upserting did_web_overrides: {:?}", e); 409 return ApiError::InternalError.into_response(); 410 } 411 412 if let Some(ref endpoint) = input.service_endpoint { 413 let endpoint_clean = endpoint.trim().trim_end_matches('/'); 414 let update_result = sqlx::query!( 415 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 416 endpoint_clean, 417 now, 418 auth_user.did 419 ) 420 .execute(&state.db) 421 .await; 422 423 if let Err(e) = update_result { 424 tracing::error!("DB error updating service endpoint: {:?}", e); 425 return ApiError::InternalError.into_response(); 426 } 427 } 428 429 let did_doc = build_did_document(&state.db, &auth_user.did).await; 430 431 tracing::info!("Updated DID document for {}", auth_user.did); 432 433 ( 434 StatusCode::OK, 435 Json(UpdateDidDocumentOutput { 436 success: true, 437 did_document: did_doc, 438 }), 439 ) 440 .into_response() 441} 442 443pub async fn get_did_document( 444 State(state): State<AppState>, 445 headers: axum::http::HeaderMap, 446) -> Response { 447 let extracted = match crate::auth::extract_auth_token_from_header( 448 headers.get("Authorization").and_then(|h| h.to_str().ok()), 449 ) { 450 Some(t) => t, 451 None => return ApiError::AuthenticationRequired.into_response(), 452 }; 453 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 454 let http_uri = format!( 455 "https://{}/xrpc/com.tranquil.account.getDidDocument", 456 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 457 ); 458 let auth_user = match crate::auth::validate_token_with_dpop( 459 &state.db, 460 &extracted.token, 461 extracted.is_dpop, 462 dpop_proof, 463 "GET", 464 &http_uri, 465 true, 466 ) 467 .await 468 { 469 Ok(user) => user, 470 Err(e) => return ApiError::from(e).into_response(), 471 }; 472 473 if !auth_user.did.starts_with("did:web:") { 474 return ( 475 StatusCode::BAD_REQUEST, 476 Json(json!({ 477 "error": "InvalidRequest", 478 "message": "This endpoint is only available for did:web accounts" 479 })), 480 ) 481 .into_response(); 482 } 483 484 let did_doc = build_did_document(&state.db, &auth_user.did).await; 485 486 (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response() 487} 488 489async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value { 490 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 491 492 let user = match sqlx::query!( 493 "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1", 494 did 495 ) 496 .fetch_optional(db) 497 .await 498 { 499 Ok(Some(row)) => row, 500 _ => { 501 return json!({ 502 "error": "User not found" 503 }); 504 } 505 }; 506 507 let overrides = sqlx::query!( 508 "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1", 509 user.id 510 ) 511 .fetch_optional(db) 512 .await 513 .ok() 514 .flatten(); 515 516 let service_endpoint = user 517 .migrated_to_pds 518 .unwrap_or_else(|| format!("https://{}", hostname)); 519 520 if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| { 521 serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) 522 .ok() 523 .filter(|p| !p.is_empty()) 524 .map(|p| (ovr, p)) 525 }) { 526 let also_known_as = if !ovr.also_known_as.is_empty() { 527 ovr.also_known_as.clone() 528 } else { 529 vec![format!("at://{}", user.handle)] 530 }; 531 return json!({ 532 "@context": [ 533 "https://www.w3.org/ns/did/v1", 534 "https://w3id.org/security/multikey/v1", 535 "https://w3id.org/security/suites/secp256k1-2019/v1" 536 ], 537 "id": did, 538 "alsoKnownAs": also_known_as, 539 "verificationMethod": parsed.iter().map(|m| json!({ 540 "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 541 "type": m.method_type, 542 "controller": did, 543 "publicKeyMultibase": m.public_key_multibase 544 })).collect::<Vec<_>>(), 545 "service": [{ 546 "id": "#atproto_pds", 547 "type": "AtprotoPersonalDataServer", 548 "serviceEndpoint": service_endpoint 549 }] 550 }); 551 } 552 553 let key_row = sqlx::query!( 554 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 555 user.id 556 ) 557 .fetch_optional(db) 558 .await; 559 560 let public_key_multibase = match key_row { 561 Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 562 Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes) 563 .unwrap_or_else(|_| "error".to_string()), 564 Err(_) => "error".to_string(), 565 }, 566 _ => "error".to_string(), 567 }; 568 569 let also_known_as = if let Some(ref ovr) = overrides { 570 if !ovr.also_known_as.is_empty() { 571 ovr.also_known_as.clone() 572 } else { 573 vec![format!("at://{}", user.handle)] 574 } 575 } else { 576 vec![format!("at://{}", user.handle)] 577 }; 578 579 json!({ 580 "@context": [ 581 "https://www.w3.org/ns/did/v1", 582 "https://w3id.org/security/multikey/v1", 583 "https://w3id.org/security/suites/secp256k1-2019/v1" 584 ], 585 "id": did, 586 "alsoKnownAs": also_known_as, 587 "verificationMethod": [{ 588 "id": format!("{}#atproto", did), 589 "type": "Multikey", 590 "controller": did, 591 "publicKeyMultibase": public_key_multibase 592 }], 593 "service": [{ 594 "id": "#atproto_pds", 595 "type": "AtprotoPersonalDataServer", 596 "serviceEndpoint": service_endpoint 597 }] 598 }) 599}