Files
dtourolleandClaude Opus 5 a1e789a6fe
CI / fmt, clippy, test (push) Failing after 1m21s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 24s
Traceability: vendor the shared gate, annotate the source
Adds jray-project as a submodule at scripts/vendor/jray-project, so this repo
runs the same extractor as every other component rather than its own copy, and
gains the system spec that defines the PR/SR requirements its register traces
up to.

scripts/traceability-gate.sh is a thin wrapper holding only what is specific to
this repo: UR/DR prefixes, .rs sources, and REPO_ROOT — which the shared gate
cannot infer once vendored, since its default resolves to the submodule itself.
Each override fails silently in a way that looks like "no work done" rather
than "misconfigured", so the wrapper documents why each is needed.

Annotates 35 units with TRACES tags, on the code that decides rather than every
helper it calls. Coverage is 23/32 (71.9%) with no orphan tags. The nine
untraced are genuinely unimplemented: UR-007 is plugin-side, UR-008 is
federation, and UR-015..018 are the pending SR-003 schema bump.

The gate caught a real error in the first pass: several tags separated IDs of
different types with commas. A comma joins IDs within one type; a pipe
separates types. Fixed, and the diagnostics are now clean.

MIN_COVERAGE stays 0 deliberately. The gate still fails on orphan tags, a >100%
ratio, a register parsing to nothing, or an empty source scan — raise the
threshold as a ratchet once the remaining work lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:27:16 +02:00

143 lines
4.4 KiB
Rust

//! `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<String>,
}
/// §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<AppState>,
peer: crate::state::PeerIp,
headers: HeaderMap,
Path(manifest_id): Path<String>,
super::json::Json(req): super::json::Json<ReportRequest>,
) -> ApiResult<Response> {
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::<String>()),
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<Health> {
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<AppState>) -> ApiResult<Json<Readiness>> {
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(),
}))
}