From 02e4bef7237f52e22034c7f36f3e6d7e8d70f22b Mon Sep 17 00:00:00 2001 From: lanbinshen Date: Thu, 12 Feb 2026 17:12:09 +0800 Subject: [PATCH] Add multi-directory static file serving --- actix-files/CHANGES.md | 2 + actix-files/src/files.rs | 68 +++++++++++++++++++++++++++------- actix-files/src/service.rs | 41 +++++++++++++++++---- actix-files/tests/encoding.rs | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 21 deletions(-) diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index ef0b79ae4..8dd2efcd7 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -4,8 +4,10 @@ - Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] - Fix handling of `bytes=0-` +- Add `Files::new_from_array()` and `Files::new_multiple()` methods to support multiple directories from array and iterator, and implement multi-directory static file serving with priority order [#3402] [#2615]: https://github.com/actix/actix-web/pull/2615 +[#3402]: https://github.com/actix/actix-web/issues/3402 ## 0.6.10 diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index 7440a43e7..ebb864430 100644 --- a/actix-files/src/files.rs +++ b/actix-files/src/files.rs @@ -35,9 +35,19 @@ use crate::{ /// let app = App::new() /// .service(Files::new("/static", ".")); /// ``` +/// +/// # Multiple Directories Example +/// ``` +/// use actix_web::App; +/// use actix_files::Files; +/// +/// // Serve files from multiple directories with priority order +/// let app = App::new() +/// .service(Files::new_from_array("/static", &["./dist", "./public"])); +/// ``` pub struct Files { mount_path: String, - directory: PathBuf, + directories: Vec, index: Option, show_index: bool, redirect_to_slash: bool, @@ -63,7 +73,7 @@ impl fmt::Debug for Files { impl Clone for Files { fn clone(&self) -> Self { Self { - directory: self.directory.clone(), + directories: self.directories.clone(), index: self.index.clone(), show_index: self.show_index, redirect_to_slash: self.redirect_to_slash, @@ -105,19 +115,51 @@ impl Files { /// The number of running threads is adjusted over time as needed, up to a maximum of 512 times /// the number of server [workers](actix_web::HttpServer::workers), by default. pub fn new>(mount_path: &str, serve_from: T) -> Files { - let orig_dir = serve_from.into(); - let dir = match orig_dir.canonicalize() { - Ok(canon_dir) => canon_dir, - Err(_) => { - log::error!("Specified path is not a directory: {:?}", orig_dir); - // Preserve original path so requests don't fall back to CWD. - orig_dir - } - }; + Self::new_multiple(mount_path, std::iter::once(serve_from)) + } + + /// Create new `Files` instance for specified base directories from an array. + /// + /// # Argument Order + /// The first argument (`mount_path`) is the root URL at which the static files are served. + /// For example, `/assets` will serve files at `example.com/assets/...`. + /// + /// The second argument (`serve_from`) is an array of paths on disk at which files are loaded. + /// For example, `["./dist", "./public"]` would serve files from both directories in order of priority. + pub fn new_from_array + Clone>(mount_path: &str, serve_from: &[T]) -> Files { + Self::new_multiple(mount_path, serve_from.iter().cloned()) + } + + /// Create new `Files` instance for specified base directories from an iterator. + /// + /// # Argument Order + /// The first argument (`mount_path`) is the root URL at which the static files are served. + /// For example, `/assets` will serve files at `example.com/assets/...`. + /// + /// The second argument (`serve_from`) is an iterator of paths on disk at which files are loaded. + /// For example, `vec!["./dist", "./public"].into_iter()` would serve files from both directories in order of priority. + pub fn new_multiple(mount_path: &str, serve_from: I) -> Files + where + I: IntoIterator, + T: Into, + { + let directories = serve_from + .into_iter() + .map(|path| { + let orig_dir = path.into(); + match orig_dir.canonicalize() { + Ok(canon_dir) => canon_dir, + Err(_) => { + log::error!("Specified path is not a directory: {:?}", orig_dir); + orig_dir + } + } + }) + .collect(); Files { mount_path: mount_path.trim_end_matches('/').to_owned(), - directory: dir, + directories, index: None, show_index: false, redirect_to_slash: false, @@ -403,7 +445,7 @@ impl ServiceFactory for Files { fn new_service(&self, _: ()) -> Self::Future { let mut inner = FilesServiceInner { - directory: self.directory.clone(), + directories: self.directories.clone(), index: self.index.clone(), show_index: self.show_index, redirect_to_slash: self.redirect_to_slash, diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs index ae6725385..04a84b9b8 100644 --- a/actix-files/src/service.rs +++ b/actix-files/src/service.rs @@ -33,7 +33,7 @@ impl Deref for FilesService { } pub struct FilesServiceInner { - pub(crate) directory: PathBuf, + pub(crate) directories: Vec, pub(crate) index: Option, pub(crate) show_index: bool, pub(crate) redirect_to_slash: bool, @@ -113,8 +113,11 @@ impl FilesService { self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity) } + /// Show index listing for a directory. + /// + /// Uses the directory where the path was found as the base directory for index listing. fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse { - let dir = Directory::new(self.directory.clone(), path); + let dir = Directory::new(path.clone(), path); let (req, _) = req.into_parts(); @@ -171,8 +174,34 @@ impl Service for FilesService { } } - // full file path - let path = this.directory.join(&path_on_disk); + // Try to find file in multiple directories + let mut found_path = None; + let mut last_err = None; + + for directory in &this.directories { + let path = directory.join(&path_on_disk); + match path.canonicalize() { + Ok(canonical_path) => { + found_path = Some(canonical_path); + break; + } + Err(err) => { + // Keep track of the last error + last_err = Some(err); + } + } + } + + let path = match found_path { + Some(path) => path, + None => { + // If all directories failed, use the last error + let err = last_err.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "File not found") + }); + return this.handle_err(err, req).await; + } + }; // Try serving pre-compressed file even if the uncompressed file doesn't exist yet. // Still handle directories (index/listing) through the normal branch below. @@ -182,10 +211,6 @@ impl Service for FilesService { } } - if let Err(err) = path.canonicalize() { - return this.handle_err(err, req).await; - } - if path.is_dir() { if this.redirect_to_slash && !req.path().ends_with('/') diff --git a/actix-files/tests/encoding.rs b/actix-files/tests/encoding.rs index 019abfb57..4d0138417 100644 --- a/actix-files/tests/encoding.rs +++ b/actix-files/tests/encoding.rs @@ -193,3 +193,72 @@ async fn partial_range_response_encoding() { "identity" ); } + +#[actix_web::test] +async fn test_multiple_directories() { + // Create test directories + std::fs::create_dir_all("./tests/test1").unwrap(); + std::fs::create_dir_all("./tests/test2").unwrap(); + + // Create test files + std::fs::write("./tests/test1/test.txt", "File from test1").unwrap(); + std::fs::write("./tests/test2/fallback.txt", "File from test2").unwrap(); + + // Test multiple directories with new_from_array + let srv = test::init_service(App::new().service(Files::new_from_array( + "/", + &["./tests/test1", "./tests/test2"], + ))) + .await; + + // Test file from first directory + let req = TestRequest::with_uri("/test.txt").to_request(); + let res = test::call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + let body = test::read_body(res).await; + assert_eq!(&body[..], b"File from test1"); + + // Test file from second directory + let req = TestRequest::with_uri("/fallback.txt").to_request(); + let res = test::call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + let body = test::read_body(res).await; + assert_eq!(&body[..], b"File from test2"); + + // Test non-existent file + let req = TestRequest::with_uri("/non-existent.txt").to_request(); + let res = test::call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + + // Clean up + let _ = std::fs::remove_dir_all("./tests/test1"); + let _ = std::fs::remove_dir_all("./tests/test2"); +} + +#[actix_web::test] +async fn test_multiple_directories_iterator() { + // Create test directories + std::fs::create_dir_all("./tests/test1").unwrap(); + std::fs::create_dir_all("./tests/test2").unwrap(); + + // Create test files + std::fs::write("./tests/test1/test.txt", "File from test1").unwrap(); + + // Test multiple directories with new_multiple + let srv = test::init_service(App::new().service(Files::new_multiple( + "/", + vec!["./tests/test1", "./tests/test2"], + ))) + .await; + + // Test file from first directory + let req = TestRequest::with_uri("/test.txt").to_request(); + let res = test::call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + let body = test::read_body(res).await; + assert_eq!(&body[..], b"File from test1"); + + // Clean up + let _ = std::fs::remove_dir_all("./tests/test1"); + let _ = std::fs::remove_dir_all("./tests/test2"); +}