//! `POST /manifests/{id}/report` (§4), and `GET /health`. //! //! Reports are a moderation lever and cheap to abuse, hence the tight §5 limit. //! A report never changes `status` by itself: §5a keeps delisting an operator //! action, because automatic delisting on report would hand any client a remote //! delete primitive. use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::{Deserialize, Serialize}; use crate::db::repo; use crate::error::{ApiError, ApiResult}; use crate::ratelimit::Surface; use crate::state::{with_quota_headers, AppState}; use crate::worker::now_iso; /// §4: `{ "reason": "misaligned" | "wrong_actors" | "spam", "note": "..." }`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ReportReason { Misaligned, WrongActors, Spam, } impl ReportReason { fn as_str(self) -> &'static str { match self { ReportReason::Misaligned => "misaligned", ReportReason::WrongActors => "wrong_actors", ReportReason::Spam => "spam", } } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReportRequest { pub reason: ReportReason, #[serde(default)] pub note: Option, } /// §5a: `note` is free text from an anonymous caller, so it is capped hard. It is /// never served back to clients — only the operator reads it. const MAX_NOTE_CHARS: usize = 500; #[derive(Debug, Serialize)] pub struct ReportAccepted { pub report_id: String, } /// TRACES: UR-005 | SR-004 pub async fn post_report( State(state): State, peer: crate::state::PeerIp, headers: HeaderMap, Path(manifest_id): Path, super::json::Json(req): super::json::Json, ) -> ApiResult { let ip = state.client_ip(&headers, peer.0); let quota = state.check_limit(&ip, Surface::Report)?; let note = match req.note { Some(n) if n.chars().count() > MAX_NOTE_CHARS => { return Err(ApiError::BadRequest(format!( "note: longer than {MAX_NOTE_CHARS} characters" ))) } // Strip control characters; the note is operator-facing text, not markup. Some(n) => Some(n.chars().filter(|c| !c.is_control()).collect::()), None => None, }; let ip_hash = crate::auth::hash_ip(&ip, &state.config.server_id); let reason = req.reason.as_str(); let now = now_iso(); let id_for_check = manifest_id.clone(); let exists = state .db .read(move |c| Ok(repo::manifest_by_id(c, &id_for_check)?.is_some())) .await .map_err(ApiError::Internal)?; if !exists { return Err(ApiError::NotFound); } let report_id = state .db .write(move |tx| { repo::insert_report(tx, &manifest_id, reason, note.as_deref(), &ip_hash, &now) }) .await .map_err(ApiError::Internal)?; Ok(with_quota_headers(Json(ReportAccepted { report_id }).into_response(), quota)) } #[derive(Debug, Serialize)] pub struct Health { pub status: &'static str, pub version: &'static str, } /// `GET /health` — liveness, unauthenticated and unlimited (§4, §5). pub async fn health() -> Json { Json(Health { status: "ok", version: env!("CARGO_PKG_VERSION") }) } #[derive(Debug, Serialize)] pub struct Readiness { pub status: &'static str, pub database: &'static str, /// §8: TMDB is a hard dependency for UR-3. If it is unconfigured, uploads /// accumulate in `pending` rather than being listed unverified — worth /// surfacing rather than failing silently. pub tmdb_configured: bool, } /// Readiness check verifying the database opens and migrations are current (§8). pub async fn ready(State(state): State) -> ApiResult> { let ok = state .db .read(|conn| { // Any query against a schema table proves both that the file opens // and that migrations have been applied. let n: i64 = conn.query_row("SELECT COUNT(*) FROM manifests", [], |r| r.get(0))?; Ok(n >= 0) }) .await .map_err(ApiError::Internal)?; Ok(Json(Readiness { status: if ok { "ready" } else { "degraded" }, database: "ok", tmdb_configured: state.tmdb.is_configured(), })) }