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}