lib.rs
⎇
Raw
1//! filebrowser-ng server.
2//!
3//! Library target so the HTTP app can be exercised from integration tests
4//! (via `tower::ServiceExt::oneshot`) without binding a real port. The
5//! `filebrowser-ng` binary is a thin wrapper around [`run`].
6
7use std::net::SocketAddr;
8use std::sync::Arc;
9
10use anyhow::{Context, bail};
11use clap::Parser;
12use tower_http::trace::TraceLayer;
13
14pub mod api;
15pub mod archive;
16mod assets;
17pub mod auth;
18pub mod cli;
19pub mod db;
20pub mod error;
21pub mod fs;
22pub mod thumb;
23pub mod webauthn;
24
25use crate::cli::Cli;
26use crate::db::Db;
27use crate::error::AppState;
28
29/// Validate the CLI config and build the running state + router without
30/// binding the port (so tests can exercise everything up to `serve`).
31pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)> {
32 let root = cli
33 .root
34 .canonicalize()
35 .with_context(|| format!("cannot resolve root folder: {}", cli.root.display()))?;
36 if !root.is_dir() {
37 bail!("root folder is not a directory: {}", root.display());
38 }
39
40 let db = Db::open(&cli.db).await?;
41
42 let root_name = cli
43 .root_name
44 .clone()
45 .filter(|n| !n.trim().is_empty())
46 .unwrap_or_else(|| root_file_name(&root));
47 let thumbs = match &cli.cache {
48 Some(dir) => {
49 let thumbs = crate::thumb::Thumbs::new(dir.clone())
50 .await
51 .with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?;
52 tokio::spawn(crate::thumb::sweep_forever(dir.clone()));
53 Some(Arc::new(thumbs))
54 }
55 None => None,
56 };
57 tokio::spawn(crate::db::sweep_forever(db.clone()));
58
59 let state = Arc::new(AppState {
60 db,
61 root: root.clone(),
62 root_name,
63 public_url: cli.public_url.clone(),
64 thumbs,
65 });
66
67 let app = api::router(state).layer(TraceLayer::new_for_http());
68
69 Ok((app, SocketAddr::new(cli.bind, cli.port)))
70}
71
72/// Parse the CLI, initialize logging and serve until the process is killed.
73pub async fn run() -> anyhow::Result<()> {
74 tracing_subscriber::fmt()
75 .with_env_filter(
76 tracing_subscriber::EnvFilter::try_from_default_env()
77 .unwrap_or_else(|_| "info,tower_http=warn".into()),
78 )
79 .init();
80
81 let cli = Cli::parse();
82 let (app, addr) = build_app(&cli).await?;
83 let listener = tokio::net::TcpListener::bind(addr)
84 .await
85 .with_context(|| format!("cannot bind to {addr}"))?;
86
87 tracing::info!(root = %cli.root.display(), "filebrowser-ng starting");
88 tracing::info!(addr = %addr, "listening (pass --bind 0.0.0.0 to expose beyond localhost)");
89 axum::serve(listener, app)
90 .with_graceful_shutdown(shutdown_signal())
91 .await?;
92 Ok(())
93}
94
95/// Resolves on Ctrl-C or SIGTERM (what a container runtime sends on stop).
96/// Without this, a restart cuts in-flight uploads and archive downloads
97/// mid-stream instead of letting them finish.
98async fn shutdown_signal() {
99 let ctrl_c = async {
100 let _ = tokio::signal::ctrl_c().await;
101 };
102 #[cfg(unix)]
103 {
104 use tokio::signal::unix::{SignalKind, signal};
105 let mut term = match signal(SignalKind::terminate()) {
106 Ok(s) => s,
107 // No SIGTERM handler: Ctrl-C alone still stops the server.
108 Err(e) => {
109 tracing::warn!(error = %e, "cannot listen for SIGTERM");
110 return ctrl_c.await;
111 }
112 };
113 tokio::select! {
114 () = ctrl_c => {}
115 _ = term.recv() => {}
116 }
117 }
118 #[cfg(not(unix))]
119 ctrl_c.await;
120 tracing::info!("shutting down, waiting for in-flight requests");
121}
122
123/// Lowercase hex of `bytes`.
124pub(crate) fn hex(bytes: &[u8]) -> String {
125 use std::fmt::Write as _;
126 bytes
127 .iter()
128 .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
129 let _ = write!(s, "{b:02x}");
130 s
131 })
132}
133
134/// The root folder's own name; "/" has none, so fall back to the full path.
135pub fn root_file_name(root: &std::path::Path) -> String {
136 root.file_name()
137 .map(|s| s.to_string_lossy().into_owned())
138 .unwrap_or_else(|| root.display().to_string())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::cli::Cli;
145
146 fn cli(root: &std::path::Path, db: &std::path::Path) -> Cli {
147 Cli {
148 root: root.to_path_buf(),
149 db: db.to_path_buf(),
150 root_name: None,
151 port: 8080,
152 bind: [127, 0, 0, 1].into(),
153 cache: None,
154 public_url: None,
155 }
156 }
157
158 #[tokio::test]
159 async fn build_app_ok() {
160 let tmp = tempfile::tempdir().unwrap();
161 std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
162 let c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
163 let (app, addr) = build_app(&c).await.unwrap();
164 let _ = app; // Router built fine
165 assert_eq!(addr.to_string(), "127.0.0.1:8080");
166 }
167
168 #[tokio::test]
169 async fn build_app_missing_root_fails() {
170 let tmp = tempfile::tempdir().unwrap();
171 let c = cli(
172 &tmp.path().join("no-such-root"),
173 &tmp.path().join("db.sqlite"),
174 );
175 assert!(build_app(&c).await.is_err());
176 }
177
178 #[tokio::test]
179 async fn build_app_root_must_be_directory() {
180 let tmp = tempfile::tempdir().unwrap();
181 let file = tmp.path().join("a-file");
182 std::fs::write(&file, "x").unwrap();
183 let c = cli(&file, &tmp.path().join("db.sqlite"));
184 assert!(build_app(&c).await.is_err());
185 }
186
187 #[tokio::test]
188 async fn build_app_custom_port_bind() {
189 let tmp = tempfile::tempdir().unwrap();
190 let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
191 c.port = 9999;
192 c.bind = [0, 0, 0, 0].into();
193 let (_app, addr) = build_app(&c).await.unwrap();
194 assert_eq!(addr.to_string(), "0.0.0.0:9999");
195 }
196}
197