mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-10-01 06:25:34 +00:00
pds-hosted did migrates away
This commit is contained in:
@@ -355,6 +355,16 @@ pub async fn create_account(
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "SelfHostedDidWebDisabled",
|
||||
"message": "This PDS does not offer self-hosted did:web identities. Please use did:plc or bring your own did:web."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let subdomain_host = format!("{}.{}", input.handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
|
||||
+130
-5
@@ -11,10 +11,19 @@ use base64::Engine;
|
||||
use k256::SecretKey;
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use reqwest;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DidWebVerificationMethod {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub method_type: String,
|
||||
pub public_key_multibase: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResolveHandleParams {
|
||||
pub handle: String,
|
||||
@@ -170,6 +179,54 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let overrides = sqlx::query!(
|
||||
"SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
|
||||
if let Some(ref ovr) = overrides {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
|
||||
{
|
||||
if !parsed.is_empty() {
|
||||
let also_known_as = if !ovr.also_known_as.is_empty() {
|
||||
ovr.also_known_as.clone()
|
||||
} else {
|
||||
vec![format!("at://{}", full_handle)]
|
||||
};
|
||||
|
||||
return Json(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
|
||||
}]
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key_row = sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
user_id
|
||||
@@ -206,7 +263,17 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
|
||||
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://{}", full_handle)]
|
||||
}
|
||||
} else {
|
||||
vec![format!("at://{}", full_handle)]
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
@@ -214,7 +281,7 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": [format!("at://{}", handle)],
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
@@ -272,6 +339,54 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let overrides = sqlx::query!(
|
||||
"SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
|
||||
if let Some(ref ovr) = overrides {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
|
||||
{
|
||||
if !parsed.is_empty() {
|
||||
let also_known_as = if !ovr.also_known_as.is_empty() {
|
||||
ovr.also_known_as.clone()
|
||||
} else {
|
||||
vec![format!("at://{}", full_handle)]
|
||||
};
|
||||
|
||||
return Json(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
|
||||
}]
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key_row = sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
user_id
|
||||
@@ -308,7 +423,17 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
|
||||
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://{}", full_handle)]
|
||||
}
|
||||
} else {
|
||||
vec![format!("at://{}", full_handle)]
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
@@ -316,7 +441,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": [format!("at://{}", handle)],
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
|
||||
@@ -92,6 +92,20 @@ pub async fn upload_blob(
|
||||
}
|
||||
};
|
||||
|
||||
if crate::util::is_account_migrated(&state.db, &did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated to another PDS. Blob operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let max_size = get_max_blob_size();
|
||||
|
||||
if body.len() > max_size {
|
||||
|
||||
@@ -129,6 +129,19 @@ pub async fn apply_writes(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if crate::util::is_account_migrated(&state.db, &did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let is_verified = has_verified_comms_channel(&state.db, &did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -57,6 +57,20 @@ pub async fn delete_record(
|
||||
return e;
|
||||
}
|
||||
|
||||
if crate::util::is_account_migrated(&state.db, &auth.did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated. Repo operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let did = auth.did;
|
||||
let user_id = auth.user_id;
|
||||
let current_root_cid = auth.current_root_cid;
|
||||
|
||||
@@ -102,6 +102,19 @@ pub async fn prepare_repo_write(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
if crate::util::is_account_migrated(&state.db, &auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -568,6 +568,7 @@ pub async fn activate_account(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeactivateAccountInput {
|
||||
pub delete_after: Option<String>,
|
||||
pub migrating_to: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn deactivate_account(
|
||||
@@ -617,32 +618,63 @@ pub async fn deactivate_account(
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
let did = auth_user.did;
|
||||
|
||||
let migrating_to = if let Some(ref url) = input.migrating_to {
|
||||
let url = url.trim().trim_end_matches('/');
|
||||
if url.is_empty() || !did.starts_with("did:web:") {
|
||||
None
|
||||
} else {
|
||||
if !url.starts_with("https://") {
|
||||
return ApiError::InvalidRequest("migratingTo must start with https://".into())
|
||||
.into_response();
|
||||
}
|
||||
Some(url.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
|
||||
did,
|
||||
delete_after
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let result = if let Some(ref pds_url) = migrating_to {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
|
||||
did,
|
||||
delete_after,
|
||||
pds_url
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
|
||||
did,
|
||||
delete_after
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
};
|
||||
|
||||
let status = if migrating_to.is_some() {
|
||||
"migrated"
|
||||
} else {
|
||||
"deactivated"
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Some(ref h) = handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", h)).await;
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
false,
|
||||
Some("deactivated"),
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status))
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account deactivation event: {}", e);
|
||||
warn!("Failed to sequence account {} event: {}", status, e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ pub async fn robots_txt() -> impl IntoResponse {
|
||||
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
|
||||
)
|
||||
}
|
||||
pub fn is_self_hosted_did_web_enabled() -> bool {
|
||||
std::env::var("ENABLE_SELF_HOSTED_DID_WEB")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> impl IntoResponse {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let domains_str =
|
||||
@@ -53,7 +59,8 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
"links": links,
|
||||
"contact": contact,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"availableCommsChannels": get_available_comms_channels()
|
||||
"availableCommsChannels": get_available_comms_channels(),
|
||||
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
|
||||
}))
|
||||
}
|
||||
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
@@ -237,3 +237,370 @@ pub async fn clear_migration_forwarding(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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/com.tranquil.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,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
|
||||
if !auth_user.did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document updates are only available for did:web accounts"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, migrated_to_pds, handle 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.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.migrated_to_pds.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
|
||||
})),
|
||||
)
|
||||
.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.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.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/com.tranquil.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,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
|
||||
if !auth_user.did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This endpoint is only available for did:web accounts"
|
||||
})),
|
||||
)
|
||||
.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(ref ovr) = overrides {
|
||||
if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) {
|
||||
if !parsed.is_empty() {
|
||||
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
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod
|
||||
pub use logo::get_logo;
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use migration::{
|
||||
clear_migration_forwarding, get_migration_status, update_migration_forwarding,
|
||||
clear_migration_forwarding, get_did_document, get_migration_status, update_did_document,
|
||||
update_migration_forwarding,
|
||||
};
|
||||
pub use passkey_account::{
|
||||
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
||||
|
||||
@@ -104,7 +104,7 @@ pub async fn create_session(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,
|
||||
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
|
||||
u.allow_legacy_login,
|
||||
u.allow_legacy_login, u.migrated_to_pds,
|
||||
u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
|
||||
k.key_bytes, k.encryption_version,
|
||||
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
|
||||
@@ -276,9 +276,12 @@ pub async fn create_session(
|
||||
}
|
||||
}
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
|
||||
let is_active = row.deactivated_at.is_none() && !is_takendown;
|
||||
let status = if is_takendown {
|
||||
Some("takendown".to_string())
|
||||
} else if is_migrated {
|
||||
Some("migrated".to_string())
|
||||
} else if row.deactivated_at.is_some() {
|
||||
Some("deactivated".to_string())
|
||||
} else {
|
||||
@@ -312,7 +315,7 @@ pub async fn get_session(
|
||||
r#"SELECT
|
||||
handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,
|
||||
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at
|
||||
FROM users WHERE did = $1"#,
|
||||
auth_user.did
|
||||
)
|
||||
@@ -331,6 +334,8 @@ pub async fn get_session(
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
let is_takendown = row.takedown_ref.is_some();
|
||||
let is_migrated =
|
||||
row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
|
||||
let is_active = row.deactivated_at.is_none() && !is_takendown;
|
||||
let email_value = if can_read_email {
|
||||
row.email.clone()
|
||||
@@ -353,6 +358,10 @@ pub async fn get_session(
|
||||
}
|
||||
if is_takendown {
|
||||
response["status"] = json!("takendown");
|
||||
} else if is_migrated {
|
||||
response["status"] = json!("migrated");
|
||||
response["migratedToPds"] = json!(row.migrated_to_pds);
|
||||
response["migratedAt"] = json!(row.migrated_at);
|
||||
} else if row.deactivated_at.is_some() {
|
||||
response["status"] = json!("deactivated");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::{
|
||||
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::util::build_full_url;
|
||||
|
||||
pub struct BearerAuth(pub AuthenticatedUser);
|
||||
|
||||
@@ -164,7 +165,7 @@ impl FromRequestParts<AppState> for BearerAuth {
|
||||
if extracted.is_dpop {
|
||||
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
|
||||
let method = parts.method.as_str();
|
||||
let uri = parts.uri.to_string();
|
||||
let uri = build_full_url(&parts.uri.to_string());
|
||||
|
||||
match validate_token_with_dpop(
|
||||
&state.db,
|
||||
@@ -217,7 +218,7 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
|
||||
if extracted.is_dpop {
|
||||
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
|
||||
let method = parts.method.as_str();
|
||||
let uri = parts.uri.to_string();
|
||||
let uri = build_full_url(&parts.uri.to_string());
|
||||
|
||||
match validate_token_with_dpop(
|
||||
&state.db,
|
||||
@@ -274,7 +275,7 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
|
||||
let user = if extracted.is_dpop {
|
||||
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
|
||||
let method = parts.method.as_str();
|
||||
let uri = parts.uri.to_string();
|
||||
let uri = build_full_url(&parts.uri.to_string());
|
||||
|
||||
match validate_token_with_dpop(
|
||||
&state.db,
|
||||
|
||||
@@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.account.clearMigrationForwarding",
|
||||
post(api::server::clear_migration_forwarding),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateDidDocument",
|
||||
post(api::server::update_did_document),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getDidDocument",
|
||||
get(api::server::get_did_document),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.requestEmailUpdate",
|
||||
post(api::server::request_email_update),
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
});
|
||||
}
|
||||
let http_method = parts.method.as_str();
|
||||
let http_uri = parts.uri.to_string();
|
||||
let http_uri = crate::util::build_full_url(&parts.uri.to_string());
|
||||
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
|
||||
{
|
||||
Ok(result) => {
|
||||
|
||||
+22
@@ -86,6 +86,16 @@ pub async fn get_user_by_identifier(
|
||||
.ok_or(DbLookupError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn is_account_migrated(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT (migrated_to_pds IS NOT NULL AND deactivated_at IS NOT NULL) as "migrated!: bool" FROM users WHERE did = $1"#,
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.migrated).unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
|
||||
query
|
||||
.map(|q| {
|
||||
@@ -128,6 +138,18 @@ pub fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
pub fn pds_hostname() -> String {
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
}
|
||||
|
||||
pub fn pds_public_url() -> String {
|
||||
format!("https://{}", pds_hostname())
|
||||
}
|
||||
|
||||
pub fn build_full_url(path: &str) -> String {
|
||||
format!("{}{}", pds_public_url(), path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user