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::Utc;
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct VerificationMethod {
16 pub id: String,
17 #[serde(rename = "type")]
18 pub method_type: String,
19 pub public_key_multibase: String,
20}
21
22#[derive(Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct UpdateDidDocumentInput {
25 pub verification_methods: Option<Vec<VerificationMethod>>,
26 pub also_known_as: Option<Vec<String>>,
27 pub service_endpoint: Option<String>,
28}
29
30#[derive(Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct UpdateDidDocumentOutput {
33 pub success: bool,
34 pub did_document: serde_json::Value,
35}
36
37pub async fn update_did_document(
38 State(state): State<AppState>,
39 headers: axum::http::HeaderMap,
40 Json(input): Json<UpdateDidDocumentInput>,
41) -> Response {
42 let extracted = match crate::auth::extract_auth_token_from_header(
43 headers.get("Authorization").and_then(|h| h.to_str().ok()),
44 ) {
45 Some(t) => t,
46 None => return ApiError::AuthenticationRequired.into_response(),
47 };
48 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
49 let http_uri = format!(
50 "https://{}/xrpc/_account.updateDidDocument",
51 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
52 );
53 let auth_user = match crate::auth::validate_token_with_dpop(
54 &state.db,
55 &extracted.token,
56 extracted.is_dpop,
57 dpop_proof,
58 "POST",
59 &http_uri,
60 true,
61 false,
62 )
63 .await
64 {
65 Ok(user) => user,
66 Err(e) => return ApiError::from(e).into_response(),
67 };
68
69 if !auth_user.did.starts_with("did:web:") {
70 return ApiError::InvalidRequest(
71 "DID document updates are only available for did:web accounts".into(),
72 )
73 .into_response();
74 }
75
76 let user = match sqlx::query!(
77 "SELECT id, handle, deactivated_at FROM users WHERE did = $1",
78 &auth_user.did
79 )
80 .fetch_optional(&state.db)
81 .await
82 {
83 Ok(Some(row)) => row,
84 Ok(None) => return ApiError::AccountNotFound.into_response(),
85 Err(e) => {
86 tracing::error!("DB error getting user: {:?}", e);
87 return ApiError::InternalError(None).into_response();
88 }
89 };
90
91 if user.deactivated_at.is_some() {
92 return ApiError::AccountDeactivated.into_response();
93 }
94
95 if let Some(ref methods) = input.verification_methods {
96 if methods.is_empty() {
97 return ApiError::InvalidRequest("verification_methods cannot be empty".into())
98 .into_response();
99 }
100 let validation_error = methods.iter().find_map(|method| {
101 if method.id.is_empty() {
102 Some("verification method id is required")
103 } else if method.method_type != "Multikey" {
104 Some("verification method type must be 'Multikey'")
105 } else if !method.public_key_multibase.starts_with('z') {
106 Some("publicKeyMultibase must start with 'z' (base58btc)")
107 } else if method.public_key_multibase.len() < 40 {
108 Some("publicKeyMultibase appears too short for a valid key")
109 } else {
110 None
111 }
112 });
113 if let Some(err) = validation_error {
114 return ApiError::InvalidRequest(err.into()).into_response();
115 }
116 }
117
118 if let Some(ref handles) = input.also_known_as
119 && handles.iter().any(|h| !h.starts_with("at://"))
120 {
121 return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
122 .into_response();
123 }
124
125 if let Some(ref endpoint) = input.service_endpoint {
126 let endpoint = endpoint.trim();
127 if !endpoint.starts_with("https://") {
128 return ApiError::InvalidRequest("serviceEndpoint must start with https://".into())
129 .into_response();
130 }
131 }
132
133 let verification_methods_json = input
134 .verification_methods
135 .as_ref()
136 .map(|v| serde_json::to_value(v).unwrap_or_default());
137
138 let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
139
140 let now = Utc::now();
141
142 let upsert_result = sqlx::query!(
143 r#"
144 INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)
145 VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)
146 ON CONFLICT (user_id) DO UPDATE SET
147 verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,
148 also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,
149 updated_at = $4
150 "#,
151 user.id,
152 verification_methods_json,
153 also_known_as.as_deref(),
154 now
155 )
156 .execute(&state.db)
157 .await;
158
159 if let Err(e) = upsert_result {
160 tracing::error!("DB error upserting did_web_overrides: {:?}", e);
161 return ApiError::InternalError(None).into_response();
162 }
163
164 if let Some(ref endpoint) = input.service_endpoint {
165 let endpoint_clean = endpoint.trim().trim_end_matches('/');
166 let update_result = sqlx::query!(
167 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
168 endpoint_clean,
169 now,
170 &auth_user.did
171 )
172 .execute(&state.db)
173 .await;
174
175 if let Err(e) = update_result {
176 tracing::error!("DB error updating service endpoint: {:?}", e);
177 return ApiError::InternalError(None).into_response();
178 }
179 }
180
181 let did_doc = build_did_document(&state.db, &auth_user.did).await;
182
183 tracing::info!("Updated DID document for {}", &auth_user.did);
184
185 (
186 StatusCode::OK,
187 Json(UpdateDidDocumentOutput {
188 success: true,
189 did_document: did_doc,
190 }),
191 )
192 .into_response()
193}
194
195pub async fn get_did_document(
196 State(state): State<AppState>,
197 headers: axum::http::HeaderMap,
198) -> Response {
199 let extracted = match crate::auth::extract_auth_token_from_header(
200 headers.get("Authorization").and_then(|h| h.to_str().ok()),
201 ) {
202 Some(t) => t,
203 None => return ApiError::AuthenticationRequired.into_response(),
204 };
205 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
206 let http_uri = format!(
207 "https://{}/xrpc/_account.getDidDocument",
208 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
209 );
210 let auth_user = match crate::auth::validate_token_with_dpop(
211 &state.db,
212 &extracted.token,
213 extracted.is_dpop,
214 dpop_proof,
215 "GET",
216 &http_uri,
217 true,
218 false,
219 )
220 .await
221 {
222 Ok(user) => user,
223 Err(e) => return ApiError::from(e).into_response(),
224 };
225
226 if !auth_user.did.starts_with("did:web:") {
227 return ApiError::InvalidRequest(
228 "This endpoint is only available for did:web accounts".into(),
229 )
230 .into_response();
231 }
232
233 let did_doc = build_did_document(&state.db, &auth_user.did).await;
234
235 (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
236}
237
238async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value {
239 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
240
241 let user = match sqlx::query!(
242 "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
243 did
244 )
245 .fetch_optional(db)
246 .await
247 {
248 Ok(Some(row)) => row,
249 _ => {
250 return json!({
251 "error": "User not found"
252 });
253 }
254 };
255
256 let overrides = sqlx::query!(
257 "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
258 user.id
259 )
260 .fetch_optional(db)
261 .await
262 .ok()
263 .flatten();
264
265 let service_endpoint = user
266 .migrated_to_pds
267 .unwrap_or_else(|| format!("https://{}", hostname));
268
269 if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
270 serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone())
271 .ok()
272 .filter(|p| !p.is_empty())
273 .map(|p| (ovr, p))
274 }) {
275 let also_known_as = if !ovr.also_known_as.is_empty() {
276 ovr.also_known_as.clone()
277 } else {
278 vec![format!("at://{}", user.handle)]
279 };
280 return json!({
281 "@context": [
282 "https://www.w3.org/ns/did/v1",
283 "https://w3id.org/security/multikey/v1",
284 "https://w3id.org/security/suites/secp256k1-2019/v1"
285 ],
286 "id": did,
287 "alsoKnownAs": also_known_as,
288 "verificationMethod": parsed.iter().map(|m| json!({
289 "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
290 "type": m.method_type,
291 "controller": did,
292 "publicKeyMultibase": m.public_key_multibase
293 })).collect::<Vec<_>>(),
294 "service": [{
295 "id": "#atproto_pds",
296 "type": "AtprotoPersonalDataServer",
297 "serviceEndpoint": service_endpoint
298 }]
299 });
300 }
301
302 let key_row = sqlx::query!(
303 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
304 user.id
305 )
306 .fetch_optional(db)
307 .await;
308
309 let public_key_multibase = match key_row {
310 Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
311 Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
312 .unwrap_or_else(|_| "error".to_string()),
313 Err(_) => "error".to_string(),
314 },
315 _ => "error".to_string(),
316 };
317
318 let also_known_as = if let Some(ref ovr) = overrides {
319 if !ovr.also_known_as.is_empty() {
320 ovr.also_known_as.clone()
321 } else {
322 vec![format!("at://{}", user.handle)]
323 }
324 } else {
325 vec![format!("at://{}", user.handle)]
326 };
327
328 json!({
329 "@context": [
330 "https://www.w3.org/ns/did/v1",
331 "https://w3id.org/security/multikey/v1",
332 "https://w3id.org/security/suites/secp256k1-2019/v1"
333 ],
334 "id": did,
335 "alsoKnownAs": also_known_as,
336 "verificationMethod": [{
337 "id": format!("{}#atproto", did),
338 "type": "Multikey",
339 "controller": did,
340 "publicKeyMultibase": public_key_multibase
341 }],
342 "service": [{
343 "id": "#atproto_pds",
344 "type": "AtprotoPersonalDataServer",
345 "serviceEndpoint": service_endpoint
346 }]
347 })
348}