Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
9.0 kB · 278 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279use axum::{ Json, extract::State, http::StatusCode, response::{IntoResponse, Response},};use serde::{Deserialize, Serialize};use serde_json::{Value, json};use std::sync::LazyLock;use tracing::{error, info, warn};use tranquil_pds::api::ApiError;use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};use tranquil_pds::auth::{AnyUser, Auth};use tranquil_pds::state::AppState;use tranquil_pds::types::{Did, Nsid};
static CREATE_REPORT_NSID: LazyLock<Nsid> = LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]pub enum ReportReasonType { #[serde(rename = "com.atproto.moderation.defs#reasonSpam")] Spam, #[serde(rename = "com.atproto.moderation.defs#reasonViolation")] Violation, #[serde(rename = "com.atproto.moderation.defs#reasonMisleading")] Misleading, #[serde(rename = "com.atproto.moderation.defs#reasonSexual")] Sexual, #[serde(rename = "com.atproto.moderation.defs#reasonRude")] Rude, #[serde(rename = "com.atproto.moderation.defs#reasonOther")] Other, #[serde(rename = "com.atproto.moderation.defs#reasonAppeal")] Appeal,}
impl ReportReasonType { pub fn as_str(self) -> &'static str { match self { Self::Spam => "com.atproto.moderation.defs#reasonSpam", Self::Violation => "com.atproto.moderation.defs#reasonViolation", Self::Misleading => "com.atproto.moderation.defs#reasonMisleading", Self::Sexual => "com.atproto.moderation.defs#reasonSexual", Self::Rude => "com.atproto.moderation.defs#reasonRude", Self::Other => "com.atproto.moderation.defs#reasonOther", Self::Appeal => "com.atproto.moderation.defs#reasonAppeal", } }}
#[derive(Deserialize)]#[serde(rename_all = "camelCase")]pub struct CreateReportInput { pub reason_type: ReportReasonType, pub reason: Option<String>, pub subject: Value,}
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct CreateReportOutput { pub id: i64, pub reason_type: ReportReasonType, pub reason: Option<String>, pub subject: Value, pub reported_by: String, pub created_at: String,}
struct ReportServiceConfig { url: String, did: Did,}
fn get_report_service_config() -> Option<ReportServiceConfig> { let cfg = tranquil_config::get(); let url = cfg.moderation.report_service_url.clone()?; let did_str = cfg.moderation.report_service_did.as_deref()?; if url.is_empty() || did_str.is_empty() { return None; } match did_str.parse::<Did>() { Ok(did) => Some(ReportServiceConfig { url, did }), Err(_) => { warn!( report_service_did = did_str, "invalid report_service_did, handling reports locally" ); None } }}
pub async fn create_report( State(state): State<AppState>, auth: Auth<AnyUser>, Json(input): Json<CreateReportInput>,) -> Response { let did = &auth.did;
if let Some(config) = get_report_service_config() { return proxy_to_report_service(&state, &auth, &config.url, &config.did, &input).await; }
create_report_locally(&state, did, auth.status.is_takendown(), input).await}
async fn proxy_to_report_service( state: &AppState, auth_user: &tranquil_pds::auth::AuthenticatedUser, service_url: &str, service_did: &Did, input: &CreateReportInput,) -> Response { if let Err(e) = is_ssrf_safe(service_url).await { error!("Report service URL failed SSRF check: {:?}", e); return ApiError::InternalError(Some("Invalid report service configuration".into())) .into_response(); }
let key_bytes = match &auth_user.key_bytes { Some(kb) => kb.clone(), None => match state.repos.user.get_with_key_by_did(&auth_user.did).await { Ok(Some(user_with_key)) => { match tranquil_pds::config::decrypt_key( &user_with_key.key_bytes, user_with_key.encryption_version, ) { Ok(key) => key, Err(e) => { error!(error = ?e, "Failed to decrypt user key for report service auth"); return ApiError::AuthenticationFailed(Some( "Failed to get signing key".into(), )) .into_response(); } } } Ok(None) => { return ApiError::AuthenticationFailed(Some("User has no signing key".into())) .into_response(); } Err(e) => { error!(error = ?e, "DB error fetching user key for report"); return ApiError::AuthenticationFailed(Some("Failed to get signing key".into())) .into_response(); } }, };
let service_token = match tranquil_pds::auth::create_service_token( &auth_user.did, service_did, Some(&CREATE_REPORT_NSID), &key_bytes, ) { Ok(t) => t, Err(e) => { error!("Failed to create service token for report: {:?}", e); return ApiError::InternalError(None).into_response(); } };
let target_url = format!("{}/xrpc/com.atproto.moderation.createReport", service_url); info!( did = %auth_user.did, service_did = %service_did, "Proxying createReport to report service" );
let request_body = json!({ "reasonType": input.reason_type, "reason": input.reason, "subject": input.subject });
let client = proxy_client(); let result = client .post(&target_url) .header("Authorization", format!("Bearer {}", service_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await;
match result { Ok(resp) => { let status = resp.status(); let headers = resp.headers().clone();
let body = match resp.bytes().await { Ok(b) => b, Err(e) => { error!("Error reading report service response: {:?}", e); return (StatusCode::BAD_GATEWAY, "Error reading upstream response") .into_response(); } };
let mut response_builder = Response::builder().status(status);
if let Some(ct) = headers.get("content-type") { response_builder = response_builder.header("content-type", ct); }
match response_builder.body(axum::body::Body::from(body)) { Ok(r) => r, Err(e) => { error!("Error building proxy response: {:?}", e); (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() } } } Err(e) => { error!("Error sending report to service: {:?}", e); if e.is_timeout() { (StatusCode::GATEWAY_TIMEOUT, "Report service timeout").into_response() } else { (StatusCode::BAD_GATEWAY, "Report service error").into_response() } } }}
async fn create_report_locally( state: &AppState, did: &tranquil_pds::types::Did, is_takendown: bool, input: CreateReportInput,) -> Response { if is_takendown && input.reason_type != ReportReasonType::Appeal { return ApiError::InvalidRequest("Report not accepted from takendown account".into()) .into_response(); }
let created_at = chrono::Utc::now(); let report_id = i64::try_from(uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) .expect("masked to 63 bits, always fits i64"); let subject_json = json!(input.subject);
if let Err(e) = state .repos .infra .insert_report( report_id, input.reason_type.as_str(), input.reason.as_deref(), subject_json, did, created_at, ) .await { error!("Failed to insert report: {:?}", e); return ApiError::InternalError(None).into_response(); }
info!( report_id = %report_id, reported_by = %did, reason_type = input.reason_type.as_str(), "Report created locally (no report service configured)" );
( StatusCode::OK, Json(CreateReportOutput { id: report_id, reason_type: input.reason_type, reason: input.reason, subject: input.subject, reported_by: did.to_string(), created_at: created_at.to_rfc3339(), }), ) .into_response()}