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