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<DeleteBodyTarget>,
#[serde(default)]
pub exclude: Option<bool>,
}
#[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<RedactionTarget, String> {
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<Hydrant> {
#[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<Hydrant>,
Path(target_raw): Path<String>,
Query(params): Query<RedactionQueryParams>,
) -> Result<Json<DeleteBodiesReport>, (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) => {
if params.target.is_some() {
return Err((
StatusCode::BAD_REQUEST,
"target only applies to a record, a repository is always erased whole".into(),
));
}
hydrant
.repos
.erase_repo(&did, params.exclude.unwrap_or(false))
.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());
}
}