use std::path::Path;
use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::auth::acquire_service_auth;
use super::error::CliError;
use super::output::truncate;
use super::pds::PdsClient;
use super::session::SessionFile;
const CREATE_REPORT_LXM: &str = "com.atproto.moderation.createReport";
#[derive(Debug, Clone)]
pub struct ReportCreateInput {
pub subject: String,
pub cid: Option<String>,
pub reason_type: String,
pub reason: Option<String>,
pub cairn_server_override: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateReportResponse {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "reasonType")]
pub reason_type: String,
#[serde(rename = "reportedBy")]
pub reported_by: String,
pub subject: Value,
}
fn build_subject(subject: &str, cid: Option<&str>) -> Result<Value, CliError> {
if let Some(rest) = subject.strip_prefix("at://") {
if rest.is_empty() {
return Err(CliError::Config(
"--subject at://... must include a repo and path".into(),
));
}
let cid =
cid.ok_or_else(|| CliError::Config("record subjects (at://...) require --cid".into()))?;
Ok(json!({
"$type": "com.atproto.repo.strongRef",
"uri": subject,
"cid": cid,
}))
} else if subject.starts_with("did:") {
if cid.is_some() {
return Err(CliError::Config(
"--cid is not meaningful for account (did:) subjects".into(),
));
}
Ok(json!({
"$type": "com.atproto.admin.defs#repoRef",
"did": subject,
}))
} else {
Err(CliError::Config(format!(
"--subject must start with `did:` or `at://`; got {subject}"
)))
}
}
pub async fn create(
session: &mut SessionFile,
session_path: &Path,
input: ReportCreateInput,
) -> Result<CreateReportResponse, CliError> {
let subject = build_subject(&input.subject, input.cid.as_deref())?;
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, CREATE_REPORT_LXM).await?;
let url = format!(
"{}/xrpc/com.atproto.moderation.createReport",
cairn_server.trim_end_matches('/')
);
let body = json!({
"reasonType": input.reason_type,
"reason": input.reason,
"subject": subject,
});
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest build");
let resp = client
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::CairnStatus { url, status, body });
}
let bytes = resp.bytes().await.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
serde_json::from_slice::<CreateReportResponse>(&bytes)
.map_err(|source| CliError::MalformedResponse { url, source })
}
pub fn format_create_json(resp: &CreateReportResponse) -> String {
serde_json::to_string_pretty(resp).expect("CreateReportResponse serializes")
}
pub fn format_create_human(resp: &CreateReportResponse) -> String {
format!("Report {} created at {}", resp.id, resp.created_at)
}
const LIST_REPORTS_LXM: &str = "tools.cairn.admin.listReports";
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "$type")]
pub enum ReportSubject {
#[serde(rename = "com.atproto.admin.defs#repoRef")]
Repo {
did: String,
},
#[serde(rename = "com.atproto.repo.strongRef")]
Strong {
uri: String,
cid: String,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ReportListEntry {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "reasonType")]
pub reason_type: String,
pub subject: ReportSubject,
#[serde(rename = "reportedBy")]
pub reported_by: String,
pub status: String,
#[serde(
rename = "resolvedAt",
skip_serializing_if = "Option::is_none",
default
)]
pub resolved_at: Option<String>,
#[serde(
rename = "resolvedBy",
skip_serializing_if = "Option::is_none",
default
)]
pub resolved_by: Option<String>,
#[serde(
rename = "resolutionLabel",
skip_serializing_if = "Option::is_none",
default
)]
pub resolution_label: Option<String>,
#[serde(
rename = "resolutionReason",
skip_serializing_if = "Option::is_none",
default
)]
pub resolution_reason: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReportListInput {
pub status: Option<String>,
pub reported_by: Option<String>,
pub limit: Option<i64>,
pub cursor: Option<String>,
pub cairn_server_override: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ReportListResponse {
pub reports: Vec<ReportListEntry>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub cursor: Option<String>,
}
pub async fn list(
session: &mut SessionFile,
session_path: &Path,
input: ReportListInput,
) -> Result<ReportListResponse, CliError> {
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, LIST_REPORTS_LXM).await?;
let url = format!("{cairn_server}/xrpc/{LIST_REPORTS_LXM}");
let limit_owned = input.limit.map(|n| n.to_string());
let mut query: Vec<(&str, &str)> = Vec::new();
if let Some(s) = &input.status {
query.push(("status", s.as_str()));
}
if let Some(r) = &input.reported_by {
query.push(("reportedBy", r.as_str()));
}
if let Some(n) = &limit_owned {
query.push(("limit", n.as_str()));
}
if let Some(c) = &input.cursor {
query.push(("cursor", c.as_str()));
}
let client = build_client();
let resp = client
.get(&url)
.bearer_auth(&token)
.query(&query)
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
cairn_response::<ReportListResponse>(url, resp).await
}
pub fn format_list_human(resp: &ReportListResponse) -> String {
use std::fmt::Write;
if resp.reports.is_empty() {
let mut s = "(no reports)".to_string();
if let Some(c) = &resp.cursor {
let _ = write!(s, "\nnext cursor: {c}");
}
return s;
}
let id_w = resp
.reports
.iter()
.map(|e| e.id.to_string().len())
.max()
.unwrap_or(2)
.max(2);
let reporter_w = resp
.reports
.iter()
.map(|e| e.reported_by.len().min(40))
.max()
.unwrap_or(8)
.max(8);
let subject_w = resp
.reports
.iter()
.map(|e| subject_summary(&e.subject).len().min(40))
.max()
.unwrap_or(8)
.max(8);
let mut s = String::new();
let _ = writeln!(
s,
"{:>id_w$} {:<9} {:<reporter_w$} {:<subject_w$} {:<20}",
"ID",
"STATUS",
"REPORTER",
"SUBJECT",
"CREATED_AT",
id_w = id_w,
reporter_w = reporter_w,
subject_w = subject_w,
);
for e in &resp.reports {
let reporter = truncate(&e.reported_by, 40);
let subj = truncate(&subject_summary(&e.subject), 40);
let _ = writeln!(
s,
"{:>id_w$} {:<9} {:<reporter_w$} {:<subject_w$} {:<20}",
e.id,
e.status,
reporter,
subj,
e.created_at,
id_w = id_w,
reporter_w = reporter_w,
subject_w = subject_w,
);
}
if let Some(c) = &resp.cursor {
let _ = write!(s, "next cursor: {c}");
} else if s.ends_with('\n') {
s.pop();
}
s
}
pub fn format_list_json(resp: &ReportListResponse) -> String {
serde_json::to_string_pretty(resp).expect("ReportListResponse serializes")
}
const GET_REPORT_LXM: &str = "tools.cairn.admin.getReport";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ReportDetail {
pub id: i64,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "reasonType")]
pub reason_type: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reason: Option<String>,
pub subject: ReportSubject,
#[serde(rename = "reportedBy")]
pub reported_by: String,
pub status: String,
#[serde(
rename = "resolvedAt",
skip_serializing_if = "Option::is_none",
default
)]
pub resolved_at: Option<String>,
#[serde(
rename = "resolvedBy",
skip_serializing_if = "Option::is_none",
default
)]
pub resolved_by: Option<String>,
#[serde(
rename = "resolutionLabel",
skip_serializing_if = "Option::is_none",
default
)]
pub resolution_label: Option<String>,
#[serde(
rename = "resolutionReason",
skip_serializing_if = "Option::is_none",
default
)]
pub resolution_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReportViewInput {
pub id: i64,
pub cairn_server_override: Option<String>,
}
pub async fn view(
session: &mut SessionFile,
session_path: &Path,
input: ReportViewInput,
) -> Result<ReportDetail, CliError> {
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, GET_REPORT_LXM).await?;
let url = format!("{cairn_server}/xrpc/{GET_REPORT_LXM}");
let id_str = input.id.to_string();
let client = build_client();
let resp = client
.get(&url)
.bearer_auth(&token)
.query(&[("id", id_str.as_str())])
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
cairn_response::<ReportDetail>(url, resp).await
}
pub fn format_view_human(detail: &ReportDetail) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "Report {}", detail.id);
let _ = writeln!(s, " status: {}", detail.status);
let _ = writeln!(s, " created_at: {}", detail.created_at);
let _ = writeln!(s, " reported_by: {}", detail.reported_by);
let _ = writeln!(s, " reason_type: {}", detail.reason_type);
if let Some(r) = &detail.reason {
let _ = writeln!(s, " reason: {r}");
}
let _ = writeln!(s, " subject: {}", subject_summary(&detail.subject));
if let Some(t) = &detail.resolved_at {
let _ = writeln!(s, " resolved_at: {t}");
}
if let Some(by) = &detail.resolved_by {
let _ = writeln!(s, " resolved_by: {by}");
}
if let Some(lab) = &detail.resolution_label {
let _ = writeln!(s, " resolution_label: {lab}");
}
if let Some(r) = &detail.resolution_reason {
let _ = writeln!(s, " resolution_reason: {r}");
}
if s.ends_with('\n') {
s.pop();
}
s
}
pub fn format_view_json(detail: &ReportDetail) -> String {
serde_json::to_string_pretty(detail).expect("ReportDetail serializes")
}
const RESOLVE_REPORT_LXM: &str = "tools.cairn.admin.resolveReport";
#[derive(Debug, Clone, Serialize)]
pub struct ApplyLabelArg {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cid: Option<String>,
pub val: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReportResolveInput {
pub id: i64,
pub apply_label: Option<ApplyLabelArg>,
pub reason: Option<String>,
pub cairn_server_override: Option<String>,
}
pub async fn resolve(
session: &mut SessionFile,
session_path: &Path,
input: ReportResolveInput,
) -> Result<ReportDetail, CliError> {
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, RESOLVE_REPORT_LXM).await?;
let mut body = json!({ "id": input.id });
if let Some(apply) = &input.apply_label {
body["applyLabel"] = serde_json::to_value(apply).expect("ApplyLabelArg serializes");
}
if let Some(r) = &input.reason {
body["reason"] = json!(r);
}
let url = format!("{cairn_server}/xrpc/{RESOLVE_REPORT_LXM}");
let client = build_client();
let resp = client
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
cairn_response::<ReportDetail>(url, resp).await
}
pub fn format_resolve_human(detail: &ReportDetail) -> String {
let label = detail
.resolution_label
.as_deref()
.map(|v| format!(" with label {v}"))
.unwrap_or_default();
format!("Resolved report {}{}", detail.id, label)
}
pub fn format_resolve_json(detail: &ReportDetail) -> String {
serde_json::to_string_pretty(detail).expect("ReportDetail serializes")
}
const FLAG_REPORTER_LXM: &str = "tools.cairn.admin.flagReporter";
#[derive(Debug, Clone)]
pub struct ReportFlagInput {
pub did: String,
pub suppressed: bool,
pub reason: Option<String>,
pub cairn_server_override: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReportFlagResponse {
pub did: String,
pub suppressed: bool,
}
pub async fn flag(
session: &mut SessionFile,
session_path: &Path,
input: ReportFlagInput,
) -> Result<ReportFlagResponse, CliError> {
if !input.did.starts_with("did:") {
return Err(CliError::Config(format!(
"DID must start with 'did:'; got {:?}",
input.did
)));
}
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, FLAG_REPORTER_LXM).await?;
let body = json!({
"did": input.did,
"suppressed": input.suppressed,
"reason": input.reason,
});
let url = format!("{cairn_server}/xrpc/{FLAG_REPORTER_LXM}");
let client = build_client();
let resp = client
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
let _: serde_json::Value = cairn_response::<serde_json::Value>(url, resp).await?;
Ok(ReportFlagResponse {
did: input.did,
suppressed: input.suppressed,
})
}
pub fn format_flag_human(resp: &ReportFlagResponse) -> String {
let verb = if resp.suppressed {
"Flagged"
} else {
"Unflagged"
};
format!("{verb} reporter {}", resp.did)
}
pub fn format_flag_json(resp: &ReportFlagResponse) -> String {
let action = if resp.suppressed { "flag" } else { "unflag" };
let body = json!({
"action": action,
"did": resp.did,
"suppressed": resp.suppressed,
});
serde_json::to_string_pretty(&body).expect("flag JSON serializes")
}
fn build_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest build")
}
async fn cairn_response<T: serde::de::DeserializeOwned>(
url: String,
resp: reqwest::Response,
) -> Result<T, CliError> {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::CairnStatus { url, status, body });
}
let bytes = resp.bytes().await.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
serde_json::from_slice::<T>(&bytes)
.map_err(|source| CliError::MalformedResponse { url, source })
}
fn subject_summary(s: &ReportSubject) -> String {
match s {
ReportSubject::Repo { did } => did.clone(),
ReportSubject::Strong { uri, cid } => format!("{uri}@{cid}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subject_did_maps_to_repo_ref() {
let v = build_subject("did:plc:example", None).unwrap();
assert_eq!(v["$type"], "com.atproto.admin.defs#repoRef");
assert_eq!(v["did"], "did:plc:example");
}
#[test]
fn subject_at_uri_requires_cid() {
let err = build_subject("at://did:plc:x/col/r", None).unwrap_err();
assert!(matches!(err, CliError::Config(_)));
}
#[test]
fn subject_at_uri_with_cid_maps_to_strong_ref() {
let v = build_subject("at://did:plc:x/col/r", Some("bafy")).unwrap();
assert_eq!(v["$type"], "com.atproto.repo.strongRef");
assert_eq!(v["uri"], "at://did:plc:x/col/r");
assert_eq!(v["cid"], "bafy");
}
#[test]
fn subject_did_with_cid_rejected() {
let err = build_subject("did:plc:x", Some("bafy")).unwrap_err();
assert!(matches!(err, CliError::Config(_)));
}
#[test]
fn subject_unknown_shape_rejected() {
let err = build_subject("bsky.example/profile", None).unwrap_err();
assert!(matches!(err, CliError::Config(_)));
}
}