Skip to main content

minimoon_sync_server/
server.rs

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    // Parse `path=...` from the raw query string using pure percent-decoding.
147    // We deliberately avoid form-decoding (which maps `+` → space) because
148    // filenames can legitimately contain `+` characters.
149    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    // Deprecated: retained for older clients that still request /file/<path>.
189    // New clients should download through /file-by-path?path=..., and this route
190    // will be removed in a future update.
191    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        // "a=b.mp3" encoded as "a%3Db.mp3" — we decode *after*
271        // splitting on '=', so the real '=' in the filename is preserved.
272        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        // `+` in the query value must resolve to the file named "A+B.mp3",
306        // not "A B.mp3" (which would be the form-decode interpretation).
307        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}