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 = match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await 55 { 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( 64 &state, 65 &auth_user, 66 &service_url, 67 &service_did, 68 &input, 69 ) 70 .await; 71 } 72 73 create_report_locally(&state, did, auth_user.is_takendown, input).await 74} 75 76async fn proxy_to_report_service( 77 state: &AppState, 78 auth_user: &crate::auth::AuthenticatedUser, 79 service_url: &str, 80 service_did: &str, 81 input: &CreateReportInput, 82) -> Response { 83 if let Err(e) = is_ssrf_safe(service_url) { 84 error!("Report service URL failed SSRF check: {:?}", e); 85 return ( 86 StatusCode::INTERNAL_SERVER_ERROR, 87 Json(json!({"error": "InternalError", "message": "Invalid report service configuration"})), 88 ) 89 .into_response(); 90 } 91 92 let key_bytes = match &auth_user.key_bytes { 93 Some(kb) => kb.clone(), 94 None => { 95 match sqlx::query_as::<_, (Vec<u8>, Option<i32>)>( 96 "SELECT k.key_bytes, k.encryption_version 97 FROM users u 98 JOIN user_keys k ON u.id = k.user_id 99 WHERE u.did = $1", 100 ) 101 .bind(&auth_user.did) 102 .fetch_optional(&state.db) 103 .await 104 { 105 Ok(Some((key_bytes_enc, encryption_version))) => { 106 match crate::config::decrypt_key(&key_bytes_enc, encryption_version) { 107 Ok(key) => key, 108 Err(e) => { 109 error!(error = ?e, "Failed to decrypt user key for report service auth"); 110 return ApiError::AuthenticationFailedMsg( 111 "Failed to get signing key".into(), 112 ) 113 .into_response(); 114 } 115 } 116 } 117 Ok(None) => { 118 return ApiError::AuthenticationFailedMsg("User has no signing key".into()) 119 .into_response(); 120 } 121 Err(e) => { 122 error!(error = ?e, "DB error fetching user key for report"); 123 return ApiError::AuthenticationFailedMsg("Failed to get signing key".into()) 124 .into_response(); 125 } 126 } 127 } 128 }; 129 130 let service_token = match crate::auth::create_service_token( 131 &auth_user.did, 132 service_did, 133 "com.atproto.moderation.createReport", 134 &key_bytes, 135 ) { 136 Ok(t) => t, 137 Err(e) => { 138 error!("Failed to create service token for report: {:?}", e); 139 return ( 140 StatusCode::INTERNAL_SERVER_ERROR, 141 Json(json!({"error": "InternalError"})), 142 ) 143 .into_response(); 144 } 145 }; 146 147 let target_url = format!("{}/xrpc/com.atproto.moderation.createReport", service_url); 148 info!( 149 did = %auth_user.did, 150 service_did = %service_did, 151 "Proxying createReport to report service" 152 ); 153 154 let request_body = json!({ 155 "reasonType": input.reason_type, 156 "reason": input.reason, 157 "subject": input.subject 158 }); 159 160 let client = proxy_client(); 161 let result = client 162 .post(&target_url) 163 .header("Authorization", format!("Bearer {}", service_token)) 164 .header("Content-Type", "application/json") 165 .json(&request_body) 166 .send() 167 .await; 168 169 match result { 170 Ok(resp) => { 171 let status = resp.status(); 172 let headers = resp.headers().clone(); 173 174 let body = match resp.bytes().await { 175 Ok(b) => b, 176 Err(e) => { 177 error!("Error reading report service response: {:?}", e); 178 return (StatusCode::BAD_GATEWAY, "Error reading upstream response") 179 .into_response(); 180 } 181 }; 182 183 let mut response_builder = Response::builder().status(status); 184 185 if let Some(ct) = headers.get("content-type") { 186 response_builder = response_builder.header("content-type", ct); 187 } 188 189 match response_builder.body(axum::body::Body::from(body)) { 190 Ok(r) => r, 191 Err(e) => { 192 error!("Error building proxy response: {:?}", e); 193 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() 194 } 195 } 196 } 197 Err(e) => { 198 error!("Error sending report to service: {:?}", e); 199 if e.is_timeout() { 200 (StatusCode::GATEWAY_TIMEOUT, "Report service timeout").into_response() 201 } else { 202 (StatusCode::BAD_GATEWAY, "Report service error").into_response() 203 } 204 } 205 } 206} 207 208async fn create_report_locally( 209 state: &AppState, 210 did: &str, 211 is_takendown: bool, 212 input: CreateReportInput, 213) -> Response { 214 const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal"; 215 216 if is_takendown && input.reason_type != REASON_APPEAL { 217 return ( 218 StatusCode::BAD_REQUEST, 219 Json(json!({"error": "InvalidRequest", "message": "Report not accepted from takendown account"})), 220 ) 221 .into_response(); 222 } 223 224 let valid_reason_types = [ 225 "com.atproto.moderation.defs#reasonSpam", 226 "com.atproto.moderation.defs#reasonViolation", 227 "com.atproto.moderation.defs#reasonMisleading", 228 "com.atproto.moderation.defs#reasonSexual", 229 "com.atproto.moderation.defs#reasonRude", 230 "com.atproto.moderation.defs#reasonOther", 231 REASON_APPEAL, 232 ]; 233 234 if !valid_reason_types.contains(&input.reason_type.as_str()) { 235 return ( 236 StatusCode::BAD_REQUEST, 237 Json(json!({"error": "InvalidRequest", "message": "Invalid reasonType"})), 238 ) 239 .into_response(); 240 } 241 242 let created_at = chrono::Utc::now(); 243 let report_id = created_at.timestamp_millis(); 244 let subject_json = json!(input.subject); 245 246 let insert = sqlx::query!( 247 "INSERT INTO reports (id, reason_type, reason, subject_json, reported_by_did, created_at) VALUES ($1, $2, $3, $4, $5, $6)", 248 report_id, 249 input.reason_type, 250 input.reason, 251 subject_json, 252 did, 253 created_at 254 ) 255 .execute(&state.db) 256 .await; 257 258 if let Err(e) = insert { 259 error!("Failed to insert report: {:?}", e); 260 return ( 261 StatusCode::INTERNAL_SERVER_ERROR, 262 Json(json!({"error": "InternalError"})), 263 ) 264 .into_response(); 265 } 266 267 info!( 268 report_id = %report_id, 269 reported_by = %did, 270 reason_type = %input.reason_type, 271 "Report created locally (no report service configured)" 272 ); 273 274 ( 275 StatusCode::OK, 276 Json(CreateReportOutput { 277 id: report_id, 278 reason_type: input.reason_type, 279 reason: input.reason, 280 subject: input.subject, 281 reported_by: did.to_string(), 282 created_at: created_at.to_rfc3339(), 283 }), 284 ) 285 .into_response() 286}