use crate::api::ApiError; use crate::api::proxy_client::{is_ssrf_safe, proxy_client}; use crate::auth::extractor::BearerAuthAllowTakendown; use crate::state::AppState; use axum::{ Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tracing::{error, info}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateReportInput { pub reason_type: String, pub reason: Option, pub subject: Value, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateReportOutput { pub id: i64, pub reason_type: String, pub reason: Option, pub subject: Value, pub reported_by: String, pub created_at: String, } fn get_report_service_config() -> Option<(String, String)> { let url = std::env::var("REPORT_SERVICE_URL").ok()?; let did = std::env::var("REPORT_SERVICE_DID").ok()?; if url.is_empty() || did.is_empty() { return None; } Some((url, did)) } pub async fn create_report( State(state): State, auth: BearerAuthAllowTakendown, Json(input): Json, ) -> Response { let auth_user = auth.0; let did = &auth_user.did; if let Some((service_url, service_did)) = get_report_service_config() { return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input) .await; } create_report_locally(&state, did, auth_user.is_takendown(), input).await } async fn proxy_to_report_service( state: &AppState, auth_user: &crate::auth::AuthenticatedUser, service_url: &str, service_did: &str, input: &CreateReportInput, ) -> Response { if let Err(e) = is_ssrf_safe(service_url) { 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 sqlx::query_as::<_, (Vec, Option)>( "SELECT k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", ) .bind(&auth_user.did) .fetch_optional(&state.db) .await { Ok(Some((key_bytes_enc, encryption_version))) => { match crate::config::decrypt_key(&key_bytes_enc, 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 crate::auth::create_service_token( &auth_user.did, service_did, "com.atproto.moderation.createReport", &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: &str, is_takendown: bool, input: CreateReportInput, ) -> Response { const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal"; if is_takendown && input.reason_type != REASON_APPEAL { return ApiError::InvalidRequest("Report not accepted from takendown account".into()) .into_response(); } let valid_reason_types = [ "com.atproto.moderation.defs#reasonSpam", "com.atproto.moderation.defs#reasonViolation", "com.atproto.moderation.defs#reasonMisleading", "com.atproto.moderation.defs#reasonSexual", "com.atproto.moderation.defs#reasonRude", "com.atproto.moderation.defs#reasonOther", REASON_APPEAL, ]; if !valid_reason_types.contains(&input.reason_type.as_str()) { return ApiError::InvalidRequest("Invalid reasonType".into()).into_response(); } let created_at = chrono::Utc::now(); let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64; let subject_json = json!(input.subject); let insert = sqlx::query!( "INSERT INTO reports (id, reason_type, reason, subject_json, reported_by_did, created_at) VALUES ($1, $2, $3, $4, $5, $6)", report_id, input.reason_type, input.reason, subject_json, did, created_at ) .execute(&state.db) .await; if let Err(e) = insert { 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, "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() }