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