//! axum server: rating endpoints, the OPDS catalog, downloads (spec §3.9, §3.12). //! //! Rating links must work from an e-reader's built-in browser, so every rating //! endpoint is a `GET` and the response is a tiny e-ink-sized HTML page. //! //! Routes (§3.12): //! | route | behaviour | //! |---|---| //! | `GET /r/{date}/{article_id}/{vote}?t=` | verify HMAC and append a rating event | //! | `GET /opds/daily.xml` (also `/opds`, `/opds/`) | OPDS 1.2 acquisition feed over `publish.epub_dir` | //! | `GET /files/epub/{name}` | EPUB download — what the feed's acquisition links point at | //! | `GET /files/xtc/{name}` | XTC artifact download, unlisted (no path traversal) | //! | `GET /healthz` | liveness | //! | `GET /issues.json` | the last 30 run reports, newest first | //! //! `/opds/*` and `/files/*` sit behind optional Basic auth (`server.basic_auth_*`). //! //! The EPUB article footer (§3.10) mints its three verdict links with the very same //! [`rating_url`] this module verifies with — both re-export [`crate::auth`], //! which pins the shared test vector (`secret = "test-secret"`, `2026-08-15`, //! article `42`, `up` → `3b314cf7e6d8f50f`). An issue generated while //! `server.hmac_secret` is unset carries links this server rejects with 403. use std::path::{Path as FsPath, PathBuf}; use std::sync::{Arc, RwLock}; use axum::Router; use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::middleware::{from_fn, from_fn_with_state}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum_login::AuthManagerLayerBuilder; use axum_login::tower_sessions::{Expiry, SessionManagerLayer}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use jiff::Timestamp; use jiff::civil::Date; use serde::Deserialize; use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::trace::TraceLayer; use crate::config::Config; use crate::db::Db; use crate::types::{ArticleId, RatingEvent, Vote}; /// Characters of the hex HMAC kept in rating links (§3.9). pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN; /// How many issues `GET /issues.json` returns (§3.12). pub const ISSUES_JSON_LIMIT: i64 = 30; /// Basic auth realm advertised for the OPDS routes (§3.11). pub const AUTH_REALM: &str = "The Daily EPUB"; /// Content type of an OPDS 1.2 acquisition feed (§3.11). pub const OPDS_CONTENT_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=acquisition"; #[derive(Debug, thiserror::Error)] pub enum ServerError { #[error("server.hmac_secret is not configured (set DAILY_EPUB_SERVER__HMAC_SECRET)")] MissingSecret, #[error("could not bind {addr}: {source}")] Bind { addr: String, #[source] source: std::io::Error, }, #[error("io error: {0}")] Io(#[from] std::io::Error), } /// Shared axum state. #[derive(Clone)] pub struct AppState { pub db: Db, pub config: Arc>>, pub config_path: Option, pub web: Arc, } impl std::fmt::Debug for AppState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AppState") .field("db", &self.db) .field("config_path", &self.config_path) .field("web", &self.web) .finish_non_exhaustive() } } impl AppState { pub fn new(db: Db, config: Config, config_path: Option) -> Self { Self { db, config: Arc::new(RwLock::new(Arc::new(config))), config_path, web: Arc::new(crate::web::WebState { jobs: Arc::new(crate::web::DisabledRunner), started_at: Timestamp::now(), config_mtime: std::sync::Mutex::new(None), }), } } pub fn config(&self) -> Arc { match self.config.read() { Ok(config) => Arc::clone(&config), Err(poisoned) => Arc::clone(&poisoned.into_inner()), } } } // --------------------------------------------------------------------------- // Rating tokens (§3.9) // --------------------------------------------------------------------------- // The formula lives in [`crate::auth`] so the EPUB writer and this verifier can // never drift apart; these re-exports keep the historical call sites intact. pub use crate::auth::{constant_time_eq, rating_token, rating_url, verify_token}; // --------------------------------------------------------------------------- // Router (§3.12) // --------------------------------------------------------------------------- /// Build the router: `/r/{date}/{article_id}/{vote}`, `/opds/daily.xml`, /// `/files/epub/{name}`, `/files/xtc/{name}`, `/healthz`, `/issues.json`, with /// `tower-http` tracing (§3.12). pub fn router(state: AppState) -> Router { let config = state.config(); let store = crate::web::session::SqliteSessionStore::new(state.db.pool().clone()); let session_layer = SessionManagerLayer::new(store) .with_name("daily_session") .with_http_only(true) .with_same_site(axum_login::tower_sessions::cookie::SameSite::Lax) .with_secure(config.server.public_url.starts_with("https://")) .with_expiry(Expiry::OnInactivity(time::Duration::days(i64::from( config.server.session_days, )))); let auth_layer = AuthManagerLayerBuilder::new( crate::web::session::Backend::new(state.db.clone()), session_layer, ) .build(); Router::new() .route("/r/{date}/{article_id}/{vote}", get(handle_rating)) .route(crate::publish::OPDS_PATH, get(handle_opds)) // OPDS browsers are typed into by hand on a 6" e-ink keyboard: serve the // same feed from the catalog root so a URL without the filename works. .route("/opds", get(handle_opds)) .route("/opds/", get(handle_opds)) .route("/files/epub/{name}", get(handle_epub_file)) .route("/files/xtc/{name}", get(handle_xtc_file)) .route("/healthz", get(handle_healthz)) .route("/issues.json", get(handle_issues_json)) .merge(crate::web::router(&config)) .layer(PropagateRequestIdLayer::x_request_id()) .layer(TraceLayer::new_for_http()) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) .layer(auth_layer) .layer(from_fn_with_state( state.clone(), crate::web::session::require_same_origin, )) .layer(from_fn(crate::web::security_headers)) .with_state(state) } /// `daily-epub serve` — bind, serve, graceful shutdown on SIGTERM (§3.12). pub async fn serve( config: Config, config_path: Option, db: Db, ) -> Result<(), ServerError> { if config.server.hmac_secret.is_none() { // Not fatal for the OPDS routes, but every rating link would 500. tracing::warn!("server.hmac_secret is unset — rating links will be rejected"); } let addr = config.server.bind.clone(); let listener = tokio::net::TcpListener::bind(&addr) .await .map_err(|source| ServerError::Bind { addr: addr.clone(), source, })?; let local = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr); tracing::info!(bind = %local, public_url = %config.server.public_url, "serving"); let store = crate::web::session::SqliteSessionStore::new(db.pool().clone()); if let Err(error) = store.delete_expired().await { tracing::warn!(%error, "could not delete expired sessions at startup"); } tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); loop { interval.tick().await; if let Err(error) = store.delete_expired().await { tracing::warn!(%error, "could not delete expired sessions"); } } }); let app = router(AppState::new(db, config, config_path)); axum::serve( listener, app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(shutdown_signal()) .await?; tracing::info!("server stopped"); Ok(()) } /// Resolve on SIGTERM (systemd stop) or ctrl-c (§3.12, §3.15). async fn shutdown_signal() { let ctrl_c = async { if let Err(e) = tokio::signal::ctrl_c().await { tracing::error!(error = %e, "failed to install the ctrl-c handler"); std::future::pending::<()>().await; } }; let terminate = async { match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { Ok(mut sig) => { sig.recv().await; } Err(e) => { tracing::error!(error = %e, "failed to install the SIGTERM handler"); std::future::pending::<()>().await; } } }; tokio::select! { _ = ctrl_c => tracing::info!("ctrl-c received, shutting down"), _ = terminate => tracing::info!("SIGTERM received, shutting down"), } } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct TokenQuery { #[serde(default)] t: String, } async fn handle_healthz() -> Response { ( StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], "ok", ) .into_response() } /// `GET /issues.json` — the last [`ISSUES_JSON_LIMIT`] run reports, newest first (§3.12). async fn handle_issues_json(State(state): State) -> Response { let rows = match state.db.recent_reports(ISSUES_JSON_LIMIT).await { Ok(rows) => rows, Err(e) => { tracing::error!(error = %e, "issues.json query failed"); return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(); } }; let issues: Vec = rows .into_iter() .map(|(date, report)| { let report = report .as_deref() .and_then(|r| serde_json::from_str::(r).ok()) .unwrap_or(serde_json::Value::Null); serde_json::json!({ "date": date.to_string(), "report": report }) }) .collect(); match serde_json::to_string_pretty(&issues) { Ok(body) => ( StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], body, ) .into_response(), Err(e) => { tracing::error!(error = %e, "serializing issues.json failed"); (StatusCode::INTERNAL_SERVER_ERROR, "serialization error").into_response() } } } /// `GET /r/{date}/{article_id}/{vote}?t=TOKEN` (§3.9). async fn handle_rating( State(state): State, Path((date, article_id, vote)): Path<(String, String, String)>, Query(query): Query, ) -> Response { let Ok(date) = date.parse::() else { tracing::warn!(%date, "rating link with a malformed date"); return page(StatusCode::BAD_REQUEST, "Bad link — invalid date.", None); }; let Ok(article_id) = article_id.parse::() else { return page(StatusCode::BAD_REQUEST, "Bad link — invalid article.", None); }; let Some(vote) = Vote::parse(&vote) else { return page(StatusCode::BAD_REQUEST, "Bad link — invalid vote.", None); }; let config = state.config(); let Some(secret) = config.server.hmac_secret.as_deref() else { tracing::error!("rating request but server.hmac_secret is unset"); return page( StatusCode::INTERNAL_SERVER_ERROR, "Server misconfigured.", None, ); }; if !verify_token(secret, date, article_id, vote, &query.t) { tracing::warn!(%date, article_id, vote = vote.as_str(), "rejected rating token"); return page(StatusCode::FORBIDDEN, "Invalid link.", None); } let article = match state.db.get_article(article_id).await { Ok(Some(a)) => a, Ok(None) => { tracing::warn!(article_id, "rating for an unknown article"); return page(StatusCode::NOT_FOUND, "Unknown article.", None); } Err(e) => { tracing::error!(error = %e, article_id, "loading the rated article failed"); return page(StatusCode::INTERNAL_SERVER_ERROR, "Database error.", None); } }; let label = match vote { Vote::Loved => "loved", Vote::Good => "good", Vote::NotForMe => "not_for_me", }; let event = RatingEvent { id: 0, user_id: None, issue_date: Some(date), article_id, kind: "explicit".into(), source: "epub".into(), label: label.into(), value: vote.value(&config.curation.feedback), note: None, event_at: Timestamp::now(), }; if let Err(error) = state.db.append_rating_event(&event).await { tracing::error!(%error, article_id, "recording the rating failed"); return page(StatusCode::INTERNAL_SERVER_ERROR, "Database error.", None); } tracing::info!( %date, article_id, feed_id = article.feed_id, vote = vote.as_str(), title = %article.title, "recorded rating event" ); let message = match vote { Vote::Loved => "Recorded: Loved it — thanks.", Vote::Good => "Recorded: Good — thanks.", Vote::NotForMe => "Recorded: Not for me — thanks.", }; confirmation_page(StatusCode::OK, message, &config, date, article_id, vote) } /// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first, /// rendered from the publish directory on each request (§3.11). async fn handle_opds(State(state): State, headers: HeaderMap) -> Response { let config = state.config(); if let Some(challenge) = check_basic_auth(&config, &headers) { return challenge; } match crate::publish::build_opds(&state.db, &config).await { Ok(feed) => ( StatusCode::OK, [ (header::CONTENT_TYPE, OPDS_CONTENT_TYPE), (header::CACHE_CONTROL, "no-cache"), ], feed, ) .into_response(), Err(e) => { tracing::error!(error = %e, "could not build the OPDS feed"); ( StatusCode::INTERNAL_SERVER_ERROR, "could not build the feed", ) .into_response() } } } /// `GET /files/epub/{name}` — download one published EPUB; this is what the /// OPDS acquisition links point at (§3.11). async fn handle_epub_file( State(state): State, Path(name): Path, headers: HeaderMap, auth: crate::web::session::AuthSession, ) -> Response { let dir = state.config().publish.epub_dir.clone(); serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await } /// `GET /files/xtc/{name}` — download one XTC artifact (§3.11). /// /// Not listed in the OPDS feed — CrossPoint's browser cannot acquire XTC — but /// kept so the artifacts can still be fetched by URL for sideloading. async fn handle_xtc_file( State(state): State, Path(name): Path, headers: HeaderMap, auth: crate::web::session::AuthSession, ) -> Response { let dir = state.config().publish.xtc_dir.clone(); serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await } /// Stream one file out of `dir`. A signed-in web session (any role) bypasses /// the OPDS Basic auth; without one the existing Basic auth policy applies /// unchanged, including "no credentials configured ⇒ public", so e-readers /// following the OPDS feed keep working (§3.11, dashboard plan §8). async fn serve_file( state: &AppState, dir: &FsPath, name: &str, headers: &HeaderMap, signed_in: bool, ) -> Response { let config = state.config(); if !signed_in && let Some(challenge) = check_basic_auth(&config, headers) { return challenge; } let Some(path) = safe_join(dir, name) else { tracing::warn!(name, "rejected an unsafe file name"); return (StatusCode::BAD_REQUEST, "bad file name").into_response(); }; // An XTCH issue is a pre-rendered page bitmap per page — ~100 MB for a full // day. Stream it rather than buffering the whole file per request (§3.11). let (file, len) = match tokio::fs::File::open(&path).await { Ok(file) => { let len = file.metadata().await.map(|m| m.len()).ok(); (file, len) } Err(e) => { tracing::warn!(error = %e, path = %path.display(), "file not found"); return (StatusCode::NOT_FOUND, "not found").into_response(); } }; // CrossPoint dispatches on the saved file's extension, not on this header, // but Calibre and KOReader both use it. let content_type = if name.ends_with(".epub") { crate::publish::EPUB_CONTENT_TYPE } else { "application/octet-stream" }; let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); if let Ok(value) = HeaderValue::from_str(&format!( "attachment; filename=\"{}\"", name.replace('"', "") )) { headers.insert(header::CONTENT_DISPOSITION, value); } // CrossPoint shows a progress bar only when it knows the size up front. if let Some(len) = len && let Ok(value) = HeaderValue::from_str(&len.to_string()) { headers.insert(header::CONTENT_LENGTH, value); } let body = Body::from_stream(tokio_util::io::ReaderStream::new(file)); (StatusCode::OK, headers, body).into_response() } /// Resolve `name` inside `dir`, rejecting anything that could escape it (§3.12). /// /// The name must be a single, plain file name: no separators, no `..`, no /// absolute paths, no hidden files, and — belt and braces — the joined path must /// still live inside `dir` once resolved. pub fn safe_join(dir: &FsPath, name: &str) -> Option { if name.is_empty() || name.len() > 255 || name.starts_with('.') || name.contains('/') || name.contains('\\') || name.contains('\0') || name.contains("..") { return None; } let mut components = FsPath::new(name).components(); let only = match (components.next(), components.next()) { (Some(std::path::Component::Normal(c)), None) => c.to_owned(), _ => return None, }; let candidate = dir.join(only); // When both sides resolve, require containment (defends against symlinked names). match (candidate.canonicalize(), dir.canonicalize()) { (Ok(resolved), Ok(root)) if !resolved.starts_with(&root) => None, _ => Some(candidate), } } // --------------------------------------------------------------------------- // Basic auth (§3.11 optional OPDS credentials) // --------------------------------------------------------------------------- /// `Some(challenge_response)` when the request must be rejected, `None` when it /// may proceed (including when no credentials are configured). fn check_basic_auth(config: &Config, headers: &HeaderMap) -> Option { let (Some(user), Some(pass)) = ( config.server.basic_auth_user.as_deref(), config.server.basic_auth_pass.as_deref(), ) else { return None; }; let expected = BASE64.encode(format!("{user}:{pass}")); let supplied = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Basic ")) .map(str::trim) .unwrap_or_default(); if constant_time_eq(expected.as_bytes(), supplied.as_bytes()) { return None; } tracing::warn!("rejected an unauthenticated OPDS request"); let challenge = HeaderValue::from_str(&format!("Basic realm=\"{AUTH_REALM}\", charset=\"UTF-8\"")) .unwrap_or(HeaderValue::from_static("Basic")); Some( ( StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, challenge)], "authentication required", ) .into_response(), ) } // --------------------------------------------------------------------------- // Tiny e-ink pages (§3.9) // --------------------------------------------------------------------------- /// A self-contained response page — no external CSS, well under 1 KB, legible on /// a 6" e-ink browser (§3.9). fn page(status: StatusCode, message: &str, note: Option<&str>) -> Response { let body = page_html(message, note, None); ( status, [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-store"), ], body, ) .into_response() } fn confirmation_page( status: StatusCode, message: &str, config: &Config, date: Date, article_id: ArticleId, selected: Vote, ) -> Response { let Some(secret) = config.server.hmac_secret.as_deref() else { return page(status, message, None); }; let choices = [ (Vote::Loved, "Loved it"), (Vote::Good, "Good"), (Vote::NotForMe, "Not for me"), ] .into_iter() .filter(|(vote, _)| *vote != selected) .map(|(vote, label)| { let url = rating_url(&config.server.public_url, secret, date, article_id, vote); format!( "[ {} ]", escape_attr(&url), escape(label) ) }) .collect::>() .join("   "); let issue_url = format!( "{}/issues/{date}", config.server.public_url.trim_end_matches('/') ); let body = page_html( message, None, Some(&format!( "

Change it: {choices}

Open this issue on the site

", escape_attr(&issue_url) )), ); ( status, [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-store"), ], body, ) .into_response() } /// The page markup itself: no external stylesheet, script, or images (§6.1). fn page_html(message: &str, note: Option<&str>, extra_html: Option<&str>) -> String { let note = note .map(|n| format!("

{}

", escape(n))) .unwrap_or_default(); format!( "\ \ The Daily EPUB\ \

{}

{}{}", escape(message), note, extra_html.unwrap_or_default() ) } fn escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") } fn escape_attr(s: &str) -> String { escape(s).replace('\"', """) } #[cfg(test)] mod tests { use super::*; use crate::types::{Article, ExtractMethod, SourceKind, SourceRef}; fn date() -> Date { "2026-08-15".parse().unwrap() } fn ts(s: &str) -> Timestamp { s.parse().unwrap() } /// The shared fixture vector: the EPUB footer builder must produce the same /// token for these inputs (§3.9). const VECTOR_SECRET: &str = "test-secret"; const VECTOR_TOKEN_UP: &str = "3b314cf7e6d8f50f"; #[test] fn token_matches_the_shared_test_vector() { let loved = rating_token(VECTOR_SECRET, date(), 42, Vote::Loved); assert_eq!(loved, "cece96767d6c5f8a"); assert_eq!(loved.len(), 16); // Down differs from up, and both verify. let down = rating_token(VECTOR_SECRET, date(), 42, Vote::NotForMe); assert_ne!(down, VECTOR_TOKEN_UP); assert!(verify_token( VECTOR_SECRET, date(), 42, Vote::Loved, VECTOR_TOKEN_UP )); assert!(verify_token( VECTOR_SECRET, date(), 42, Vote::NotForMe, &down )); } /// The links the EPUB footer embeds must verify here — this is the whole /// feedback loop in one assertion (§3.9). #[test] fn epub_footer_links_verify_against_this_server() { for (id, vote) in [(42, Vote::Loved), (1234, Vote::NotForMe)] { let from_epub = crate::epub::build::rating_url( "https://daily.hallada.net", VECTOR_SECRET, date(), id, vote, ); assert_eq!( from_epub, rating_url("https://daily.hallada.net", VECTOR_SECRET, date(), id, vote) ); let token = from_epub.rsplit("?t=").next().unwrap_or_default(); assert!( verify_token(VECTOR_SECRET, date(), id, vote, token), "{from_epub}" ); } } #[test] fn token_verification_rejects_tampering() { let t = rating_token(VECTOR_SECRET, date(), 42, Vote::Loved); assert!(!verify_token(VECTOR_SECRET, date(), 42, Vote::NotForMe, &t)); assert!(!verify_token(VECTOR_SECRET, date(), 43, Vote::Loved, &t)); assert!(!verify_token("other-secret", date(), 42, Vote::Loved, &t)); assert!(!verify_token( VECTOR_SECRET, "2026-08-16".parse().unwrap(), 42, Vote::Loved, &t )); assert!(!verify_token(VECTOR_SECRET, date(), 42, Vote::Loved, "")); assert!(!verify_token( VECTOR_SECRET, date(), 42, Vote::Loved, &format!("{t}00") )); } #[test] fn rating_url_is_the_link_the_epub_embeds() { assert_eq!( rating_url( "https://daily.hallada.net/", VECTOR_SECRET, date(), 42, Vote::Loved ), format!( "https://daily.hallada.net/r/2026-08-15/42/loved?t={}", rating_token(VECTOR_SECRET, date(), 42, Vote::Loved) ) ); } #[test] fn constant_time_eq_behaves_like_eq() { assert!(constant_time_eq(b"abc", b"abc")); assert!(!constant_time_eq(b"abc", b"abd")); assert!(!constant_time_eq(b"abc", b"abcd")); assert!(!constant_time_eq(b"", b"a")); assert!(constant_time_eq(b"", b"")); } #[test] fn safe_join_rejects_traversal() { let dir = FsPath::new("/var/lib/daily-epub/xtc"); assert_eq!( safe_join(dir, "The Daily EPUB - 2026-08-15 (X4).xtch"), Some(dir.join("The Daily EPUB - 2026-08-15 (X4).xtch")) ); for bad in [ "", "..", "../secret", "..%2Fsecret", "a/../../secret", "sub/dir.xtch", "/etc/passwd", ".hidden", "back\\slash", ] { assert!(safe_join(dir, bad).is_none(), "should reject {bad:?}"); } // Percent-decoding happens before us: a decoded traversal is rejected too. assert!(safe_join(dir, "../../etc/passwd").is_none()); } #[tokio::test] async fn the_confirmation_page_is_tiny_and_self_contained() { let html = page_html( "Recorded: Loved it — thanks.", Some("2026-08-15 · article 42"), None, ); assert!(html.len() < 1024, "page is {} bytes", html.len()); assert!(!html.contains("x", None, None).contains("<b>")); let mut config = Config::default(); config.server.hmac_secret = Some("test-secret".into()); config.server.public_url = "https://daily.example".into(); let response = confirmation_page(StatusCode::OK, "Recorded", &config, date(), 42, Vote::Loved); let body = String::from_utf8( axum::body::to_bytes(response.into_body(), 4096) .await .unwrap() .to_vec(), ) .unwrap(); assert!( body.contains( "href=\"https://daily.example/issues/2026-08-15\">Open this issue on the site" ), "{body}" ); } // ----------------------------------------------------------------- // End-to-end over a real listener (the crate has no lib target, so the // HTTP-level tests live here rather than in `tests/`). // ----------------------------------------------------------------- struct TestServer { base: String, db: Db, _dir: tempfile::TempDir, handle: tokio::task::JoinHandle<()>, } impl TestServer { async fn start(with_auth: bool) -> TestServer { let dir = tempfile::tempdir().unwrap(); let xtc_dir = dir.path().join("xtc"); let epub_dir = dir.path().join("epub"); std::fs::create_dir_all(&xtc_dir).unwrap(); std::fs::create_dir_all(&epub_dir).unwrap(); let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) .await .unwrap(); let mut config = Config::default(); config.server.hmac_secret = Some(VECTOR_SECRET.into()); config.publish.xtc_dir = xtc_dir; config.publish.epub_dir = epub_dir; config.server.public_url = "https://daily.hallada.net".into(); if with_auth { config.server.basic_auth_user = Some("opds".into()); config.server.basic_auth_pass = Some("hunter2".into()); } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let app = router(AppState::new(db.clone(), config, None)); let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); TestServer { base: format!("http://{addr}"), db, _dir: dir, handle, } } fn xtc_dir(&self) -> PathBuf { self._dir.path().join("xtc") } fn epub_dir(&self) -> PathBuf { self._dir.path().join("epub") } async fn seed_article(&self) -> ArticleId { let entry = crate::types::Entry { id: 1, feed_id: 7, feed_title: Some("Hacker News".into()), category: None, title: "Story".into(), url: "https://example.com/1".into(), canonical_url: Some("https://example.com/1".into()), author: None, published_at: Some(ts("2026-08-15T04:00:00Z")), comments_url: None, raw_content: "

hi

".into(), fetched_at: ts("2026-08-15T05:30:00Z"), }; self.db.upsert_entry(&entry).await.unwrap(); let article = Article { id: 0, canonical_url: "https://example.com/1".into(), title: "Story".into(), best_entry_id: 1, content_html: "

hi

".into(), word_count: 500, excerpt_only: false, image_count: 0, sources: vec![SourceRef { entry_id: 1, feed_id: 7, feed_title: "Hacker News".into(), category: None, kind: SourceKind::HnFrontpage, }], first_seen: ts("2026-08-15T05:30:00Z"), url: "https://example.com/1".into(), author: None, feed_id: 7, feed_title: "Hacker News".into(), category: None, published_at: None, comments_url: None, image_urls: vec![], social: vec![], extract_method: ExtractMethod::Miniflux, }; self.db.upsert_article(&article).await.unwrap() } } impl Drop for TestServer { fn drop(&mut self) { self.handle.abort(); } } fn client() -> reqwest::Client { reqwest::Client::builder().build().unwrap() } #[tokio::test] async fn healthz_and_issues_json() { let server = TestServer::start(false).await; let res = client() .get(format!("{}/healthz", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 200); assert_eq!(res.text().await.unwrap(), "ok"); let res = client() .get(format!("{}/issues.json", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body.as_array().map(Vec::len), Some(0)); // Newest first, report JSON inlined. for (day, n) in [("2026-08-13", 1), ("2026-08-15", 3), ("2026-08-14", 2)] { server .db .upsert_issue( day.parse().unwrap(), n, ts("2026-08-15T05:30:00Z"), None, None, None, None, Some(&format!("{{\"selected\":{n}}}")), None, ) .await .unwrap(); } let body: serde_json::Value = client() .get(format!("{}/issues.json", server.base)) .send() .await .unwrap() .json() .await .unwrap(); let dates: Vec<&str> = body .as_array() .unwrap() .iter() .map(|v| v["date"].as_str().unwrap()) .collect(); assert_eq!(dates, ["2026-08-15", "2026-08-14", "2026-08-13"]); assert_eq!(body[0]["report"]["selected"], 3); } #[tokio::test] async fn rating_taps_append_events_and_latest_correction_wins() { let server = TestServer::start(false).await; let id = server.seed_article().await; let loved = rating_url(&server.base, VECTOR_SECRET, date(), id, Vote::Loved); let response = client().get(&loved).send().await.unwrap(); assert_eq!(response.status(), 200); let body = response.text().await.unwrap(); assert!(body.contains("Recorded: Loved it — thanks."), "{body}"); assert!(body.contains("[ Good ]"), "{body}"); assert!(body.contains("[ Not for me ]"), "{body}"); assert!( body.len() < 2048, "confirmation page is {} bytes", body.len() ); // Every tap is history, even a repeated one. assert_eq!(client().get(&loved).send().await.unwrap().status(), 200); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rating_events") .fetch_one(server.db.pool()) .await .unwrap(); assert_eq!(count, 2); // A correction appends and becomes the current verdict. let down = rating_url(&server.base, VECTOR_SECRET, date(), id, Vote::NotForMe); assert_eq!(client().get(&down).send().await.unwrap().status(), 200); let current = server.db.current_ratings(36500).await.unwrap(); assert_eq!(current.len(), 1); assert_eq!(current[0].label, "not_for_me"); let sources: Vec = sqlx::query_scalar("SELECT source FROM rating_events ORDER BY id") .fetch_all(server.db.pool()) .await .unwrap(); assert_eq!(sources, ["epub", "epub", "epub"]); } #[tokio::test] async fn rating_rejects_bad_tokens_dates_and_unknown_articles() { let server = TestServer::start(false).await; let id = server.seed_article().await; let bad = format!("{}/r/2026-08-15/{id}/up?t=deadbeefdeadbeef", server.base); assert_eq!(client().get(&bad).send().await.unwrap().status(), 403); let missing = format!("{}/r/2026-08-15/{id}/up", server.base); assert_eq!(client().get(&missing).send().await.unwrap().status(), 403); // A valid token for an article that does not exist. let unknown = rating_url(&server.base, VECTOR_SECRET, date(), 9999, Vote::Loved); assert_eq!(client().get(&unknown).send().await.unwrap().status(), 404); // Malformed date / vote. let token = rating_token(VECTOR_SECRET, date(), id, Vote::Loved); let bad_date = format!("{}/r/not-a-date/{id}/up?t={token}", server.base); assert_eq!(client().get(&bad_date).send().await.unwrap().status(), 400); let bad_vote = format!("{}/r/2026-08-15/{id}/sideways?t={token}", server.base); assert_eq!(client().get(&bad_vote).send().await.unwrap().status(), 400); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rating_events") .fetch_one(server.db.pool()) .await .unwrap(); assert_eq!(count, 0); } #[tokio::test] async fn opds_and_files_are_served_behind_basic_auth() { let server = TestServer::start(true).await; for edition in crate::types::Edition::ALL { let name = crate::publish::issue_filename(date(), edition, "epub"); std::fs::write(server.epub_dir().join(name), b"EPUB").unwrap(); } std::fs::write( server .xtc_dir() .join("The Daily EPUB - 2026-08-15 (X4).xtch"), b"XTCH", ) .unwrap(); let res = client() .get(format!("{}/opds/daily.xml", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 401); assert!( res.headers() .get("www-authenticate") .and_then(|v| v.to_str().ok()) .unwrap_or_default() .starts_with("Basic realm=") ); let res = client() .get(format!("{}/opds/daily.xml", server.base)) .basic_auth("opds", Some("wrong")) .send() .await .unwrap(); assert_eq!(res.status(), 401); let res = client() .get(format!("{}/opds/daily.xml", server.base)) .basic_auth("opds", Some("hunter2")) .send() .await .unwrap(); assert_eq!(res.status(), 200); assert!( res.headers()[header::CONTENT_TYPE] .to_str() .unwrap() .starts_with("application/atom+xml") ); let feed = res.text().await.unwrap(); assert_eq!(feed.matches("").count(), 2, "{feed}"); assert_eq!(feed.matches("application/epub+zip").count(), 2, "{feed}"); // XTC exists on disk but is never advertised (§3.11). assert!(!feed.contains(".xtch"), "{feed}"); // The catalog root serves the same feed, behind the same auth. for alias in ["/opds", "/opds/"] { let res = client() .get(format!("{}{alias}", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 401, "{alias}"); let res = client() .get(format!("{}{alias}", server.base)) .basic_auth("opds", Some("hunter2")) .send() .await .unwrap(); assert_eq!(res.status(), 200, "{alias}"); assert_eq!(res.text().await.unwrap(), feed, "{alias}"); } // The acquisition link resolves, typed as an EPUB. let res = client() .get(format!( "{}/files/epub/The%20Daily%20EPUB%20-%202026-08-15%20(X4).epub", server.base )) .basic_auth("opds", Some("hunter2")) .send() .await .unwrap(); assert_eq!(res.status(), 200); // CrossPoint needs the size up front to show download progress. assert_eq!(res.content_length(), Some(4)); assert_eq!( res.headers()[header::CONTENT_TYPE].to_str().unwrap(), "application/epub+zip" ); assert_eq!(res.bytes().await.unwrap().as_ref(), b"EPUB"); // XTC is still fetchable by URL for sideloading, just not listed. let res = client() .get(format!( "{}/files/xtc/The%20Daily%20EPUB%20-%202026-08-15%20(X4).xtch", server.base )) .basic_auth("opds", Some("hunter2")) .send() .await .unwrap(); assert_eq!(res.status(), 200); assert_eq!(res.bytes().await.unwrap().as_ref(), b"XTCH"); // Ratings are not behind auth (the token is the credential). assert_eq!( client() .get(format!("{}/healthz", server.base)) .send() .await .unwrap() .status(), 200 ); } #[tokio::test] async fn file_route_rejects_path_traversal() { let server = TestServer::start(false).await; std::fs::write(server._dir.path().join("secret"), b"top secret").unwrap(); // Encoded traversal survives URL normalization and reaches the handler. let res = client() .get(format!("{}/files/xtc/..%2Fsecret", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 400); let res = client() .get(format!( "{}/files/xtc/%2e%2e%2f%2e%2e%2fsecret", server.base )) .send() .await .unwrap(); assert_eq!(res.status(), 400); // A plain `..` segment is not even a match for the single-segment route. let res = client() .get(format!("{}/files/xtc/nope.xtch", server.base)) .send() .await .unwrap(); assert_eq!(res.status(), 404); } }