//! Streams blobs from the `Storage` trait. Same endpoint serves manga //! covers and chapter pages; the key embedded in the URL is whatever the //! writer stored. //! //! The handler uses `Storage::get_stream` so a multi-MB page is piped to //! the client a chunk at a time instead of buffered server-side. //! //! **Auth model — capability URLs by design.** This endpoint is //! deliberately unauthenticated: reads stay public per the project //! brief, and per-page authorisation would require either a per-request //! ownership lookup (covers + pages are scoped by manga, not user) or a //! signed-URL scheme. Mangalord instead relies on the keys being //! unguessable — `mangas/{uuid}/...` and //! `mangas/{uuid}/chapters/{uuid}/...` — so a leaked URL leaks at most //! the one referenced file. A future feat/private-libraries branch //! would gate this endpoint behind a `Storage::owner_of(key)` check; //! the seam is intentional. use axum::body::Body; use axum::extract::{Path, State}; use axum::http::{header, HeaderName}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; use crate::app::AppState; use crate::error::AppResult; use crate::storage::StorageError; pub fn routes() -> Router { Router::new().route("/files/*key", get(serve)) } async fn serve(State(state): State, Path(key): Path) -> AppResult { let file = match state.storage.get_stream(&key).await { Ok(f) => f, Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound), Err(e) => return Err(e.into()), }; let ct = content_type_for(&key); // `nosniff` makes the contract explicit: the browser must trust the // Content-Type we declared (and that the magic-byte sniff at upload // time produced) instead of trying to detect HTML/JS in the body. // Belt-and-braces vs. polyglot files that survive the upload sniff. let headers = [ (header::CONTENT_TYPE, ct.to_string()), (header::CONTENT_LENGTH, file.size_bytes.to_string()), ( HeaderName::from_static("x-content-type-options"), "nosniff".to_string(), ), ]; Ok((headers, Body::from_stream(file.stream)).into_response()) } fn content_type_for(key: &str) -> &'static str { let ext = key.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); match ext.as_str() { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "webp" => "image/webp", "gif" => "image/gif", "avif" => "image/avif", _ => "application/octet-stream", } }