use axum::{ Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::sync::LazyLock; use tracing::{error, info, warn}; use tranquil_pds::api::ApiError; use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client}; use tranquil_pds::auth::{AnyUser, Auth}; use tranquil_pds::state::AppState; use tranquil_pds::types::{Did, Nsid}; static CREATE_REPORT_NSID: LazyLock = LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap()); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ReportReasonType { #[serde(rename = "com.atproto.moderation.defs#reasonSpam")] Spam, #[serde(rename = "com.atproto.moderation.defs#reasonViolation")] Violation, #[serde(rename = "com.atproto.moderation.defs#reasonMisleading")] Misleading, #[serde(rename = "com.atproto.moderation.defs#reasonSexual")] Sexual, #[serde(rename = "com.atproto.moderation.defs#reasonRude")] Rude, #[serde(rename = "com.atproto.moderation.defs#reasonOther")] Other, #[serde(rename = "com.atproto.moderation.defs#reasonAppeal")] Appeal, } impl ReportReasonType { pub fn as_str(self) -> &'static str { match self { Self::Spam => "com.atproto.moderation.defs#reasonSpam", Self::Violation => "com.atproto.moderation.defs#reasonViolation", Self::Misleading => "com.atproto.moderation.defs#reasonMisleading", Self::Sexual => "com.atproto.moderation.defs#reasonSexual", Self::Rude => "com.atproto.moderation.defs#reasonRude", Self::Other => "com.atproto.moderation.defs#reasonOther", Self::Appeal => "com.atproto.moderation.defs#reasonAppeal", } } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateReportInput { pub reason_type: ReportReasonType, pub reason: Option, pub subject: Value, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateReportOutput { pub id: i64, pub reason_type: ReportReasonType, pub reason: Option, pub subject: Value, pub reported_by: String, pub created_at: String, } struct ReportServiceConfig { url: String, did: Did, } fn get_report_service_config() -> Option { let cfg = tranquil_config::get(); let url = cfg.moderation.report_service_url.clone()?; let did_str = cfg.moderation.report_service_did.as_deref()?; if url.is_empty() || did_str.is_empty() { return None; } match did_str.parse::() { Ok(did) => Some(ReportServiceConfig { url, did }), Err(_) => { warn!( report_service_did = did_str, "invalid report_service_did, handling reports locally" ); None } } } pub async fn create_report( State(state): State, auth: Auth, Json(input): Json, ) -> Response { let did = &auth.did; if let Some(config) = get_report_service_config() { return proxy_to_report_service(&state, &auth, &config.url, &config.did, &input).await; } create_report_locally(&state, did, auth.status.is_takendown(), input).await } async fn proxy_to_report_service( state: &AppState, auth_user: &tranquil_pds::auth::AuthenticatedUser, service_url: &str, service_did: &Did, input: &CreateReportInput, ) -> Response { if let Err(e) = is_ssrf_safe(service_url).await { error!("Report service URL failed SSRF check: {:?}", e); return ApiError::InternalError(Some("Invalid report service configuration".into())) .into_response(); } let key_bytes = match &auth_user.key_bytes { Some(kb) => kb.clone(), None => match state.repos.user.get_with_key_by_did(&auth_user.did).await { Ok(Some(user_with_key)) => { match tranquil_pds::config::decrypt_key( &user_with_key.key_bytes, user_with_key.encryption_version, ) { Ok(key) => key, Err(e) => { error!(error = ?e, "Failed to decrypt user key for report service auth"); return ApiError::AuthenticationFailed(Some( "Failed to get signing key".into(), )) .into_response(); } } } Ok(None) => { return ApiError::AuthenticationFailed(Some("User has no signing key".into())) .into_response(); } Err(e) => { error!(error = ?e, "DB error fetching user key for report"); return ApiError::AuthenticationFailed(Some("Failed to get signing key".into())) .into_response(); } }, }; let service_token = match tranquil_pds::auth::create_service_token( &auth_user.did, service_did, Some(&CREATE_REPORT_NSID), &key_bytes, ) { Ok(t) => t, Err(e) => { error!("Failed to create service token for report: {:?}", e); return ApiError::InternalError(None).into_response(); } }; let target_url = format!("{}/xrpc/com.atproto.moderation.createReport", service_url); info!( did = %auth_user.did, service_did = %service_did, "Proxying createReport to report service" ); let request_body = json!({ "reasonType": input.reason_type, "reason": input.reason, "subject": input.subject }); let client = proxy_client(); let result = client .post(&target_url) .header("Authorization", format!("Bearer {}", service_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await; match result { Ok(resp) => { let status = resp.status(); let headers = resp.headers().clone(); let body = match resp.bytes().await { Ok(b) => b, Err(e) => { error!("Error reading report service response: {:?}", e); return (StatusCode::BAD_GATEWAY, "Error reading upstream response") .into_response(); } }; let mut response_builder = Response::builder().status(status); if let Some(ct) = headers.get("content-type") { response_builder = response_builder.header("content-type", ct); } match response_builder.body(axum::body::Body::from(body)) { Ok(r) => r, Err(e) => { error!("Error building proxy response: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() } } } Err(e) => { error!("Error sending report to service: {:?}", e); if e.is_timeout() { (StatusCode::GATEWAY_TIMEOUT, "Report service timeout").into_response() } else { (StatusCode::BAD_GATEWAY, "Report service error").into_response() } } } } async fn create_report_locally( state: &AppState, did: &tranquil_pds::types::Did, is_takendown: bool, input: CreateReportInput, ) -> Response { if is_takendown && input.reason_type != ReportReasonType::Appeal { return ApiError::InvalidRequest("Report not accepted from takendown account".into()) .into_response(); } let created_at = chrono::Utc::now(); let report_id = i64::try_from(uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) .expect("masked to 63 bits, always fits i64"); let subject_json = json!(input.subject); if let Err(e) = state .repos .infra .insert_report( report_id, input.reason_type.as_str(), input.reason.as_deref(), subject_json, did, created_at, ) .await { error!("Failed to insert report: {:?}", e); return ApiError::InternalError(None).into_response(); } info!( report_id = %report_id, reported_by = %did, reason_type = input.reason_type.as_str(), "Report created locally (no report service configured)" ); ( StatusCode::OK, Json(CreateReportOutput { id: report_id, reason_type: input.reason_type, reason: input.reason, subject: input.subject, reported_by: did.to_string(), created_at: created_at.to_rfc3339(), }), ) .into_response() }