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/// Whether the browser reaches this server over TLS.
30///
31/// The process itself only ever speaks plain HTTP, so it cannot observe this;
32/// `--public-url` is the operator telling it. The answer decides the `Secure`
33/// attribute on cookies and the origin passkeys are bound to, and both must
34/// agree with the address in the URL bar.
35fn secure(public_url: Option<&str>) -> bool {
36 public_url.is_some_and(|u| {
37 u.split_once("://")
38 .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"))
39 })
40}
41
42/// Validate the CLI config and build the running state + router without
43/// binding the port (so tests can exercise everything up to `serve`).
44pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)> {
45 let root = cli
46 .root
47 .canonicalize()
48 .with_context(|| format!("cannot resolve root folder: {}", cli.root.display()))?;
49 if !root.is_dir() {
50 bail!("root folder is not a directory: {}", root.display());
51 }
52
53 let db = Db::open(&cli.db).await?;
54
55 let root_name = cli
56 .root_name
57 .clone()
58 .filter(|n| !n.trim().is_empty())
59 .unwrap_or_else(|| root_file_name(&root));
60 let thumbs = match &cli.cache {
61 Some(dir) => Some(Arc::new(
62 crate::thumb::Thumbs::new(dir.clone())
63 .await
64 .with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?,
65 )),
66 None => None,
67 };
68 if let Some(dir) = cli.cache.clone() {
69 tokio::spawn(crate::thumb::sweep_forever(dir));
70 }
71 tokio::spawn(crate::db::sweep_forever(db.clone()));
72
73 let state = Arc::new(AppState {
74 db,
75 root: root.clone(),
76 root_name,
77 https: secure(cli.public_url.as_deref()),
78 public_url: cli
79 .public_url
80 .as_deref()
81 .map(|u| u.trim_end_matches('/').to_string()),
82 thumbs,
83 });
84
85 let app = api::router(state).layer(TraceLayer::new_for_http());
86
87 let ip: IpAddr = cli.bind.parse().context("invalid --bind address")?;
88 let addr = SocketAddr::new(ip, cli.port);
89 Ok((app, addr))
90}
91
92/// Parse the CLI, initialize logging and serve until the process is killed.
93pub async fn run() -> anyhow::Result<()> {
94 tracing_subscriber::fmt()
95 .with_env_filter(
96 tracing_subscriber::EnvFilter::try_from_default_env()
97 .unwrap_or_else(|_| "info,tower_http=warn".into()),
98 )
99 .init();
100
101 let cli = Cli::parse();
102 let (app, addr) = build_app(&cli).await?;
103 let listener = tokio::net::TcpListener::bind(addr)
104 .await
105 .with_context(|| format!("cannot bind to {addr}"))?;
106
107 tracing::info!(root = %cli.root.display(), "filebrowser-ng starting");
108 tracing::info!(addr = %addr, "listening (pass --bind 0.0.0.0 to expose beyond localhost)");
109 axum::serve(listener, app)
110 .with_graceful_shutdown(shutdown_signal())
111 .await?;
112 Ok(())
113}
114
115/// Resolves on Ctrl-C or SIGTERM (what a container runtime sends on stop).
116/// Without this, a restart cuts in-flight uploads and archive downloads
117/// mid-stream instead of letting them finish.
118async fn shutdown_signal() {
119 let ctrl_c = async {
120 let _ = tokio::signal::ctrl_c().await;
121 };
122 #[cfg(unix)]
123 {
124 use tokio::signal::unix::{SignalKind, signal};
125 let mut term = match signal(SignalKind::terminate()) {
126 Ok(s) => s,
127 // No SIGTERM handler: Ctrl-C alone still stops the server.
128 Err(e) => {
129 tracing::warn!(error = %e, "cannot listen for SIGTERM");
130 return ctrl_c.await;
131 }
132 };
133 tokio::select! {
134 () = ctrl_c => {}
135 _ = term.recv() => {}
136 }
137 }
138 #[cfg(not(unix))]
139 ctrl_c.await;
140 tracing::info!("shutting down, waiting for in-flight requests");
141}
142
143/// The root folder's own name; "/" has none, so fall back to the full path.
144pub fn root_file_name(root: &std::path::Path) -> String {
145 root.file_name()
146 .map(|s| s.to_string_lossy().into_owned())
147 .unwrap_or_else(|| root.display().to_string())
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::cli::Cli;
154
155 fn cli(root: &std::path::Path, db: &std::path::Path) -> Cli {
156 Cli {
157 root: root.to_path_buf(),
158 db: db.to_path_buf(),
159 root_name: None,
160 port: 8080,
161 bind: "127.0.0.1".into(),
162 cache: None,
163 public_url: None,
164 }
165 }
166
167 #[test]
168 fn tls_comes_from_the_public_url_scheme() {
169 assert!(secure(Some("https://files.example.com")));
170 assert!(secure(Some("HTTPS://files.example.com")));
171 assert!(!secure(Some("http://files.example.com")));
172 assert!(!secure(Some("files.example.com")));
173 assert!(!secure(None));
174 }
175
176 #[tokio::test]
177 async fn build_app_ok() {
178 let tmp = tempfile::tempdir().unwrap();
179 std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
180 let c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
181 let (app, addr) = build_app(&c).await.unwrap();
182 let _ = app; // Router built fine
183 assert_eq!(addr.to_string(), "127.0.0.1:8080");
184 }
185
186 #[tokio::test]
187 async fn build_app_missing_root_fails() {
188 let tmp = tempfile::tempdir().unwrap();
189 let c = cli(
190 &tmp.path().join("no-such-root"),
191 &tmp.path().join("db.sqlite"),
192 );
193 assert!(build_app(&c).await.is_err());
194 }
195
196 #[tokio::test]
197 async fn build_app_root_must_be_directory() {
198 let tmp = tempfile::tempdir().unwrap();
199 let file = tmp.path().join("a-file");
200 std::fs::write(&file, "x").unwrap();
201 let c = cli(&file, &tmp.path().join("db.sqlite"));
202 assert!(build_app(&c).await.is_err());
203 }
204
205 #[tokio::test]
206 async fn build_app_rejects_bad_bind() {
207 let tmp = tempfile::tempdir().unwrap();
208 let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
209 c.bind = "not-an-ip".into();
210 assert!(build_app(&c).await.is_err());
211 }
212
213 #[tokio::test]
214 async fn build_app_custom_port_bind() {
215 let tmp = tempfile::tempdir().unwrap();
216 let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
217 c.port = 9999;
218 c.bind = "0.0.0.0".into();
219 let (_app, addr) = build_app(&c).await.unwrap();
220 assert_eq!(addr.to_string(), "0.0.0.0:9999");
221 }
222}
223