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