pds-hosted did migrates away

This commit is contained in:
lewis
2025-12-30 21:16:49 +02:00
parent ea55590b6c
commit 4d6e21b00d
44 changed files with 2088 additions and 116 deletions
+10
View File
@@ -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
View File
@@ -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",
+14
View File
@@ -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 {
+13
View File
@@ -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);
+14
View File
@@ -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;
+13
View File
@@ -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);
+47 -15
View File
@@ -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()
}
+8 -1
View File
@@ -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 {
+367
View File
@@ -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
}]
})
}
+2 -1
View File
@@ -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,
+11 -2
View File
@@ -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");
}
+4 -3
View File
@@ -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,
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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::*;