//! dovenest server. //! //! Library target so the HTTP app can be exercised from integration tests //! (via `tower::ServiceExt::oneshot`) without binding a real port. The //! `dovenest` binary is a thin wrapper around [`run`]. use std::net::SocketAddr; use std::sync::Arc; use anyhow::{Context, bail}; use clap::Parser; use tower_http::trace::TraceLayer; pub mod api; pub mod archive; mod assets; pub mod auth; pub mod cli; pub mod db; pub mod error; pub mod fs; pub mod thumb; pub mod webauthn; use crate::cli::Cli; use crate::db::Db; use crate::error::AppState; /// Validate the CLI config and build the running state + router without /// binding the port (so tests can exercise everything up to `serve`). pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)> { let root = cli .root .canonicalize() .with_context(|| format!("cannot resolve root folder: {}", cli.root.display()))?; if !root.is_dir() { bail!("root folder is not a directory: {}", root.display()); } let db = Db::open(&cli.db).await?; let root_name = cli .root_name .clone() .filter(|n| !n.trim().is_empty()) .unwrap_or_else(|| root_file_name(&root)); let thumbs = match &cli.cache { Some(dir) => { let thumbs = crate::thumb::Thumbs::new(dir.clone()) .await .with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?; tokio::spawn(crate::thumb::sweep_forever(dir.clone())); Some(Arc::new(thumbs)) } None => None, }; tokio::spawn(crate::db::sweep_forever(db.clone())); let state = Arc::new(AppState { db, root: root.clone(), root_name, public_url: cli.public_url.clone(), thumbs, }); let app = api::router(state).layer(TraceLayer::new_for_http()); Ok((app, SocketAddr::new(cli.bind, cli.port))) } /// Parse the CLI, initialize logging and serve until the process is killed. pub async fn run() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,tower_http=warn".into()), ) .init(); let cli = Cli::parse(); let (app, addr) = build_app(&cli).await?; let listener = tokio::net::TcpListener::bind(addr) .await .with_context(|| format!("cannot bind to {addr}"))?; tracing::info!(root = %cli.root.display(), "dovenest starting"); tracing::info!(addr = %addr, "listening (pass --bind 0.0.0.0 to expose beyond localhost)"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } /// Resolves on Ctrl-C or SIGTERM (what a container runtime sends on stop). /// Without this, a restart cuts in-flight uploads and archive downloads /// mid-stream instead of letting them finish. async fn shutdown_signal() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; let mut term = match signal(SignalKind::terminate()) { Ok(s) => s, // No SIGTERM handler: Ctrl-C alone still stops the server. Err(e) => { tracing::warn!(error = %e, "cannot listen for SIGTERM"); return ctrl_c.await; } }; tokio::select! { () = ctrl_c => {} _ = term.recv() => {} } } #[cfg(not(unix))] ctrl_c.await; tracing::info!("shutting down, waiting for in-flight requests"); } /// Lowercase hex of `bytes`. pub(crate) fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; bytes .iter() .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { let _ = write!(s, "{b:02x}"); s }) } /// The root folder's own name; "/" has none, so fall back to the full path. pub fn root_file_name(root: &std::path::Path) -> String { root.file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| root.display().to_string()) } #[cfg(test)] mod tests { use super::*; use crate::cli::Cli; fn cli(root: &std::path::Path, db: &std::path::Path) -> Cli { Cli { root: root.to_path_buf(), db: db.to_path_buf(), root_name: None, port: 8080, bind: [127, 0, 0, 1].into(), cache: None, public_url: None, } } #[tokio::test] async fn build_app_ok() { let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir_all(tmp.path().join("sub")).unwrap(); let c = cli(tmp.path(), &tmp.path().join("db.sqlite")); let (app, addr) = build_app(&c).await.unwrap(); let _ = app; // Router built fine assert_eq!(addr.to_string(), "127.0.0.1:8080"); } #[tokio::test] async fn build_app_missing_root_fails() { let tmp = tempfile::tempdir().unwrap(); let c = cli( &tmp.path().join("no-such-root"), &tmp.path().join("db.sqlite"), ); assert!(build_app(&c).await.is_err()); } #[tokio::test] async fn build_app_root_must_be_directory() { let tmp = tempfile::tempdir().unwrap(); let file = tmp.path().join("a-file"); std::fs::write(&file, "x").unwrap(); let c = cli(&file, &tmp.path().join("db.sqlite")); assert!(build_app(&c).await.is_err()); } #[tokio::test] async fn build_app_custom_port_bind() { let tmp = tempfile::tempdir().unwrap(); let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite")); c.port = 9999; c.bind = [0, 0, 0, 0].into(); let (_app, addr) = build_app(&c).await.unwrap(); assert_eq!(addr.to_string(), "0.0.0.0:9999"); } }