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::{IpAddr, 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) => Some(Arc::new(
49 crate::thumb::Thumbs::new(dir.clone())
50 .await
51 .with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?,
52 )),
53 None => None,
54 };
55 if let Some(dir) = cli.cache.clone() {
56 tokio::spawn(crate::thumb::sweep_forever(dir));
57 }
58
59 let state = Arc::new(AppState {
60 db,
61 root: root.clone(),
62 root_name,
63 https: cli.https,
64 public_url: cli
65 .public_url
66 .as_deref()
67 .map(|u| u.trim_end_matches('/').to_string()),
68 thumbs,
69 });
70
71 let app = api::router(state).layer(TraceLayer::new_for_http());
72
73 let ip: IpAddr = cli.bind.parse().context("invalid --bind address")?;
74 let addr = SocketAddr::new(ip, cli.port);
75 Ok((app, addr))
76}
77
78/// Parse the CLI, initialize logging and serve until the process is killed.
79pub async fn run() -> anyhow::Result<()> {
80 tracing_subscriber::fmt()
81 .with_env_filter(
82 tracing_subscriber::EnvFilter::try_from_default_env()
83 .unwrap_or_else(|_| "info,tower_http=warn".into()),
84 )
85 .init();
86
87 let cli = Cli::parse();
88 let (app, addr) = build_app(&cli).await?;
89 let listener = tokio::net::TcpListener::bind(addr)
90 .await
91 .with_context(|| format!("cannot bind to {addr}"))?;
92
93 tracing::info!(root = %cli.root.display(), "filebrowser-ng starting");
94 tracing::info!(addr = %addr, "listening (pass --bind 0.0.0.0 to expose beyond localhost)");
95 axum::serve(listener, app)
96 .with_graceful_shutdown(shutdown_signal())
97 .await?;
98 Ok(())
99}
100
101/// Resolves on Ctrl-C or SIGTERM (what a container runtime sends on stop).
102/// Without this, a restart cuts in-flight uploads and archive downloads
103/// mid-stream instead of letting them finish.
104async fn shutdown_signal() {
105 let ctrl_c = async {
106 let _ = tokio::signal::ctrl_c().await;
107 };
108 #[cfg(unix)]
109 {
110 use tokio::signal::unix::{SignalKind, signal};
111 let mut term = match signal(SignalKind::terminate()) {
112 Ok(s) => s,
113 // No SIGTERM handler: Ctrl-C alone still stops the server.
114 Err(e) => {
115 tracing::warn!(error = %e, "cannot listen for SIGTERM");
116 return ctrl_c.await;
117 }
118 };
119 tokio::select! {
120 () = ctrl_c => {}
121 _ = term.recv() => {}
122 }
123 }
124 #[cfg(not(unix))]
125 ctrl_c.await;
126 tracing::info!("shutting down, waiting for in-flight requests");
127}
128
129/// The root folder's own name; "/" has none, so fall back to the full path.
130pub fn root_file_name(root: &std::path::Path) -> String {
131 root.file_name()
132 .map(|s| s.to_string_lossy().into_owned())
133 .unwrap_or_else(|| root.display().to_string())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::cli::Cli;
140
141 fn cli(root: &std::path::Path, db: &std::path::Path) -> Cli {
142 Cli {
143 root: root.to_path_buf(),
144 db: db.to_path_buf(),
145 root_name: None,
146 port: 8080,
147 bind: "127.0.0.1".into(),
148 cache: None,
149 https: false,
150 public_url: None,
151 }
152 }
153
154 #[tokio::test]
155 async fn build_app_ok() {
156 let tmp = tempfile::tempdir().unwrap();
157 std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
158 let c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
159 let (app, addr) = build_app(&c).await.unwrap();
160 let _ = app; // Router built fine
161 assert_eq!(addr.to_string(), "127.0.0.1:8080");
162 }
163
164 #[tokio::test]
165 async fn build_app_missing_root_fails() {
166 let tmp = tempfile::tempdir().unwrap();
167 let c = cli(
168 &tmp.path().join("no-such-root"),
169 &tmp.path().join("db.sqlite"),
170 );
171 assert!(build_app(&c).await.is_err());
172 }
173
174 #[tokio::test]
175 async fn build_app_root_must_be_directory() {
176 let tmp = tempfile::tempdir().unwrap();
177 let file = tmp.path().join("a-file");
178 std::fs::write(&file, "x").unwrap();
179 let c = cli(&file, &tmp.path().join("db.sqlite"));
180 assert!(build_app(&c).await.is_err());
181 }
182
183 #[tokio::test]
184 async fn build_app_rejects_bad_bind() {
185 let tmp = tempfile::tempdir().unwrap();
186 let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
187 c.bind = "not-an-ip".into();
188 assert!(build_app(&c).await.is_err());
189 }
190
191 #[tokio::test]
192 async fn build_app_custom_port_bind() {
193 let tmp = tempfile::tempdir().unwrap();
194 let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
195 c.port = 9999;
196 c.bind = "0.0.0.0".into();
197 let (_app, addr) = build_app(&c).await.unwrap();
198 assert_eq!(addr.to_string(), "0.0.0.0:9999");
199 }
200}
201