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}