this repo has no description
1use crate::api::ApiError; 2use crate::api::proxy_client::{is_ssrf_safe, proxy_client}; 3use crate::auth::extractor::BearerAuthAllowTakendown; 4use crate::state::AppState; 5use axum::{ 6 Json, 7 extract::State, 8 http::StatusCode, 9 response::{IntoResponse, Response}, 10}; 11use serde::{Deserialize, Serialize}; 12use serde_json::{Value, json}; 13use tracing::{error, info}; 14 15#[derive(Deserialize)] 16#[serde(rename_all = "camelCase")] 17pub struct CreateReportInput { 18 pub reason_type: String, 19 pub reason: Option<String>, 20 pub subject: Value, 21} 22 23#[derive(Serialize)] 24#[serde(rename_all = "camelCase")] 25pub struct CreateReportOutput { 26 pub id: i64, 27 pub reason_type: String, 28 pub reason: Option<String>, 29 pub subject: Value, 30 pub reported_by: String, 31 pub created_at: String, 32} 33 34fn get_report_service_config() -> Option<(String, String)> { 35 let url = std::env::var("REPORT_SERVICE_URL").ok()?; 36 let did = std::env::var("REPORT_SERVICE_DID").ok()?; 37 if url.is_empty() || did.is_empty() { 38 return None; 39 } 40 Some((url, did)) 41} 42 43pub async fn create_report( 44 State(state): State<AppState>, 45 auth: BearerAuthAllowTakendown, 46 Json(input): Json<CreateReportInput>, 47) -> Response { 48 let auth_user = auth.0; 49 let did = &auth_user.did; 50 51 if let Some((service_url, service_did)) = get_report_service_config() { 52 return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input) 53 .await; 54 } 55 56 create_report_locally(&state, did, auth_user.is_takendown(), input).await 57} 58 59async fn proxy_to_report_service( 60 state: &AppState, 61 auth_user: &crate::auth::AuthenticatedUser, 62 service_url: &str, 63 service_did: &str, 64 input: &CreateReportInput, 65) -> Response { 66 if let Err(e) = is_ssrf_safe(service_url) { 67 error!("Report service URL failed SSRF check: {:?}", e); 68 return ApiError::InternalError(Some("Invalid report service configuration".into())) 69 .into_response(); 70 } 71 72 let key_bytes = match &auth_user.key_bytes { 73 Some(kb) => kb.clone(), 74 None => { 75 match sqlx::query_as::<_, (Vec<u8>, Option<i32>)>( 76 "SELECT k.key_bytes, k.encryption_version 77 FROM users u 78 JOIN user_keys k ON u.id = k.user_id 79 WHERE u.did = $1", 80 ) 81 .bind(&auth_user.did) 82 .fetch_optional(&state.db) 83 .await 84 { 85 Ok(Some((key_bytes_enc, encryption_version))) => { 86 match crate::config::decrypt_key(&key_bytes_enc, encryption_version) { 87 Ok(key) => key, 88 Err(e) => { 89 error!(error = ?e, "Failed to decrypt user key for report service auth"); 90 return ApiError::AuthenticationFailed(Some( 91 "Failed to get signing key".into(), 92 )) 93 .into_response(); 94 } 95 } 96 } 97 Ok(None) => { 98 return ApiError::AuthenticationFailed(Some("User has no signing key".into())) 99 .into_response(); 100 } 101 Err(e) => { 102 error!(error = ?e, "DB error fetching user key for report"); 103 return ApiError::AuthenticationFailed(Some( 104 "Failed to get signing key".into(), 105 )) 106 .into_response(); 107 } 108 } 109 } 110 }; 111 112 let service_token = match crate::auth::create_service_token( 113 &auth_user.did, 114 service_did, 115 "com.atproto.moderation.createReport", 116 &key_bytes, 117 ) { 118 Ok(t) => t, 119 Err(e) => { 120 error!("Failed to create service token for report: {:?}", e); 121 return ApiError::InternalError(None).into_response(); 122 } 123 }; 124 125 let target_url = format!("{}/xrpc/com.atproto.moderation.createReport", service_url); 126 info!( 127 did = %auth_user.did, 128 service_did = %service_did, 129 "Proxying createReport to report service" 130 ); 131 132 let request_body = json!({ 133 "reasonType": input.reason_type, 134 "reason": input.reason, 135 "subject": input.subject 136 }); 137 138 let client = proxy_client(); 139 let result = client 140 .post(&target_url) 141 .header("Authorization", format!("Bearer {}", service_token)) 142 .header("Content-Type", "application/json") 143 .json(&request_body) 144 .send() 145 .await; 146 147 match result { 148 Ok(resp) => { 149 let status = resp.status(); 150 let headers = resp.headers().clone(); 151 152 let body = match resp.bytes().await { 153 Ok(b) => b, 154 Err(e) => { 155 error!("Error reading report service response: {:?}", e); 156 return (StatusCode::BAD_GATEWAY, "Error reading upstream response") 157 .into_response(); 158 } 159 }; 160 161 let mut response_builder = Response::builder().status(status); 162 163 if let Some(ct) = headers.get("content-type") { 164 response_builder = response_builder.header("content-type", ct); 165 } 166 167 match response_builder.body(axum::body::Body::from(body)) { 168 Ok(r) => r, 169 Err(e) => { 170 error!("Error building proxy response: {:?}", e); 171 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() 172 } 173 } 174 } 175 Err(e) => { 176 error!("Error sending report to service: {:?}", e); 177 if e.is_timeout() { 178 (StatusCode::GATEWAY_TIMEOUT, "Report service timeout").into_response() 179 } else { 180 (StatusCode::BAD_GATEWAY, "Report service error").into_response() 181 } 182 } 183 } 184} 185 186async fn create_report_locally( 187 state: &AppState, 188 did: &str, 189 is_takendown: bool, 190 input: CreateReportInput, 191) -> Response { 192 const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal"; 193 194 if is_takendown && input.reason_type != REASON_APPEAL { 195 return ApiError::InvalidRequest("Report not accepted from takendown account".into()) 196 .into_response(); 197 } 198 199 let valid_reason_types = [ 200 "com.atproto.moderation.defs#reasonSpam", 201 "com.atproto.moderation.defs#reasonViolation", 202 "com.atproto.moderation.defs#reasonMisleading", 203 "com.atproto.moderation.defs#reasonSexual", 204 "com.atproto.moderation.defs#reasonRude", 205 "com.atproto.moderation.defs#reasonOther", 206 REASON_APPEAL, 207 ]; 208 209 if !valid_reason_types.contains(&input.reason_type.as_str()) { 210 return ApiError::InvalidRequest("Invalid reasonType".into()).into_response(); 211 } 212 213 let created_at = chrono::Utc::now(); 214 let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64; 215 let subject_json = json!(input.subject); 216 217 let insert = sqlx::query!( 218 "INSERT INTO reports (id, reason_type, reason, subject_json, reported_by_did, created_at) VALUES ($1, $2, $3, $4, $5, $6)", 219 report_id, 220 input.reason_type, 221 input.reason, 222 subject_json, 223 did, 224 created_at 225 ) 226 .execute(&state.db) 227 .await; 228 229 if let Err(e) = insert { 230 error!("Failed to insert report: {:?}", e); 231 return ApiError::InternalError(None).into_response(); 232 } 233 234 info!( 235 report_id = %report_id, 236 reported_by = %did, 237 reason_type = %input.reason_type, 238 "Report created locally (no report service configured)" 239 ); 240 241 ( 242 StatusCode::OK, 243 Json(CreateReportOutput { 244 id: report_id, 245 reason_type: input.reason_type, 246 reason: input.reason, 247 subject: input.subject, 248 reported_by: did.to_string(), 249 created_at: created_at.to_rfc3339(), 250 }), 251 ) 252 .into_response() 253}