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