Something went wrong. Try again.
PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
Something went wrong. Try again.
11 kB · 358 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359use crate::api::ApiError;use crate::state::AppState;use axum::{ Json, extract::State, http::StatusCode, response::{IntoResponse, Response},};use chrono::Utc;use serde::{Deserialize, Serialize};use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize)]#[serde(rename_all = "camelCase")]pub struct VerificationMethod { pub id: String, #[serde(rename = "type")] pub method_type: String, pub public_key_multibase: String,}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub struct UpdateDidDocumentInput { pub verification_methods: Option<Vec<VerificationMethod>>, pub also_known_as: Option<Vec<String>>, pub service_endpoint: Option<String>,}
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct UpdateDidDocumentOutput { pub success: bool, pub did_document: serde_json::Value,}
pub async fn update_did_document( State(state): State<AppState>, headers: axum::http::HeaderMap, Json(input): Json<UpdateDidDocumentInput>,) -> Response { let extracted = match crate::auth::extract_auth_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); let http_uri = format!( "https://{}/xrpc/_account.updateDidDocument", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) ); let auth_user = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, extracted.is_dpop, dpop_proof, "POST", &http_uri, true, false, ) .await { Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), };
if !auth_user.did.starts_with("did:web:") { return ApiError::InvalidRequest( "DID document updates are only available for did:web accounts".into(), ) .into_response(); }
let user = match sqlx::query!( "SELECT id, handle, deactivated_at FROM users WHERE did = $1", &auth_user.did ) .fetch_optional(&state.db) .await { Ok(Some(row)) => row, Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { tracing::error!("DB error getting user: {:?}", e); return ApiError::InternalError(None).into_response(); } };
if user.deactivated_at.is_some() { return ApiError::AccountDeactivated.into_response(); }
if let Some(ref methods) = input.verification_methods { if methods.is_empty() { return ApiError::InvalidRequest("verification_methods cannot be empty".into()) .into_response(); } for method in methods { if method.id.is_empty() { return ApiError::InvalidRequest("verification method id is required".into()) .into_response(); } if method.method_type != "Multikey" { return ApiError::InvalidRequest( "verification method type must be 'Multikey'".into(), ) .into_response(); } if !method.public_key_multibase.starts_with('z') { return ApiError::InvalidRequest( "publicKeyMultibase must start with 'z' (base58btc)".into(), ) .into_response(); } if method.public_key_multibase.len() < 40 { return ApiError::InvalidRequest( "publicKeyMultibase appears too short for a valid key".into(), ) .into_response(); } } }
if let Some(ref handles) = input.also_known_as { for handle in handles { if !handle.starts_with("at://") { return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) .into_response(); } } }
if let Some(ref endpoint) = input.service_endpoint { let endpoint = endpoint.trim(); if !endpoint.starts_with("https://") { return ApiError::InvalidRequest("serviceEndpoint must start with https://".into()) .into_response(); } }
let verification_methods_json = input .verification_methods .as_ref() .map(|v| serde_json::to_value(v).unwrap_or_default());
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
let now = Utc::now();
let upsert_result = sqlx::query!( r#" INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at) VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4) ON CONFLICT (user_id) DO UPDATE SET verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END, also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END, updated_at = $4 "#, user.id, verification_methods_json, also_known_as.as_deref(), now ) .execute(&state.db) .await;
if let Err(e) = upsert_result { tracing::error!("DB error upserting did_web_overrides: {:?}", e); return ApiError::InternalError(None).into_response(); }
if let Some(ref endpoint) = input.service_endpoint { let endpoint_clean = endpoint.trim().trim_end_matches('/'); let update_result = sqlx::query!( "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", endpoint_clean, now, &auth_user.did ) .execute(&state.db) .await;
if let Err(e) = update_result { tracing::error!("DB error updating service endpoint: {:?}", e); return ApiError::InternalError(None).into_response(); } }
let did_doc = build_did_document(&state.db, &auth_user.did).await;
tracing::info!("Updated DID document for {}", &auth_user.did);
( StatusCode::OK, Json(UpdateDidDocumentOutput { success: true, did_document: did_doc, }), ) .into_response()}
pub async fn get_did_document( State(state): State<AppState>, headers: axum::http::HeaderMap,) -> Response { let extracted = match crate::auth::extract_auth_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); let http_uri = format!( "https://{}/xrpc/_account.getDidDocument", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) ); let auth_user = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, extracted.is_dpop, dpop_proof, "GET", &http_uri, true, false, ) .await { Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), };
if !auth_user.did.starts_with("did:web:") { return ApiError::InvalidRequest( "This endpoint is only available for did:web accounts".into(), ) .into_response(); }
let did_doc = build_did_document(&state.db, &auth_user.did).await;
(StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()}
async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user = match sqlx::query!( "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1", did ) .fetch_optional(db) .await { Ok(Some(row)) => row, _ => { return json!({ "error": "User not found" }); } };
let overrides = sqlx::query!( "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1", user.id ) .fetch_optional(db) .await .ok() .flatten();
let service_endpoint = user .migrated_to_pds .unwrap_or_else(|| format!("https://{}", hostname));
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| { serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) .ok() .filter(|p| !p.is_empty()) .map(|p| (ovr, p)) }) { let also_known_as = if !ovr.also_known_as.is_empty() { ovr.also_known_as.clone() } else { vec![format!("at://{}", user.handle)] }; return json!({ "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1", "https://w3id.org/security/suites/secp256k1-2019/v1" ], "id": did, "alsoKnownAs": also_known_as, "verificationMethod": parsed.iter().map(|m| json!({ "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), "type": m.method_type, "controller": did, "publicKeyMultibase": m.public_key_multibase })).collect::<Vec<_>>(), "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": service_endpoint }] }); }
let key_row = sqlx::query!( "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user.id ) .fetch_optional(db) .await;
let public_key_multibase = match key_row { Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes) .unwrap_or_else(|_| "error".to_string()), Err(_) => "error".to_string(), }, _ => "error".to_string(), };
let also_known_as = if let Some(ref ovr) = overrides { if !ovr.also_known_as.is_empty() { ovr.also_known_as.clone() } else { vec![format!("at://{}", user.handle)] } } else { vec![format!("at://{}", user.handle)] };
json!({ "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1", "https://w3id.org/security/suites/secp256k1-2019/v1" ], "id": did, "alsoKnownAs": also_known_as, "verificationMethod": [{ "id": format!("{}#atproto", did), "type": "Multikey", "controller": did, "publicKeyMultibase": public_key_multibase }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": service_endpoint }] })}