diff --git a/maskinrommet/Cargo.lock b/maskinrommet/Cargo.lock index 2ba2eb8..d333921 100644 --- a/maskinrommet/Cargo.lock +++ b/maskinrommet/Cargo.lock @@ -971,6 +971,7 @@ dependencies = [ "sha2", "sqlx", "tokio", + "tokio-util", "tower-http", "tracing", "tracing-subscriber", @@ -2133,6 +2134,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/maskinrommet/Cargo.toml b/maskinrommet/Cargo.toml index 016b356..921646d 100644 --- a/maskinrommet/Cargo.toml +++ b/maskinrommet/Cargo.toml @@ -18,3 +18,4 @@ jsonwebtoken = "9" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } sha2 = "0.10" hex = "0.4" +tokio-util = { version = "0.7", features = ["io"] } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 64ef110..c996fba 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -2,6 +2,7 @@ mod auth; pub mod cas; mod intentions; mod queries; +mod serving; mod stdb; mod warmup; @@ -131,6 +132,7 @@ async fn main() { .route("/intentions/delete_node", post(intentions::delete_node)) .route("/intentions/create_communication", post(intentions::create_communication)) .route("/intentions/upload_media", post(intentions::upload_media)) + .route("/cas/{hash}", get(serving::get_cas_file)) .route("/query/nodes", get(queries::query_nodes)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/maskinrommet/src/serving.rs b/maskinrommet/src/serving.rs new file mode 100644 index 0000000..350103b --- /dev/null +++ b/maskinrommet/src/serving.rs @@ -0,0 +1,73 @@ +//! CAS-serving: GET /cas/{hash} → stream fil fra disk. +//! +//! Slår opp MIME-type fra media-nodens metadata i PG. +//! Streamer filen med riktig Content-Type og Content-Length. +//! Hashen er i praksis en capability token — ingen auth kreves. + +use axum::{ + body::Body, + extract::{Path, State}, + http::{header, StatusCode}, + response::Response, +}; +use tokio::fs::File; +use tokio_util::io::ReaderStream; + +use crate::AppState; + +/// Valider at hash er 64 hex-tegn (SHA-256). +fn is_valid_hash(hash: &str) -> bool { + hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// GET /cas/{hash} — stream CAS-fil med riktig Content-Type. +pub async fn get_cas_file( + State(state): State, + Path(hash): Path, +) -> Result { + // Valider hash-format for å hindre path traversal + if !is_valid_hash(&hash) { + return Err(StatusCode::BAD_REQUEST); + } + + // Sjekk at filen finnes i CAS + let path = state.cas.path_for(&hash); + if !path.exists() { + return Err(StatusCode::NOT_FOUND); + } + + // Slå opp MIME-type fra media-nodens metadata i PG + let mime: String = sqlx::query_scalar( + "SELECT metadata->>'mime' FROM nodes WHERE metadata->>'cas_hash' = $1 LIMIT 1", + ) + .bind(&hash) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(hash = %hash, error = %e, "Feil ved MIME-oppslag i PG"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .unwrap_or_else(|| "application/octet-stream".to_string()); + + // Hent filstørrelse + let metadata = tokio::fs::metadata(&path).await.map_err(|e| { + tracing::error!(hash = %hash, error = %e, "Kunne ikke lese filmetadata"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Åpne fil og stream + let file = File::open(&path).await.map_err(|e| { + tracing::error!(hash = %hash, error = %e, "Kunne ikke åpne CAS-fil"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, mime) + .header(header::CONTENT_LENGTH, metadata.len()) + .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .body(body) + .unwrap()) +} diff --git a/tasks.md b/tasks.md index f3490d7..21c0eae 100644 --- a/tasks.md +++ b/tasks.md @@ -88,8 +88,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 6.1 CAS-lagring: filsystem med content-addressable hashing (SHA-256). Katalogstruktur med hash-prefix. Deduplisering. - [x] 6.2 Upload-endepunkt: `POST /intentions/upload_media` → hash fil, lagre i CAS, opprett media-node med `has_media`-edge. -- [~] 6.3 Serving: `GET /cas/{hash}` → stream fil fra disk. Caddy kan serve direkte for ytelse. - > Påbegynt: 2026-03-17T16:50 +- [x] 6.3 Serving: `GET /cas/{hash}` → stream fil fra disk. Caddy kan serve direkte for ytelse. - [ ] 6.4 Bilder i TipTap: drag-and-drop/paste → upload → CAS-node → inline i `metadata.document` via `node_id`. ## Fase 7: Lyd-pipeline @@ -136,9 +135,13 @@ Uavhengige faser kan fortsatt plukkes. - [ ] 14.1 HTML-rendering: maskinrommet rendrer `metadata.document` til HTML ved publisering, lagrer i CAS. Noden får `metadata.rendered.html_hash`. Ref: `docs/concepts/publisering.md`. - [ ] 14.2 Caddy-ruting for synops.no/pub: statisk serving av CAS-rendret HTML. Rute `synops.no/pub/{slug}/{id}` → oppslag i maskinrommet → CAS-fil. SEO-metadata (OG-tags, canonical). -- [ ] 14.3 Publiseringsflyt i frontend: publiseringsknapp på noder i samlinger med `publishing`-trait. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge. +- [ ] 14.3 Publiseringsflyt i frontend (personlig): publiseringsknapp på noder i samlinger med `publishing`-trait der `require_approval: false`. Forhåndsvisning, slug-editor, bekreftelse. Avpublisering ved fjerning av edge. - [ ] 14.4 RSS/Atom-feed: samling med `rss`-trait genererer feed automatisk ved publisering/avpublisering. `synops.no/pub/{slug}/feed.xml`. - [ ] 14.5 Custom domains: bruker registrerer domene i `publishing`-trait. Maskinrommet validerer DNS, Caddy on-demand TLS med validerings-callback. Re-rendring med riktig canonical URL. +- [ ] 14.6 Redaksjonell innsending: `submitted_to`-edge med status-metadata (`pending`, `in_review`, `revision_requested`, `rejected`, `approved`). Maskinrommet validerer at kun roller i `submission_roles` kan opprette `submitted_to`, og kun owner/admin kan endre status eller opprette `belongs_to`. Ref: `docs/concepts/publisering.md` § "Innsending". +- [ ] 14.7 Redaktørens arbeidsflate: frontend-visning av noder med `submitted_to`-edge til samling, gruppert på status. Kanban-stil drag-and-drop for statusendring. Siste kolonne ("Planlagt") setter `publish_at` i edge-metadata. +- [ ] 14.8 Planlagt publisering: maskinrommet sjekker periodisk (cron/intervall) for `belongs_to`-edges med `publish_at` i fortiden som ikke er rendret. Ved treff: render HTML → CAS → oppdater RSS. +- [ ] 14.9 Redaksjonell samtale: ved innsending kan redaktør opprette kommunikasjonsnode knyttet til artikkel + forfatter for diskusjon/feedback utover kort notat i edge-metadata. ## Fase 15: Adminpanel