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