1use crate::data::{
2 iter_files_with_options, resolve_syncable_file_path_with_options, FileAccessOptions,
3};
4use crate::network;
5use anyhow::Result;
6use axum::extract::RawQuery;
7use axum::http::{header, Method, StatusCode};
8use axum::{response::IntoResponse, routing::get, Json, Router};
9use percent_encoding::percent_decode_str;
10use std::net::SocketAddr;
11use std::path::PathBuf;
12use std::time::Duration;
13use tokio::sync::oneshot::{Receiver, Sender};
14use tower_http::cors::{Any, CorsLayer};
15use tower_http::{services::ServeDir, trace::TraceLayer};
16use tracing::debug;
17
18#[derive(Clone, Debug)]
19pub struct ServerConfig {
20 pub root_dir: PathBuf,
21 pub selected_roots: Vec<String>,
22 pub port: u16,
23 pub allow_top_level_directory_symlinks: bool,
24}
25
26impl ServerConfig {
27 pub fn new(root_dir: impl Into<PathBuf>) -> Self {
28 Self {
29 root_dir: root_dir.into(),
30 selected_roots: Vec::new(),
31 port: network::listen_port(),
32 allow_top_level_directory_symlinks: false,
33 }
34 }
35
36 pub fn with_selected_roots(mut self, selected_roots: Vec<String>) -> Self {
37 self.selected_roots = selected_roots;
38 self
39 }
40
41 pub fn with_port(mut self, port: u16) -> Self {
42 self.port = port;
43 self
44 }
45
46 pub fn with_allow_top_level_directory_symlinks(mut self, allow: bool) -> Self {
47 self.allow_top_level_directory_symlinks = allow;
48 self
49 }
50
51 fn file_access_options(&self) -> FileAccessOptions {
52 FileAccessOptions {
53 allow_top_level_directory_symlinks: self.allow_top_level_directory_symlinks,
54 }
55 }
56}
57
58async fn shutdown_signal(signal: Receiver<bool>) {
59 tokio::select! {
60 _ = signal => {},
61 }
62}
63
64pub async fn run_server(
65 config: ServerConfig,
66 shutdown: Receiver<bool>,
67 on_ready: Option<Sender<Result<SocketAddr, String>>>,
68) -> Result<()> {
69 if config.root_dir.as_os_str().is_empty() {
70 return Ok(());
71 }
72
73 let cors = CorsLayer::new()
74 .allow_methods([Method::GET])
75 .allow_origin(Any);
76
77 let app = build_router(config.clone()).layer(cors);
78
79 let addr = match network::preferred_bind_addr_for_port(config.port) {
80 Ok(addr) => addr,
81 Err(e) => {
82 let message =
83 "Private LAN sharing is unavailable. Connect to Wi-Fi or Ethernet on your local network.".to_string();
84 eprintln!("Failed to determine private LAN bind address: {e}");
85 if let Some(tx) = on_ready {
86 let _ = tx.send(Err(message));
87 }
88 return Ok(());
89 }
90 };
91
92 let listener = loop {
93 match tokio::net::TcpListener::bind(addr).await {
94 Ok(listener) => break listener,
95 Err(e) => {
96 if e.kind() == std::io::ErrorKind::AddrInUse {
97 eprintln!(
98 "Port {} on {} in use, retrying in 100ms...",
99 config.port,
100 addr.ip()
101 );
102 tokio::time::sleep(Duration::from_millis(100)).await;
103 continue;
104 }
105 eprintln!("Failed to bind to {addr}: {e}");
106 if let Some(tx) = on_ready {
107 let _ = tx.send(Err(format!(
108 "Could not start local sharing on {}:{}",
109 addr.ip(),
110 config.port
111 )));
112 }
113 return Ok(());
114 }
115 }
116 };
117
118 let bound_addr = listener.local_addr()?;
119 if let Some(tx) = on_ready {
120 let _ = tx.send(Ok(bound_addr));
121 }
122
123 axum::serve(listener, app.layer(TraceLayer::new_for_http()))
124 .with_graceful_shutdown(shutdown_signal(shutdown))
125 .await?;
126
127 Ok(())
128}
129
130async fn list_files_handler(
131 dir_path: PathBuf,
132 selected_paths: Vec<String>,
133 file_access_options: FileAccessOptions,
134) -> impl IntoResponse {
135 let files = iter_files_with_options(dir_path, file_access_options)
136 .expect("can't list files")
137 .map(|file| file.expect("can't read file metadata"))
138 .filter(|f| {
139 selected_paths.is_empty() || selected_paths.iter().any(|p| f.path.starts_with(p))
140 })
141 .collect::<Vec<_>>();
142 Json(files)
143}
144
145fn extract_path_param(raw_query: Option<&str>) -> Option<String> {
146 raw_query?.split('&').find_map(|kv| {
150 let value = kv.strip_prefix("path=")?;
151 Some(percent_decode_str(value).decode_utf8_lossy().into_owned())
152 })
153}
154
155async fn download_file_query_handler(
156 dir_path: PathBuf,
157 file_access_options: FileAccessOptions,
158 RawQuery(raw_query): RawQuery,
159) -> impl IntoResponse {
160 let path = match extract_path_param(raw_query.as_deref()) {
161 Some(p) => p,
162 None => return StatusCode::BAD_REQUEST.into_response(),
163 };
164
165 debug!("download request for path: {:?}", path);
166 let resolved_path =
167 match resolve_syncable_file_path_with_options(&dir_path, &path, file_access_options) {
168 Ok(path) => path,
169 Err(e) => {
170 debug!("download rejected for {:?}: {}", path, e);
171 return StatusCode::NOT_FOUND.into_response();
172 }
173 };
174
175 match tokio::fs::read(&resolved_path).await {
176 Ok(data) => {
177 debug!("serving {} bytes from {:?}", data.len(), resolved_path);
178 ([(header::CONTENT_TYPE, "application/octet-stream")], data).into_response()
179 }
180 Err(e) => {
181 debug!("failed to read file {:?}: {}", resolved_path, e);
182 StatusCode::NOT_FOUND.into_response()
183 }
184 }
185}
186
187pub fn build_router(config: ServerConfig) -> Router {
188 let deprecated_file_service = ServeDir::new(config.root_dir.clone());
192
193 let file_access_options = config.file_access_options();
194 let files_dir = config.root_dir.clone();
195 let files_selected_paths = config.selected_roots.clone();
196 let query_dir = config.root_dir;
197
198 Router::new()
199 .nest_service("/file", deprecated_file_service)
200 .route(
201 "/file-by-path",
202 get(move |query| {
203 download_file_query_handler(query_dir.clone(), file_access_options, query)
204 }),
205 )
206 .route(
207 "/files",
208 get(move || {
209 list_files_handler(
210 files_dir.clone(),
211 files_selected_paths.clone(),
212 file_access_options,
213 )
214 }),
215 )
216}
217
218#[cfg(test)]
219mod tests {
220 use super::{build_router, extract_path_param, run_server, ServerConfig};
221 use axum::body::{to_bytes, Body};
222 use axum::http::Request;
223 use std::fs;
224 use std::time::{SystemTime, UNIX_EPOCH};
225 use tokio::sync::oneshot;
226 use tower::util::ServiceExt;
227
228 fn temp_test_dir(prefix: &str) -> std::path::PathBuf {
229 let unique = SystemTime::now()
230 .duration_since(UNIX_EPOCH)
231 .unwrap()
232 .as_nanos();
233 let path = std::env::temp_dir().join(format!("minimoon-sync-server-{prefix}-{unique}"));
234 fs::create_dir_all(&path).unwrap();
235 path
236 }
237
238 #[test]
239 fn extract_path_param_treats_plus_as_literal() {
240 assert_eq!(
241 extract_path_param(Some("path=A+B.mp3")),
242 Some("A+B.mp3".to_string())
243 );
244 }
245
246 #[test]
247 fn extract_path_param_percent_decodes_spaces() {
248 assert_eq!(
249 extract_path_param(Some("path=A%20B.mp3")),
250 Some("A B.mp3".to_string())
251 );
252 }
253
254 #[test]
255 fn extract_path_param_handles_missing_path_key() {
256 assert_eq!(extract_path_param(Some("other=foo")), None);
257 assert_eq!(extract_path_param(None), None);
258 }
259
260 #[test]
261 fn extract_path_param_finds_path_key_not_first() {
262 assert_eq!(
263 extract_path_param(Some("other=foo&path=test.mp3")),
264 Some("test.mp3".to_string())
265 );
266 }
267
268 #[test]
269 fn extract_path_param_percent_decodes_equals_in_filename() {
270 assert_eq!(
273 extract_path_param(Some("path=a%3Db.mp3")),
274 Some("a=b.mp3".to_string())
275 );
276 }
277
278 #[tokio::test]
279 async fn file_by_path_returns_file_contents_for_encoded_query_paths() {
280 let root = temp_test_dir("download");
281 let nested_dir = root.join("Albums");
282 fs::create_dir_all(&nested_dir).unwrap();
283 let file_path = nested_dir.join("track #1?.opus");
284 fs::write(&file_path, "hello").unwrap();
285
286 let app = build_router(ServerConfig::new(&root));
287 let request: Request<Body> = Request::builder()
288 .uri("/file-by-path?path=Albums%2Ftrack%20%231%3F.opus")
289 .body(Body::empty())
290 .unwrap();
291 let response = app.oneshot(request).await.unwrap();
292
293 assert_eq!(response.status(), 200);
294 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
295 assert_eq!(body.as_ref(), b"hello");
296 let _ = fs::remove_dir_all(root);
297 }
298
299 #[tokio::test]
300 async fn file_by_path_treats_plus_as_literal_not_space() {
301 let root = temp_test_dir("plus");
302 fs::write(root.join("A+B.mp3"), "hello").unwrap();
303
304 let app = build_router(ServerConfig::new(&root));
305 let request: Request<Body> = Request::builder()
308 .uri("/file-by-path?path=A+B.mp3")
309 .body(Body::empty())
310 .unwrap();
311 let response = app.oneshot(request).await.unwrap();
312
313 assert_eq!(response.status(), 200);
314 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
315 assert_eq!(body.as_ref(), b"hello");
316 let _ = fs::remove_dir_all(root);
317 }
318
319 #[tokio::test]
320 async fn file_by_path_rejects_parent_directory_traversal() {
321 let root = temp_test_dir("traversal");
322 let app = build_router(ServerConfig::new(&root));
323 let request: Request<Body> = Request::builder()
324 .uri("/file-by-path?path=..%2Fsecret.opus")
325 .body(Body::empty())
326 .unwrap();
327 let response = app.oneshot(request).await.unwrap();
328
329 assert_eq!(response.status(), 404);
330 let _ = fs::remove_dir_all(root);
331 }
332
333 #[tokio::test]
334 async fn file_by_path_rejects_empty_path_value() {
335 let root = temp_test_dir("empty-path");
336 fs::write(root.join("track.mp3"), "hello").unwrap();
337
338 let app = build_router(ServerConfig::new(&root));
339 let request: Request<Body> = Request::builder()
340 .uri("/file-by-path?path=")
341 .body(Body::empty())
342 .unwrap();
343 let response = app.oneshot(request).await.unwrap();
344
345 assert_eq!(response.status(), 404);
346 let _ = fs::remove_dir_all(root);
347 }
348
349 #[cfg(unix)]
350 #[tokio::test]
351 async fn file_by_path_rejects_top_level_directory_symlinks_by_default() {
352 use std::os::unix::fs::symlink;
353
354 let root = temp_test_dir("symlink-default");
355 let outside = temp_test_dir("symlink-default-outside");
356 fs::write(outside.join("track.mp3"), "hello").unwrap();
357 symlink(&outside, root.join("Artist")).unwrap();
358
359 let app = build_router(ServerConfig::new(&root));
360 let request: Request<Body> = Request::builder()
361 .uri("/file-by-path?path=Artist%2Ftrack.mp3")
362 .body(Body::empty())
363 .unwrap();
364 let response = app.oneshot(request).await.unwrap();
365
366 assert_eq!(response.status(), 404);
367 let _ = fs::remove_dir_all(root);
368 let _ = fs::remove_dir_all(outside);
369 }
370
371 #[cfg(unix)]
372 #[tokio::test]
373 async fn file_by_path_allows_top_level_directory_symlinks_when_enabled() {
374 use std::os::unix::fs::symlink;
375
376 let root = temp_test_dir("symlink-enabled");
377 let outside = temp_test_dir("symlink-enabled-outside");
378 fs::write(outside.join("track.mp3"), "hello").unwrap();
379 symlink(&outside, root.join("Artist")).unwrap();
380
381 let app =
382 build_router(ServerConfig::new(&root).with_allow_top_level_directory_symlinks(true));
383 let request: Request<Body> = Request::builder()
384 .uri("/file-by-path?path=Artist%2Ftrack.mp3")
385 .body(Body::empty())
386 .unwrap();
387 let response = app.oneshot(request).await.unwrap();
388
389 assert_eq!(response.status(), 200);
390 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
391 assert_eq!(body.as_ref(), b"hello");
392 let _ = fs::remove_dir_all(root);
393 let _ = fs::remove_dir_all(outside);
394 }
395
396 #[tokio::test]
397 async fn run_server_serves_files_on_ready_bound_address() {
398 let root = temp_test_dir("integration");
399 fs::write(root.join("track.mp3"), "hello").unwrap();
400
401 let (shutdown_tx, shutdown_rx) = oneshot::channel();
402 let (ready_tx, ready_rx) = oneshot::channel();
403 let task = tokio::spawn(run_server(
404 ServerConfig::new(&root).with_port(0),
405 shutdown_rx,
406 Some(ready_tx),
407 ));
408
409 let addr = ready_rx.await.unwrap().unwrap();
410 let response: Vec<crate::FileInfo> = reqwest::get(format!("http://{addr}/files"))
411 .await
412 .unwrap()
413 .json()
414 .await
415 .unwrap();
416 assert_eq!(response.len(), 1);
417 assert_eq!(response[0].path, "track.mp3");
418
419 let body = reqwest::get(format!("http://{addr}/file-by-path?path=track.mp3"))
420 .await
421 .unwrap()
422 .bytes()
423 .await
424 .unwrap();
425 assert_eq!(body.as_ref(), b"hello");
426
427 let _ = shutdown_tx.send(true);
428 task.await.unwrap().unwrap();
429 let _ = fs::remove_dir_all(root);
430 }
431}