Implementerer automatisk opprydding av CAS-filer basert på dokumentert spec i docs/retninger/maskinrommet.md: - TTL per modalitet: lyd 30d, bilde 30d, video 14d, tekst aldri - Signaler som forlenger levetid: publishing-edge, siste tilgang (last_accessed_at), utranskribert lyd beholdes - Tre-trinns disk-nødventil: - >85%: slett generert innhold (TTS osv, kan regenereres) - >90%: aggressiv pruning med kraftig redusert TTL - >95%: kritisk — alt uten publishing-edge slettes - Periodisk bakgrunnsloop: hvert 6. time, oftere ved høy disk - Tilgangslogging: serving oppdaterer last_accessed_at (fire-and-forget) - Pruning-hendelser logges til resource_usage_log Ny modul: maskinrommet/src/pruning.rs Ny migrasjon: 010_pruning.sql (last_accessed_at kolonne + indeks) CasStore utvidet med delete(), disk_usage_bytes(), disk_usage_percent() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
80 lines
2.5 KiB
Rust
80 lines
2.5 KiB
Rust
//! 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<AppState>,
|
|
Path(hash): Path<String>,
|
|
) -> Result<Response, StatusCode> {
|
|
// 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
|
|
})?;
|
|
|
|
// Oppdater last_accessed_at asynkront (fire-and-forget for ytelse)
|
|
let db_clone = state.db.clone();
|
|
let hash_clone = hash.clone();
|
|
tokio::spawn(async move {
|
|
crate::pruning::touch_access(&db_clone, &hash_clone).await;
|
|
});
|
|
|
|
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())
|
|
}
|