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:
+66
@@ -0,0 +1,66 @@
|
||||
//! Router construction.
|
||||
//!
|
||||
//! §6 stage 0/1 body caps are applied here as per-route `DefaultBodyLimit`
|
||||
//! layers: Axum rejects on `Content-Length` before reading a body *and* caps the
|
||||
//! stream for chunked or mis-declared uploads, which is what makes a lying
|
||||
//! header and a chunked upload both safe. Per-route means the bundle endpoint
|
||||
//! gets its larger limit without widening the others (§6 stage 1).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::api::{exists, fetch, report, upload};
|
||||
use crate::state::AppState;
|
||||
use crate::validate::limits;
|
||||
|
||||
/// Small cap for endpoints that take a short JSON body. A read endpoint has no
|
||||
/// business accepting a large payload, and the batch `exists` form is bounded at
|
||||
/// 100 items.
|
||||
const SMALL_BODY_LIMIT: usize = 256 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let timeout = state.config.request_timeout;
|
||||
|
||||
let v1 = Router::new()
|
||||
// UR-1 — existence probes.
|
||||
.route("/manifests/exists", get(exists::exists).post(exists::exists_batch))
|
||||
// Reads.
|
||||
.route("/manifests/movie", get(fetch::get_movie))
|
||||
.route("/manifests/episode", get(fetch::get_episode))
|
||||
.route("/manifests/series/{series_tmdb_id}", get(fetch::get_series))
|
||||
.route("/manifests/{id}", get(fetch::get_by_id))
|
||||
.route("/manifests/{id}/status", get(fetch::get_status))
|
||||
.route("/manifests/{id}/report", post(report::post_report))
|
||||
// UR-2 — contribution.
|
||||
.route(
|
||||
"/manifests",
|
||||
post(upload::post_manifest).layer(DefaultBodyLimit::max(limits::BODY_LIMIT_MANIFEST)),
|
||||
)
|
||||
// UR-6 — whole-series contribution, with its own larger cap.
|
||||
.route(
|
||||
"/manifests/bundle",
|
||||
post(upload::post_bundle).layer(DefaultBodyLimit::max(limits::BODY_LIMIT_BUNDLE)),
|
||||
)
|
||||
// §5a — anonymous bearer capability, not an account.
|
||||
.route("/tokens", post(upload::post_token))
|
||||
.layer(DefaultBodyLimit::max(SMALL_BODY_LIMIT));
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(report::health))
|
||||
.route("/ready", get(report::ready))
|
||||
.nest("/api/v1", v1)
|
||||
// §8: a request timeout so a slow bundle query fails fast.
|
||||
.layer(TimeoutLayer::with_status_code(axum::http::StatusCode::REQUEST_TIMEOUT, timeout))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Convenience for tests and `main`.
|
||||
pub fn default_timeout() -> Duration {
|
||||
Duration::from_secs(30)
|
||||
}
|
||||
Reference in New Issue
Block a user