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