diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index 6f4056f57d1..93afaebfe84 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -4,6 +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 - Fix `NamedFile` panic when serving files with pre-UNIX epoch modification times. [#2748] - Fix invalid `Content-Encoding: identity` header in `NamedFile` range responses. [#3191] diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index 1c7a1d9021f..d0078464d3d 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,52 @@ 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); + // Preserve original path so requests don't fall back to CWD. + 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, @@ -407,7 +450,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 ae67253850f..547e5f0b269 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) } - fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse { - let dir = Directory::new(self.directory.clone(), path); + /// Show index listing for a directory. + /// + /// Uses the provided base directory for calculating relative paths in the index listing. + fn show_index(&self, req: ServiceRequest, base: PathBuf, path: PathBuf) -> ServiceResponse { + let dir = Directory::new(base, path); let (req, _) = req.into_parts(); @@ -171,70 +174,94 @@ impl Service for FilesService { } } - // full file path - let path = this.directory.join(&path_on_disk); + // Try to find file in multiple directories + let mut last_err = None; - // Try serving pre-compressed file even if the uncompressed file doesn't exist yet. - // Still handle directories (index/listing) through the normal branch below. - if this.try_compressed && !path.is_dir() { - if let Some((named_file, encoding)) = find_compressed(&req, &path).await { - return Ok(this.serve_named_file_with_encoding(req, named_file, encoding)); - } - } - - if let Err(err) = path.canonicalize() { - return this.handle_err(err, req).await; - } + for directory in &this.directories { + let path = directory.join(&path_on_disk); - if path.is_dir() { - if this.redirect_to_slash - && !req.path().ends_with('/') - && (this.index.is_some() || this.show_index) - { - let redirect_to = format!("{}/", req.path()); - - let response = if this.with_permanent_redirect { - HttpResponse::PermanentRedirect() - } else { - HttpResponse::TemporaryRedirect() - } - .insert_header((header::LOCATION, redirect_to)) - .finish(); - - return Ok(req.into_response(response)); - } - - match this.index { - Some(ref index) => { - let named_path = path.join(index); - if this.try_compressed { - if let Some((named_file, encoding)) = - find_compressed(&req, &named_path).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. + if this.try_compressed { + if let Ok(metadata) = path.metadata() { + if !metadata.is_dir() { + if let Some((named_file, encoding)) = find_compressed(&req, &path).await { return Ok( this.serve_named_file_with_encoding(req, named_file, encoding) ); } } - // fallback to the uncompressed version - match NamedFile::open_async(named_path).await { - Ok(named_file) => Ok(this.serve_named_file(req, named_file)), - Err(_) if this.show_index => Ok(this.show_index(req, path)), - Err(err) => this.handle_err(err, req).await, - } } - None if this.show_index => Ok(this.show_index(req, path)), - None => Ok(ServiceResponse::from_err( - FilesError::IsDirectory, - req.into_parts().0, - )), } - } else { - match NamedFile::open_async(&path).await { - Ok(named_file) => Ok(this.serve_named_file(req, named_file)), - Err(err) => this.handle_err(err, req).await, + + // Check if path is a directory + if path.is_dir() { + // Handle directory logic inline to avoid multiple iterations + if this.redirect_to_slash + && !req.path().ends_with('/') + && (this.index.is_some() || this.show_index) + { + let redirect_to = format!("{}/", req.path()); + + let response = if this.with_permanent_redirect { + HttpResponse::PermanentRedirect() + } else { + HttpResponse::TemporaryRedirect() + } + .insert_header((header::LOCATION, redirect_to)) + .finish(); + + return Ok(req.into_response(response)); + } + + match this.index { + Some(ref index) => { + let named_path = path.join(index); + if this.try_compressed { + if let Some((named_file, encoding)) = + find_compressed(&req, &named_path).await + { + return Ok(this.serve_named_file_with_encoding( + req, named_file, encoding, + )); + } + } + // fallback to the uncompressed version + match NamedFile::open_async(named_path).await { + Ok(named_file) => return Ok(this.serve_named_file(req, named_file)), + Err(_) if this.show_index => { + return Ok(this.show_index(req, directory.clone(), path)) + } + Err(err) => last_err = Some(err), + } + } + None => { + // No index file configured, check if we should show directory listing + if this.show_index { + return Ok(this.show_index(req, directory.clone(), path)); + } + return Ok(ServiceResponse::from_err( + FilesError::IsDirectory, + req.into_parts().0, + )); + } + } + } else { + // Try to open the file + match NamedFile::open_async(&path).await { + Ok(named_file) => return Ok(this.serve_named_file(req, named_file)), + Err(err) => { + last_err = Some(err); + } + } } } + + // 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; }) } } diff --git a/actix-files/tests/encoding.rs b/actix-files/tests/encoding.rs index 72e72b9132a..1310e1d692b 100644 --- a/actix-files/tests/encoding.rs +++ b/actix-files/tests/encoding.rs @@ -190,3 +190,183 @@ async fn partial_range_response_encoding() { assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT); assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); } + +#[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/test3").unwrap(); + std::fs::create_dir_all("./tests/test4").unwrap(); + + // Create test files + std::fs::write("./tests/test3/test.txt", "File from test3").unwrap(); + std::fs::write("./tests/test4/fallback.txt", "File from test4").unwrap(); + + // Test multiple directories with new_multiple + let srv = test::init_service(App::new().service(Files::new_multiple( + "/", + vec!["./tests/test3", "./tests/test4"], + ))) + .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 test3"); + + // 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 test4"); + + // 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/test3"); + let _ = std::fs::remove_dir_all("./tests/test4"); +} + +#[actix_web::test] +async fn test_multiple_directories_with_index_file() { + // Create test directories + std::fs::create_dir_all("./tests/test_index1").unwrap(); + std::fs::create_dir_all("./tests/test_index2").unwrap(); + + // Create test files - only second directory has index.html + std::fs::write("./tests/test_index1/other.txt", "Other file").unwrap(); + std::fs::write( + "./tests/test_index2/index.html", + "Index from test2", + ) + .unwrap(); + + // Test multiple directories with index_file - index.html only exists in second directory + let srv = test::init_service( + App::new().service( + Files::new_from_array("/", &["./tests/test_index1", "./tests/test_index2"]) + .index_file("index.html"), + ), + ) + .await; + + // Request / should find index.html in second directory + let req = TestRequest::with_uri("/").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"Index from test2"); + + // Clean up + let _ = std::fs::remove_dir_all("./tests/test_index1"); + let _ = std::fs::remove_dir_all("./tests/test_index2"); +} + +#[actix_web::test] +async fn test_multiple_directories_try_compressed() { + use actix_web::body::MessageBody; + + // Create test directories + std::fs::create_dir_all("./tests/test_compress1").unwrap(); + std::fs::create_dir_all("./tests/test_compress2").unwrap(); + + // Create test files: + // - First directory has only uncompressed file + // - Second directory has both uncompressed and compressed files + std::fs::copy("./tests/utf8.txt", "./tests/test_compress1/utf8.txt").unwrap(); + std::fs::copy("./tests/utf8.txt", "./tests/test_compress2/other.txt").unwrap(); + std::fs::copy("./tests/utf8.txt.gz", "./tests/test_compress2/other.txt.gz").unwrap(); + + let other_txt_gz_len = std::fs::metadata("./tests/test_compress2/other.txt.gz") + .unwrap() + .len(); + + // Test multiple directories with try_compressed + let srv = test::init_service( + App::new().service( + Files::new_from_array("/", &["./tests/test_compress1", "./tests/test_compress2"]) + .try_compressed(), + ), + ) + .await; + + // Request /utf8.txt - first directory has it uncompressed, should return uncompressed + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + // First directory has utf8.txt but no .gz version, so no content-encoding + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None); + + // Request /other.txt - second directory has both, should return compressed + let mut req = TestRequest::with_uri("/other.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("gzip")), + ); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(other_txt_gz_len), + ); + + // Clean up + let _ = std::fs::remove_dir_all("./tests/test_compress1"); + let _ = std::fs::remove_dir_all("./tests/test_compress2"); +}