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}