this repo has no description
1use crate::api::ApiError;
2use crate::api::proxy_client::{is_ssrf_safe, proxy_client};
3use crate::auth::extractor::BearerAuthAllowTakendown;
4use crate::state::AppState;
5use axum::{
6 Json,
7 extract::State,
8 http::StatusCode,
9 response::{IntoResponse, Response},
10};
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use tracing::{error, info};
14
15#[derive(Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct CreateReportInput {
18 pub reason_type: String,
19 pub reason: Option<String>,
20 pub subject: Value,
21}
22
23#[derive(Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct CreateReportOutput {
26 pub id: i64,
27 pub reason_type: String,
28 pub reason: Option<String>,
29 pub subject: Value,
30 pub reported_by: String,
31 pub created_at: String,
32}
33
34fn get_report_service_config() -> Option<(String, String)> {
35 let url = std::env::var("REPORT_SERVICE_URL").ok()?;
36 let did = std::env::var("REPORT_SERVICE_DID").ok()?;
37 if url.is_empty() || did.is_empty() {
38 return None;
39 }
40 Some((url, did))
41}
42
43pub async fn create_report(
44 State(state): State<AppState>,
45 auth: BearerAuthAllowTakendown,
46 Json(input): Json<CreateReportInput>,
47) -> Response {
48 let auth_user = auth.0;
49 let did = &auth_user.did;
50
51 if let Some((service_url, service_did)) = get_report_service_config() {
52 return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input)
53 .await;
54 }
55
56 create_report_locally(&state, did, auth_user.is_takendown(), input).await
57}
58
59async fn proxy_to_report_service(
60 state: &AppState,
61 auth_user: &crate::auth::AuthenticatedUser,
62 service_url: &str,
63 service_did: &str,
64 input: &CreateReportInput,
65) -> Response {
66 if let Err(e) = is_ssrf_safe(service_url) {
67 error!("Report service URL failed SSRF check: {:?}", e);
68 return ApiError::InternalError(Some("Invalid report service configuration".into()))
69 .into_response();
70 }
71
72 let key_bytes = match &auth_user.key_bytes {
73 Some(kb) => kb.clone(),
74 None => {
75 match sqlx::query_as::<_, (Vec<u8>, Option<i32>)>(
76 "SELECT k.key_bytes, k.encryption_version
77 FROM users u
78 JOIN user_keys k ON u.id = k.user_id
79 WHERE u.did = $1",
80 )
81 .bind(&auth_user.did)
82 .fetch_optional(&state.db)
83 .await
84 {
85 Ok(Some((key_bytes_enc, encryption_version))) => {
86 match crate::config::decrypt_key(&key_bytes_enc, encryption_version) {
87 Ok(key) => key,
88 Err(e) => {
89 error!(error = ?e, "Failed to decrypt user key for report service auth");
90 return ApiError::AuthenticationFailed(Some(
91 "Failed to get signing key".into(),
92 ))
93 .into_response();
94 }
95 }
96 }
97 Ok(None) => {
98 return ApiError::AuthenticationFailed(Some("User has no signing key".into()))
99 .into_response();
100 }
101 Err(e) => {
102 error!(error = ?e, "DB error fetching user key for report");
103 return ApiError::AuthenticationFailed(Some(
104 "Failed to get signing key".into(),
105 ))
106 .into_response();
107 }
108 }
109 }
110 };
111
112 let service_token = match crate::auth::create_service_token(
113 &auth_user.did,
114 service_did,
115 "com.atproto.moderation.createReport",
116 &key_bytes,
117 ) {
118 Ok(t) => t,
119 Err(e) => {
120 error!("Failed to create service token for report: {:?}", e);
121 return ApiError::InternalError(None).into_response();
122 }
123 };
124
125 let target_url = format!("{}/xrpc/com.atproto.moderation.createReport", service_url);
126 info!(
127 did = %auth_user.did,
128 service_did = %service_did,
129 "Proxying createReport to report service"
130 );
131
132 let request_body = json!({
133 "reasonType": input.reason_type,
134 "reason": input.reason,
135 "subject": input.subject
136 });
137
138 let client = proxy_client();
139 let result = client
140 .post(&target_url)
141 .header("Authorization", format!("Bearer {}", service_token))
142 .header("Content-Type", "application/json")
143 .json(&request_body)
144 .send()
145 .await;
146
147 match result {
148 Ok(resp) => {
149 let status = resp.status();
150 let headers = resp.headers().clone();
151
152 let body = match resp.bytes().await {
153 Ok(b) => b,
154 Err(e) => {
155 error!("Error reading report service response: {:?}", e);
156 return (StatusCode::BAD_GATEWAY, "Error reading upstream response")
157 .into_response();
158 }
159 };
160
161 let mut response_builder = Response::builder().status(status);
162
163 if let Some(ct) = headers.get("content-type") {
164 response_builder = response_builder.header("content-type", ct);
165 }
166
167 match response_builder.body(axum::body::Body::from(body)) {
168 Ok(r) => r,
169 Err(e) => {
170 error!("Error building proxy response: {:?}", e);
171 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
172 }
173 }
174 }
175 Err(e) => {
176 error!("Error sending report to service: {:?}", e);
177 if e.is_timeout() {
178 (StatusCode::GATEWAY_TIMEOUT, "Report service timeout").into_response()
179 } else {
180 (StatusCode::BAD_GATEWAY, "Report service error").into_response()
181 }
182 }
183 }
184}
185
186async fn create_report_locally(
187 state: &AppState,
188 did: &str,
189 is_takendown: bool,
190 input: CreateReportInput,
191) -> Response {
192 const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal";
193
194 if is_takendown && input.reason_type != REASON_APPEAL {
195 return ApiError::InvalidRequest("Report not accepted from takendown account".into())
196 .into_response();
197 }
198
199 let valid_reason_types = [
200 "com.atproto.moderation.defs#reasonSpam",
201 "com.atproto.moderation.defs#reasonViolation",
202 "com.atproto.moderation.defs#reasonMisleading",
203 "com.atproto.moderation.defs#reasonSexual",
204 "com.atproto.moderation.defs#reasonRude",
205 "com.atproto.moderation.defs#reasonOther",
206 REASON_APPEAL,
207 ];
208
209 if !valid_reason_types.contains(&input.reason_type.as_str()) {
210 return ApiError::InvalidRequest("Invalid reasonType".into()).into_response();
211 }
212
213 let created_at = chrono::Utc::now();
214 let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64;
215 let subject_json = json!(input.subject);
216
217 let insert = sqlx::query!(
218 "INSERT INTO reports (id, reason_type, reason, subject_json, reported_by_did, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
219 report_id,
220 input.reason_type,
221 input.reason,
222 subject_json,
223 did,
224 created_at
225 )
226 .execute(&state.db)
227 .await;
228
229 if let Err(e) = insert {
230 error!("Failed to insert report: {:?}", e);
231 return ApiError::InternalError(None).into_response();
232 }
233
234 info!(
235 report_id = %report_id,
236 reported_by = %did,
237 reason_type = %input.reason_type,
238 "Report created locally (no report service configured)"
239 );
240
241 (
242 StatusCode::OK,
243 Json(CreateReportOutput {
244 id: report_id,
245 reason_type: input.reason_type,
246 reason: input.reason,
247 subject: input.subject,
248 reported_by: did.to_string(),
249 created_at: created_at.to_rfc3339(),
250 }),
251 )
252 .into_response()
253}