diff --git a/crates/tranquil-api/src/admin/status.rs b/crates/tranquil-api/src/admin/status.rs index 3c7414b..aad7291 100644 --- a/crates/tranquil-api/src/admin/status.rs +++ b/crates/tranquil-api/src/admin/status.rs @@ -2,6 +2,7 @@ use axum::{ Json, extract::{Query, State}, }; +use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tracing::{error, warn}; @@ -165,16 +166,19 @@ pub async fn update_subject_status( Ok(d) => d, Err(_) => return Err(ApiError::InvalidDid("Invalid DID format".into())), }; - if let Some(takedown) = &input.takedown { - let takedown_ref = if takedown.applied { - takedown.r#ref.as_deref() - } else { - None - }; + let takedown_ref = input.takedown.as_ref().and_then(|takedown| { + takedown.applied.then(|| { + takedown + .r#ref + .clone() + .unwrap_or_else(|| Utc::now().to_rfc3339()) + }) + }); + if input.takedown.is_some() { state .repos .user - .set_user_takedown(&did, takedown_ref) + .set_user_takedown(&did, takedown_ref.as_deref()) .await .map_err(|e| { error!("Failed to update user takedown status for {}: {:?}", did, e); @@ -196,13 +200,17 @@ pub async fn update_subject_status( })?; } let takedown_update = input.takedown.as_ref().map(|t| t.applied); - let takedown_ref = input.takedown.as_ref().and_then(|t| t.r#ref.as_deref()); let deactivated_update = input.deactivated.as_ref().map(|d| d.applied); if (takedown_update.is_some() || deactivated_update.is_some()) && let Err(e) = state .repos .repo - .update_repo_status(&did, takedown_update, takedown_ref, deactivated_update) + .update_repo_status( + &did, + takedown_update, + takedown_ref.as_deref(), + deactivated_update, + ) .await { warn!("failed to sync status to repo backend: {e:?}"); diff --git a/crates/tranquil-pds/tests/admin_moderation.rs b/crates/tranquil-pds/tests/admin_moderation.rs index ec29cb1..b6597f0 100644 --- a/crates/tranquil-pds/tests/admin_moderation.rs +++ b/crates/tranquil-pds/tests/admin_moderation.rs @@ -123,6 +123,75 @@ async fn test_update_subject_status_takedown_user() { assert_eq!(status_body["takedown"]["ref"], "mod-action-123"); } +#[tokio::test] +async fn test_update_subject_status_takedown_user_without_ref_updates_sync_status() { + let client = client(); + let (admin_jwt, _) = create_admin_account_and_login(&client).await; + let (_, target_did) = create_account_and_login(&client).await; + let payload = json!({ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": target_did + }, + "takedown": { + "applied": true + } + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .bearer_auth(&admin_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + + let status_res = client + .get(format!( + "{}/xrpc/com.atproto.sync.getRepoStatus", + base_url().await + )) + .query(&[("did", target_did.as_str())]) + .send() + .await + .expect("Failed to send request"); + assert_eq!(status_res.status(), StatusCode::OK); + let status_body: Value = status_res.json().await.unwrap(); + assert_eq!(status_body["active"], false); + assert_eq!(status_body["status"], "takendown"); + + let mut cursor: Option = None; + loop { + let mut url = format!( + "{}/xrpc/com.atproto.sync.listRepos?limit=1000", + base_url().await + ); + if let Some(cursor) = &cursor { + url.push_str(&format!("&cursor={cursor}")); + } + let list_res = client.get(&url).send().await.unwrap(); + assert_eq!(list_res.status(), StatusCode::OK); + let list_body: Value = list_res.json().await.unwrap(); + if let Some(repo) = list_body["repos"] + .as_array() + .unwrap() + .iter() + .find(|repo| repo["did"] == target_did) + { + assert_eq!(repo["active"], false); + assert_eq!(repo["status"], "takendown"); + break; + } + match list_body["cursor"].as_str() { + Some(next_cursor) => cursor = Some(next_cursor.to_string()), + None => panic!("Takendown repo should be in listRepos"), + } + } +} + #[tokio::test] async fn test_update_subject_status_remove_takedown() { let client = client(); diff --git a/crates/tranquil-pds/tests/sync_conformance.rs b/crates/tranquil-pds/tests/sync_conformance.rs index 1db281f..64bc3fc 100644 --- a/crates/tranquil-pds/tests/sync_conformance.rs +++ b/crates/tranquil-pds/tests/sync_conformance.rs @@ -357,6 +357,8 @@ async fn test_get_repo_since_returns_partial() { let client = client(); let (jwt, did) = create_account_and_login(&client).await; + create_post(&client, &did, &jwt, "Existing post before since rev").await; + let initial_commit_res = client .get(format!( "{}/xrpc/com.atproto.sync.getLatestCommit", diff --git a/migrations/20260812_backfill_no_ref_takedowns.sql b/migrations/20260812_backfill_no_ref_takedowns.sql new file mode 100644 index 0000000..2b622b7 --- /dev/null +++ b/migrations/20260812_backfill_no_ref_takedowns.sql @@ -0,0 +1,16 @@ +WITH latest_takedown_state AS ( + SELECT DISTINCT ON (did) + did, + status, + created_at + FROM repo_seq + WHERE event_type = 'account' + AND (status = 'takendown' OR active = TRUE) + ORDER BY did, id DESC +) +UPDATE users +SET takedown_ref = latest_takedown_state.created_at::TEXT +FROM latest_takedown_state +WHERE users.did = latest_takedown_state.did + AND users.takedown_ref IS NULL + AND latest_takedown_state.status = 'takendown'; \ No newline at end of file