Initial implementation: core vertical slice
Implements the core of SPEC.md — the manifest exchange, less audio-tier matching (§3) and federation (§9a), both of which the spec sequences as later work. - §2 Jmanifest format and series bundles - §3 cut matching: exact / runtime / loose tiers - §4 API, less POST /manifests/search - §5 rate limiting; §5a trust model, anonymous bearer tokens - §6 upload validation, all four stages - §7 relational storage, no JSON blob on the write path - §8 Rust + Axum + SQLite, single serialized writer, in-process job queue - §9a content addressing, computed on upload Reconciled against the system spec: - anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows track extent, so a track survives its own gaps and there is nothing to anneal. Its successor extinction_sec and the new gallery_scope are accepted and stored; scope enters the §7 ranking. A manifest still carrying anneal_sec is a hard 400, not silently ignored — it came from a pipeline whose window semantics differ from what this server assumes. - Audio signature: media under 120 s now emits no signature at all, matching scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened window under 150 s, which was the weaker rule — a caller-varying length is the property SR-004 forbids. - UR IDs regularised to UR-nnn; docs/requirements.md registers 32 requirements, each tracing to an SR-nnn or PR-nnn. 189 tests: unit, end-to-end through the real router, and an injection suite covering SQL, JSON, header and Unicode payloads. Writing that suite found two real gaps, both fixed here: compatibility homoglyphs passed the §5a character class, and a one-frame audio signature was accepted on a feature-length item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
//! `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,
|
||||
}
|
||||
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user