1use std::path::Path;
20use std::time::Duration;
21
22use reqwest::Client;
23use serde::{Deserialize, Serialize};
24use serde_json::{Value, json};
25
26use super::auth::acquire_service_auth;
27use super::error::CliError;
28use super::output::truncate;
29use super::pds::PdsClient;
30use super::session::SessionFile;
31
32const CREATE_REPORT_LXM: &str = "com.atproto.moderation.createReport";
33
34#[derive(Debug, Clone)]
37pub struct ReportCreateInput {
38 pub subject: String,
41 pub cid: Option<String>,
44 pub reason_type: String,
47 pub reason: Option<String>,
50 pub cairn_server_override: Option<String>,
52}
53
54#[derive(Debug, Deserialize, Serialize)]
57pub struct CreateReportResponse {
58 pub id: i64,
60 #[serde(rename = "createdAt")]
62 pub created_at: String,
63 #[serde(rename = "reasonType")]
66 pub reason_type: String,
67 #[serde(rename = "reportedBy")]
70 pub reported_by: String,
71 pub subject: Value,
76}
77
78fn build_subject(subject: &str, cid: Option<&str>) -> Result<Value, CliError> {
83 if let Some(rest) = subject.strip_prefix("at://") {
84 if rest.is_empty() {
88 return Err(CliError::Config(
89 "--subject at://... must include a repo and path".into(),
90 ));
91 }
92 let cid =
93 cid.ok_or_else(|| CliError::Config("record subjects (at://...) require --cid".into()))?;
94 Ok(json!({
95 "$type": "com.atproto.repo.strongRef",
96 "uri": subject,
97 "cid": cid,
98 }))
99 } else if subject.starts_with("did:") {
100 if cid.is_some() {
101 return Err(CliError::Config(
102 "--cid is not meaningful for account (did:) subjects".into(),
103 ));
104 }
105 Ok(json!({
106 "$type": "com.atproto.admin.defs#repoRef",
107 "did": subject,
108 }))
109 } else {
110 Err(CliError::Config(format!(
111 "--subject must start with `did:` or `at://`; got {subject}"
112 )))
113 }
114}
115
116pub async fn create(
121 session: &mut SessionFile,
122 session_path: &Path,
123 input: ReportCreateInput,
124) -> Result<CreateReportResponse, CliError> {
125 let subject = build_subject(&input.subject, input.cid.as_deref())?;
126 let cairn_server = input
127 .cairn_server_override
128 .as_deref()
129 .unwrap_or(&session.cairn_server_url)
130 .to_string();
131
132 let pds = PdsClient::new(&session.pds_url)?;
133 let token = acquire_service_auth(&pds, session, session_path, CREATE_REPORT_LXM).await?;
134
135 let url = format!(
136 "{}/xrpc/com.atproto.moderation.createReport",
137 cairn_server.trim_end_matches('/')
138 );
139 let body = json!({
140 "reasonType": input.reason_type,
141 "reason": input.reason,
142 "subject": subject,
143 });
144
145 let client = Client::builder()
146 .timeout(Duration::from_secs(30))
147 .build()
148 .expect("reqwest build");
149 let resp = client
150 .post(&url)
151 .bearer_auth(&token)
152 .json(&body)
153 .send()
154 .await
155 .map_err(|source| CliError::Http {
156 url: url.clone(),
157 source,
158 })?;
159
160 if !resp.status().is_success() {
161 let status = resp.status().as_u16();
162 let body = resp.text().await.unwrap_or_default();
163 return Err(CliError::CairnStatus { url, status, body });
164 }
165 let bytes = resp.bytes().await.map_err(|source| CliError::Http {
166 url: url.clone(),
167 source,
168 })?;
169 serde_json::from_slice::<CreateReportResponse>(&bytes)
170 .map_err(|source| CliError::MalformedResponse { url, source })
171}
172
173pub fn format_create_json(resp: &CreateReportResponse) -> String {
179 serde_json::to_string_pretty(resp).expect("CreateReportResponse serializes")
180}
181
182pub fn format_create_human(resp: &CreateReportResponse) -> String {
188 format!("Report {} created at {}", resp.id, resp.created_at)
189}
190
191const LIST_REPORTS_LXM: &str = "tools.cairn.admin.listReports";
197
198#[derive(Debug, Clone, Deserialize, Serialize)]
201#[serde(tag = "$type")]
202pub enum ReportSubject {
203 #[serde(rename = "com.atproto.admin.defs#repoRef")]
205 Repo {
206 did: String,
208 },
209 #[serde(rename = "com.atproto.repo.strongRef")]
211 Strong {
212 uri: String,
214 cid: String,
216 },
217}
218
219#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct ReportListEntry {
225 pub id: i64,
227 #[serde(rename = "createdAt")]
229 pub created_at: String,
230 #[serde(rename = "reasonType")]
232 pub reason_type: String,
233 pub subject: ReportSubject,
235 #[serde(rename = "reportedBy")]
237 pub reported_by: String,
238 pub status: String,
240 #[serde(
242 rename = "resolvedAt",
243 skip_serializing_if = "Option::is_none",
244 default
245 )]
246 pub resolved_at: Option<String>,
247 #[serde(
249 rename = "resolvedBy",
250 skip_serializing_if = "Option::is_none",
251 default
252 )]
253 pub resolved_by: Option<String>,
254 #[serde(
256 rename = "resolutionLabel",
257 skip_serializing_if = "Option::is_none",
258 default
259 )]
260 pub resolution_label: Option<String>,
261 #[serde(
263 rename = "resolutionReason",
264 skip_serializing_if = "Option::is_none",
265 default
266 )]
267 pub resolution_reason: Option<String>,
268}
269
270#[derive(Debug, Clone, Default)]
272pub struct ReportListInput {
273 pub status: Option<String>,
276 pub reported_by: Option<String>,
278 pub limit: Option<i64>,
280 pub cursor: Option<String>,
282 pub cairn_server_override: Option<String>,
284}
285
286#[derive(Debug, Deserialize, Serialize)]
288pub struct ReportListResponse {
289 pub reports: Vec<ReportListEntry>,
291 #[serde(skip_serializing_if = "Option::is_none", default)]
293 pub cursor: Option<String>,
294}
295
296pub async fn list(
304 session: &mut SessionFile,
305 session_path: &Path,
306 input: ReportListInput,
307) -> Result<ReportListResponse, CliError> {
308 let cairn_server = input
309 .cairn_server_override
310 .as_deref()
311 .unwrap_or(&session.cairn_server_url)
312 .trim_end_matches('/')
313 .to_string();
314 let pds = PdsClient::new(&session.pds_url)?;
315 let token = acquire_service_auth(&pds, session, session_path, LIST_REPORTS_LXM).await?;
316
317 let url = format!("{cairn_server}/xrpc/{LIST_REPORTS_LXM}");
318 let limit_owned = input.limit.map(|n| n.to_string());
321 let mut query: Vec<(&str, &str)> = Vec::new();
322 if let Some(s) = &input.status {
323 query.push(("status", s.as_str()));
324 }
325 if let Some(r) = &input.reported_by {
326 query.push(("reportedBy", r.as_str()));
327 }
328 if let Some(n) = &limit_owned {
329 query.push(("limit", n.as_str()));
330 }
331 if let Some(c) = &input.cursor {
332 query.push(("cursor", c.as_str()));
333 }
334
335 let client = build_client();
336 let resp = client
337 .get(&url)
338 .bearer_auth(&token)
339 .query(&query)
340 .send()
341 .await
342 .map_err(|source| CliError::Http {
343 url: url.clone(),
344 source,
345 })?;
346 cairn_response::<ReportListResponse>(url, resp).await
347}
348
349pub fn format_list_human(resp: &ReportListResponse) -> String {
354 use std::fmt::Write;
355 if resp.reports.is_empty() {
356 let mut s = "(no reports)".to_string();
357 if let Some(c) = &resp.cursor {
358 let _ = write!(s, "\nnext cursor: {c}");
359 }
360 return s;
361 }
362 let id_w = resp
363 .reports
364 .iter()
365 .map(|e| e.id.to_string().len())
366 .max()
367 .unwrap_or(2)
368 .max(2);
369 let reporter_w = resp
370 .reports
371 .iter()
372 .map(|e| e.reported_by.len().min(40))
373 .max()
374 .unwrap_or(8)
375 .max(8);
376 let subject_w = resp
377 .reports
378 .iter()
379 .map(|e| subject_summary(&e.subject).len().min(40))
380 .max()
381 .unwrap_or(8)
382 .max(8);
383 let mut s = String::new();
384 let _ = writeln!(
385 s,
386 "{:>id_w$} {:<9} {:<reporter_w$} {:<subject_w$} {:<20}",
387 "ID",
388 "STATUS",
389 "REPORTER",
390 "SUBJECT",
391 "CREATED_AT",
392 id_w = id_w,
393 reporter_w = reporter_w,
394 subject_w = subject_w,
395 );
396 for e in &resp.reports {
397 let reporter = truncate(&e.reported_by, 40);
398 let subj = truncate(&subject_summary(&e.subject), 40);
399 let _ = writeln!(
400 s,
401 "{:>id_w$} {:<9} {:<reporter_w$} {:<subject_w$} {:<20}",
402 e.id,
403 e.status,
404 reporter,
405 subj,
406 e.created_at,
407 id_w = id_w,
408 reporter_w = reporter_w,
409 subject_w = subject_w,
410 );
411 }
412 if let Some(c) = &resp.cursor {
413 let _ = write!(s, "next cursor: {c}");
414 } else if s.ends_with('\n') {
415 s.pop();
416 }
417 s
418}
419
420pub fn format_list_json(resp: &ReportListResponse) -> String {
422 serde_json::to_string_pretty(resp).expect("ReportListResponse serializes")
423}
424
425const GET_REPORT_LXM: &str = "tools.cairn.admin.getReport";
431
432#[derive(Debug, Clone, Deserialize, Serialize)]
435pub struct ReportDetail {
436 pub id: i64,
438 #[serde(rename = "createdAt")]
440 pub created_at: String,
441 #[serde(rename = "reasonType")]
443 pub reason_type: String,
444 #[serde(skip_serializing_if = "Option::is_none", default)]
447 pub reason: Option<String>,
448 pub subject: ReportSubject,
450 #[serde(rename = "reportedBy")]
452 pub reported_by: String,
453 pub status: String,
455 #[serde(
457 rename = "resolvedAt",
458 skip_serializing_if = "Option::is_none",
459 default
460 )]
461 pub resolved_at: Option<String>,
462 #[serde(
464 rename = "resolvedBy",
465 skip_serializing_if = "Option::is_none",
466 default
467 )]
468 pub resolved_by: Option<String>,
469 #[serde(
471 rename = "resolutionLabel",
472 skip_serializing_if = "Option::is_none",
473 default
474 )]
475 pub resolution_label: Option<String>,
476 #[serde(
478 rename = "resolutionReason",
479 skip_serializing_if = "Option::is_none",
480 default
481 )]
482 pub resolution_reason: Option<String>,
483}
484
485#[derive(Debug, Clone)]
487pub struct ReportViewInput {
488 pub id: i64,
490 pub cairn_server_override: Option<String>,
492}
493
494pub async fn view(
502 session: &mut SessionFile,
503 session_path: &Path,
504 input: ReportViewInput,
505) -> Result<ReportDetail, CliError> {
506 let cairn_server = input
507 .cairn_server_override
508 .as_deref()
509 .unwrap_or(&session.cairn_server_url)
510 .trim_end_matches('/')
511 .to_string();
512 let pds = PdsClient::new(&session.pds_url)?;
513 let token = acquire_service_auth(&pds, session, session_path, GET_REPORT_LXM).await?;
514
515 let url = format!("{cairn_server}/xrpc/{GET_REPORT_LXM}");
516 let id_str = input.id.to_string();
517 let client = build_client();
518 let resp = client
519 .get(&url)
520 .bearer_auth(&token)
521 .query(&[("id", id_str.as_str())])
522 .send()
523 .await
524 .map_err(|source| CliError::Http {
525 url: url.clone(),
526 source,
527 })?;
528 cairn_response::<ReportDetail>(url, resp).await
529}
530
531pub fn format_view_human(detail: &ReportDetail) -> String {
533 use std::fmt::Write;
534 let mut s = String::new();
535 let _ = writeln!(s, "Report {}", detail.id);
536 let _ = writeln!(s, " status: {}", detail.status);
537 let _ = writeln!(s, " created_at: {}", detail.created_at);
538 let _ = writeln!(s, " reported_by: {}", detail.reported_by);
539 let _ = writeln!(s, " reason_type: {}", detail.reason_type);
540 if let Some(r) = &detail.reason {
541 let _ = writeln!(s, " reason: {r}");
542 }
543 let _ = writeln!(s, " subject: {}", subject_summary(&detail.subject));
544 if let Some(t) = &detail.resolved_at {
545 let _ = writeln!(s, " resolved_at: {t}");
546 }
547 if let Some(by) = &detail.resolved_by {
548 let _ = writeln!(s, " resolved_by: {by}");
549 }
550 if let Some(lab) = &detail.resolution_label {
551 let _ = writeln!(s, " resolution_label: {lab}");
552 }
553 if let Some(r) = &detail.resolution_reason {
554 let _ = writeln!(s, " resolution_reason: {r}");
555 }
556 if s.ends_with('\n') {
557 s.pop();
558 }
559 s
560}
561
562pub fn format_view_json(detail: &ReportDetail) -> String {
564 serde_json::to_string_pretty(detail).expect("ReportDetail serializes")
565}
566
567const RESOLVE_REPORT_LXM: &str = "tools.cairn.admin.resolveReport";
576
577#[derive(Debug, Clone, Serialize)]
580pub struct ApplyLabelArg {
581 pub uri: String,
584 #[serde(skip_serializing_if = "Option::is_none")]
587 pub cid: Option<String>,
588 pub val: String,
591 #[serde(skip_serializing_if = "Option::is_none")]
593 pub exp: Option<String>,
594}
595
596#[derive(Debug, Clone)]
598pub struct ReportResolveInput {
599 pub id: i64,
601 pub apply_label: Option<ApplyLabelArg>,
605 pub reason: Option<String>,
608 pub cairn_server_override: Option<String>,
610}
611
612pub async fn resolve(
624 session: &mut SessionFile,
625 session_path: &Path,
626 input: ReportResolveInput,
627) -> Result<ReportDetail, CliError> {
628 let cairn_server = input
629 .cairn_server_override
630 .as_deref()
631 .unwrap_or(&session.cairn_server_url)
632 .trim_end_matches('/')
633 .to_string();
634 let pds = PdsClient::new(&session.pds_url)?;
635 let token = acquire_service_auth(&pds, session, session_path, RESOLVE_REPORT_LXM).await?;
636
637 let mut body = json!({ "id": input.id });
641 if let Some(apply) = &input.apply_label {
642 body["applyLabel"] = serde_json::to_value(apply).expect("ApplyLabelArg serializes");
643 }
644 if let Some(r) = &input.reason {
645 body["reason"] = json!(r);
646 }
647
648 let url = format!("{cairn_server}/xrpc/{RESOLVE_REPORT_LXM}");
649 let client = build_client();
650 let resp = client
651 .post(&url)
652 .bearer_auth(&token)
653 .json(&body)
654 .send()
655 .await
656 .map_err(|source| CliError::Http {
657 url: url.clone(),
658 source,
659 })?;
660 cairn_response::<ReportDetail>(url, resp).await
661}
662
663pub fn format_resolve_human(detail: &ReportDetail) -> String {
667 let label = detail
668 .resolution_label
669 .as_deref()
670 .map(|v| format!(" with label {v}"))
671 .unwrap_or_default();
672 format!("Resolved report {}{}", detail.id, label)
673}
674
675pub fn format_resolve_json(detail: &ReportDetail) -> String {
678 serde_json::to_string_pretty(detail).expect("ReportDetail serializes")
679}
680
681const FLAG_REPORTER_LXM: &str = "tools.cairn.admin.flagReporter";
691
692#[derive(Debug, Clone)]
694pub struct ReportFlagInput {
695 pub did: String,
698 pub suppressed: bool,
703 pub reason: Option<String>,
706 pub cairn_server_override: Option<String>,
708}
709
710#[derive(Debug, Clone, Serialize)]
715pub struct ReportFlagResponse {
716 pub did: String,
718 pub suppressed: bool,
720}
721
722pub async fn flag(
737 session: &mut SessionFile,
738 session_path: &Path,
739 input: ReportFlagInput,
740) -> Result<ReportFlagResponse, CliError> {
741 if !input.did.starts_with("did:") {
742 return Err(CliError::Config(format!(
743 "DID must start with 'did:'; got {:?}",
744 input.did
745 )));
746 }
747 let cairn_server = input
748 .cairn_server_override
749 .as_deref()
750 .unwrap_or(&session.cairn_server_url)
751 .trim_end_matches('/')
752 .to_string();
753 let pds = PdsClient::new(&session.pds_url)?;
754 let token = acquire_service_auth(&pds, session, session_path, FLAG_REPORTER_LXM).await?;
755
756 let body = json!({
757 "did": input.did,
758 "suppressed": input.suppressed,
759 "reason": input.reason,
760 });
761 let url = format!("{cairn_server}/xrpc/{FLAG_REPORTER_LXM}");
762 let client = build_client();
763 let resp = client
764 .post(&url)
765 .bearer_auth(&token)
766 .json(&body)
767 .send()
768 .await
769 .map_err(|source| CliError::Http {
770 url: url.clone(),
771 source,
772 })?;
773 let _: serde_json::Value = cairn_response::<serde_json::Value>(url, resp).await?;
779 Ok(ReportFlagResponse {
780 did: input.did,
781 suppressed: input.suppressed,
782 })
783}
784
785pub fn format_flag_human(resp: &ReportFlagResponse) -> String {
787 let verb = if resp.suppressed {
788 "Flagged"
789 } else {
790 "Unflagged"
791 };
792 format!("{verb} reporter {}", resp.did)
793}
794
795pub fn format_flag_json(resp: &ReportFlagResponse) -> String {
799 let action = if resp.suppressed { "flag" } else { "unflag" };
800 let body = json!({
801 "action": action,
802 "did": resp.did,
803 "suppressed": resp.suppressed,
804 });
805 serde_json::to_string_pretty(&body).expect("flag JSON serializes")
806}
807
808fn build_client() -> Client {
815 Client::builder()
816 .timeout(Duration::from_secs(30))
817 .build()
818 .expect("reqwest build")
819}
820
821async fn cairn_response<T: serde::de::DeserializeOwned>(
825 url: String,
826 resp: reqwest::Response,
827) -> Result<T, CliError> {
828 if !resp.status().is_success() {
829 let status = resp.status().as_u16();
830 let body = resp.text().await.unwrap_or_default();
831 return Err(CliError::CairnStatus { url, status, body });
832 }
833 let bytes = resp.bytes().await.map_err(|source| CliError::Http {
834 url: url.clone(),
835 source,
836 })?;
837 serde_json::from_slice::<T>(&bytes)
838 .map_err(|source| CliError::MalformedResponse { url, source })
839}
840
841fn subject_summary(s: &ReportSubject) -> String {
844 match s {
845 ReportSubject::Repo { did } => did.clone(),
846 ReportSubject::Strong { uri, cid } => format!("{uri}@{cid}"),
847 }
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn subject_did_maps_to_repo_ref() {
856 let v = build_subject("did:plc:example", None).unwrap();
857 assert_eq!(v["$type"], "com.atproto.admin.defs#repoRef");
858 assert_eq!(v["did"], "did:plc:example");
859 }
860
861 #[test]
862 fn subject_at_uri_requires_cid() {
863 let err = build_subject("at://did:plc:x/col/r", None).unwrap_err();
864 assert!(matches!(err, CliError::Config(_)));
865 }
866
867 #[test]
868 fn subject_at_uri_with_cid_maps_to_strong_ref() {
869 let v = build_subject("at://did:plc:x/col/r", Some("bafy")).unwrap();
870 assert_eq!(v["$type"], "com.atproto.repo.strongRef");
871 assert_eq!(v["uri"], "at://did:plc:x/col/r");
872 assert_eq!(v["cid"], "bafy");
873 }
874
875 #[test]
876 fn subject_did_with_cid_rejected() {
877 let err = build_subject("did:plc:x", Some("bafy")).unwrap_err();
878 assert!(matches!(err, CliError::Config(_)));
879 }
880
881 #[test]
882 fn subject_unknown_shape_rejected() {
883 let err = build_subject("bsky.example/profile", None).unwrap_err();
884 assert!(matches!(err, CliError::Config(_)));
885 }
886}