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( 336 "verification_methods cannot be empty".into(), 337 ) 338 .into_response(); 339 } 340 for method in methods { 341 if method.id.is_empty() { 342 return ApiError::InvalidRequest("verification method id is required".into()) 343 .into_response(); 344 } 345 if method.method_type != "Multikey" { 346 return ApiError::InvalidRequest( 347 "verification method type must be 'Multikey'".into(), 348 ) 349 .into_response(); 350 } 351 if !method.public_key_multibase.starts_with('z') { 352 return ApiError::InvalidRequest( 353 "publicKeyMultibase must start with 'z' (base58btc)".into(), 354 ) 355 .into_response(); 356 } 357 if method.public_key_multibase.len() < 40 { 358 return ApiError::InvalidRequest( 359 "publicKeyMultibase appears too short for a valid key".into(), 360 ) 361 .into_response(); 362 } 363 } 364 } 365 366 if let Some(ref handles) = input.also_known_as { 367 for handle in handles { 368 if !handle.starts_with("at://") { 369 return ApiError::InvalidRequest( 370 "alsoKnownAs entries must be at:// URIs".into(), 371 ) 372 .into_response(); 373 } 374 } 375 } 376 377 if let Some(ref endpoint) = input.service_endpoint { 378 let endpoint = endpoint.trim(); 379 if !endpoint.starts_with("https://") { 380 return ApiError::InvalidRequest( 381 "serviceEndpoint must start with https://".into(), 382 ) 383 .into_response(); 384 } 385 } 386 387 let verification_methods_json = input 388 .verification_methods 389 .as_ref() 390 .map(|v| serde_json::to_value(v).unwrap_or_default()); 391 392 let also_known_as: Option<Vec<String>> = input.also_known_as.clone(); 393 394 let now = Utc::now(); 395 396 let upsert_result = sqlx::query!( 397 r#" 398 INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at) 399 VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4) 400 ON CONFLICT (user_id) DO UPDATE SET 401 verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END, 402 also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END, 403 updated_at = $4 404 "#, 405 user.id, 406 verification_methods_json, 407 also_known_as.as_deref(), 408 now 409 ) 410 .execute(&state.db) 411 .await; 412 413 if let Err(e) = upsert_result { 414 tracing::error!("DB error upserting did_web_overrides: {:?}", e); 415 return ApiError::InternalError.into_response(); 416 } 417 418 if let Some(ref endpoint) = input.service_endpoint { 419 let endpoint_clean = endpoint.trim().trim_end_matches('/'); 420 let update_result = sqlx::query!( 421 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 422 endpoint_clean, 423 now, 424 auth_user.did 425 ) 426 .execute(&state.db) 427 .await; 428 429 if let Err(e) = update_result { 430 tracing::error!("DB error updating service endpoint: {:?}", e); 431 return ApiError::InternalError.into_response(); 432 } 433 } 434 435 let did_doc = build_did_document(&state.db, &auth_user.did).await; 436 437 tracing::info!("Updated DID document for {}", auth_user.did); 438 439 ( 440 StatusCode::OK, 441 Json(UpdateDidDocumentOutput { 442 success: true, 443 did_document: did_doc, 444 }), 445 ) 446 .into_response() 447} 448 449pub async fn get_did_document( 450 State(state): State<AppState>, 451 headers: axum::http::HeaderMap, 452) -> Response { 453 let extracted = match crate::auth::extract_auth_token_from_header( 454 headers.get("Authorization").and_then(|h| h.to_str().ok()), 455 ) { 456 Some(t) => t, 457 None => return ApiError::AuthenticationRequired.into_response(), 458 }; 459 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 460 let http_uri = format!( 461 "https://{}/xrpc/com.tranquil.account.getDidDocument", 462 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 463 ); 464 let auth_user = match crate::auth::validate_token_with_dpop( 465 &state.db, 466 &extracted.token, 467 extracted.is_dpop, 468 dpop_proof, 469 "GET", 470 &http_uri, 471 true, 472 ) 473 .await 474 { 475 Ok(user) => user, 476 Err(e) => return ApiError::from(e).into_response(), 477 }; 478 479 if !auth_user.did.starts_with("did:web:") { 480 return ( 481 StatusCode::BAD_REQUEST, 482 Json(json!({ 483 "error": "InvalidRequest", 484 "message": "This endpoint is only available for did:web accounts" 485 })), 486 ) 487 .into_response(); 488 } 489 490 let did_doc = build_did_document(&state.db, &auth_user.did).await; 491 492 (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response() 493} 494 495async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value { 496 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 497 498 let user = match sqlx::query!( 499 "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1", 500 did 501 ) 502 .fetch_optional(db) 503 .await 504 { 505 Ok(Some(row)) => row, 506 _ => { 507 return json!({ 508 "error": "User not found" 509 }); 510 } 511 }; 512 513 let overrides = sqlx::query!( 514 "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1", 515 user.id 516 ) 517 .fetch_optional(db) 518 .await 519 .ok() 520 .flatten(); 521 522 let service_endpoint = user 523 .migrated_to_pds 524 .unwrap_or_else(|| format!("https://{}", hostname)); 525 526 if let Some(ref ovr) = overrides { 527 if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) { 528 if !parsed.is_empty() { 529 let also_known_as = if !ovr.also_known_as.is_empty() { 530 ovr.also_known_as.clone() 531 } else { 532 vec![format!("at://{}", user.handle)] 533 }; 534 return json!({ 535 "@context": [ 536 "https://www.w3.org/ns/did/v1", 537 "https://w3id.org/security/multikey/v1", 538 "https://w3id.org/security/suites/secp256k1-2019/v1" 539 ], 540 "id": did, 541 "alsoKnownAs": also_known_as, 542 "verificationMethod": parsed.iter().map(|m| json!({ 543 "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 544 "type": m.method_type, 545 "controller": did, 546 "publicKeyMultibase": m.public_key_multibase 547 })).collect::<Vec<_>>(), 548 "service": [{ 549 "id": "#atproto_pds", 550 "type": "AtprotoPersonalDataServer", 551 "serviceEndpoint": service_endpoint 552 }] 553 }); 554 } 555 } 556 } 557 558 let key_row = sqlx::query!( 559 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 560 user.id 561 ) 562 .fetch_optional(db) 563 .await; 564 565 let public_key_multibase = match key_row { 566 Ok(Some(row)) => { 567 match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 568 Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes) 569 .unwrap_or_else(|_| "error".to_string()), 570 Err(_) => "error".to_string(), 571 } 572 } 573 _ => "error".to_string(), 574 }; 575 576 let also_known_as = if let Some(ref ovr) = overrides { 577 if !ovr.also_known_as.is_empty() { 578 ovr.also_known_as.clone() 579 } else { 580 vec![format!("at://{}", user.handle)] 581 } 582 } else { 583 vec![format!("at://{}", user.handle)] 584 }; 585 586 json!({ 587 "@context": [ 588 "https://www.w3.org/ns/did/v1", 589 "https://w3id.org/security/multikey/v1", 590 "https://w3id.org/security/suites/secp256k1-2019/v1" 591 ], 592 "id": did, 593 "alsoKnownAs": also_known_as, 594 "verificationMethod": [{ 595 "id": format!("{}#atproto", did), 596 "type": "Multikey", 597 "controller": did, 598 "publicKeyMultibase": public_key_multibase 599 }], 600 "service": [{ 601 "id": "#atproto_pds", 602 "type": "AtprotoPersonalDataServer", 603 "serviceEndpoint": service_endpoint 604 }] 605 }) 606}