use axum::Router; use crate::control::Hydrant; #[cfg(feature = "indexer")] use crate::types::{DeleteBodiesReport, DeleteBodyTarget}; #[cfg(feature = "indexer")] use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, routing::post, }; #[cfg(feature = "indexer")] use jacquard_common::types::{did::Did, nsid::Nsid, string::Rkey}; #[cfg(feature = "indexer")] use serde::Deserialize; #[cfg(feature = "indexer")] #[derive(Deserialize, Debug, Default)] pub struct RedactionQueryParams { #[serde(default)] pub target: Option, #[serde(default)] pub exclude: Option, } #[cfg(feature = "indexer")] #[derive(Debug, PartialEq, Eq)] pub enum RedactionTarget { Repo(Did), Record { did: Did, collection: String, rkey: String, }, } #[cfg(feature = "indexer")] pub fn parse_redaction_target(raw: &str) -> Result { let s = raw.strip_prefix("at://").unwrap_or(raw); let segments: Vec<&str> = s.split('/').filter(|seg| !seg.is_empty()).collect(); match segments.as_slice() { [did_str] => { let did = Did::new_owned(*did_str).map_err(|e| format!("invalid DID: {e}"))?; Ok(RedactionTarget::Repo(did)) } [did_str, collection_str, rkey_str] => { let did = Did::new_owned(*did_str).map_err(|e| format!("invalid DID: {e}"))?; Nsid::new(*collection_str).map_err(|e| format!("invalid collection NSID: {e}"))?; Rkey::new(*rkey_str).map_err(|e| format!("invalid rkey: {e}"))?; Ok(RedactionTarget::Record { did, collection: (*collection_str).to_string(), rkey: (*rkey_str).to_string(), }) } _ => Err("invalid redaction target: expected DID (did:...) or AT-URI (at://did/.../collection/rkey)".to_string()), } } pub fn router() -> Router { #[cfg(feature = "indexer")] { Router::new().route("/redactions/{*target}", post(handle_post_redaction)) } #[cfg(not(feature = "indexer"))] { Router::new() } } #[cfg(feature = "indexer")] pub async fn handle_post_redaction( State(hydrant): State, Path(target_raw): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { tracing::info!("handle_post_redaction called with target_raw: {target_raw}"); if hydrant.state.ephemeral { return Err(( StatusCode::CONFLICT, "operator body deletion is not supported in ephemeral mode".into(), )); } let parsed = parse_redaction_target(&target_raw).map_err(|e| (StatusCode::BAD_REQUEST, e))?; match parsed { RedactionTarget::Repo(did) => { let target = params.target.unwrap_or(DeleteBodyTarget::All); let exclude = params.exclude.unwrap_or(true); hydrant .repos .drop_repo(&did, target, exclude) .await .map(Json) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } RedactionTarget::Record { did, collection, rkey, } => { let target = params.target.unwrap_or(DeleteBodyTarget::All); hydrant .repos .get(&did) .delete_record_bodies(&collection, &rkey, target) .await .map(Json) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } } } #[cfg(all(test, feature = "indexer"))] mod tests { use super::*; #[test] fn test_parse_redaction_target_repo() { let did = Did::new_static("did:plc:testrepo").unwrap(); assert_eq!( parse_redaction_target("did:plc:testrepo").unwrap(), RedactionTarget::Repo(did.clone()) ); assert_eq!( parse_redaction_target("at://did:plc:testrepo").unwrap(), RedactionTarget::Repo(did) ); } #[test] fn test_parse_redaction_target_record() { let did = Did::new_static("did:plc:testrepo").unwrap(); let expected = RedactionTarget::Record { did: did.clone(), collection: "app.bsky.feed.post".to_string(), rkey: "3kzbif5moe22m".to_string(), }; assert_eq!( parse_redaction_target("did:plc:testrepo/app.bsky.feed.post/3kzbif5moe22m").unwrap(), expected ); assert_eq!( parse_redaction_target("at://did:plc:testrepo/app.bsky.feed.post/3kzbif5moe22m") .unwrap(), expected ); } #[test] fn test_parse_redaction_target_invalid() { assert!(parse_redaction_target("not-a-did").is_err()); assert!(parse_redaction_target("did:plc:test/only-two-parts").is_err()); } }