diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index 6f4056f57d1..43a7f20f862 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -2,11 +2,13 @@ ## Unreleased +- Add support for passing multiple root directories to `Files::new`. [#3402] - Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] - Fix handling of `bytes=0-` - Fix `NamedFile` panic when serving files with pre-UNIX epoch modification times. [#2748] - Fix invalid `Content-Encoding: identity` header in `NamedFile` range responses. [#3191] +[#3402]: https://github.com/actix/actix-web/issues/3402 [#2615]: https://github.com/actix/actix-web/pull/2615 [#2748]: https://github.com/actix/actix-web/issues/2748 [#3191]: https://github.com/actix/actix-web/issues/3191 diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index 1c7a1d9021f..8145d614a72 100644 --- a/actix-files/src/files.rs +++ b/actix-files/src/files.rs @@ -1,5 +1,7 @@ use std::{ + borrow::Cow, cell::RefCell, + ffi::{OsStr, OsString}, fmt, io, path::{Path, PathBuf}, rc::Rc, @@ -37,7 +39,7 @@ use crate::{ /// ``` pub struct Files { mount_path: String, - directory: PathBuf, + directories: Vec, index: Option, show_index: bool, redirect_to_slash: bool, @@ -63,7 +65,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, @@ -83,6 +85,131 @@ impl Clone for Files { } } +/// File serving root directories for [`Files`]. +/// +/// This type is used by [`Files::new`] to accept either one root directory or an ordered +/// collection of root directories. +#[derive(Debug)] +pub struct FilesDirs(Vec); + +impl FilesDirs { + fn canonicalize(self) -> Vec { + self.0 + .into_iter() + .map(|orig_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 + } + }) + .collect() + } +} + +impl From<&Path> for FilesDirs { + fn from(dir: &Path) -> Self { + Self(vec![dir.into()]) + } +} + +impl From<&PathBuf> for FilesDirs { + fn from(dir: &PathBuf) -> Self { + Self(vec![dir.into()]) + } +} + +impl From for FilesDirs { + fn from(dir: PathBuf) -> Self { + Self(vec![dir]) + } +} + +impl From<&str> for FilesDirs { + fn from(dir: &str) -> Self { + Self(vec![dir.into()]) + } +} + +impl From<&String> for FilesDirs { + fn from(dir: &String) -> Self { + Self(vec![dir.into()]) + } +} + +impl From for FilesDirs { + fn from(dir: String) -> Self { + Self(vec![dir.into()]) + } +} + +impl From<&OsStr> for FilesDirs { + fn from(dir: &OsStr) -> Self { + Self(vec![dir.into()]) + } +} + +impl From for FilesDirs { + fn from(dir: OsString) -> Self { + Self(vec![dir.into()]) + } +} + +impl From<&OsString> for FilesDirs { + fn from(dir: &OsString) -> Self { + Self(vec![dir.into()]) + } +} + +impl From> for FilesDirs { + fn from(dir: Box) -> Self { + Self(vec![dir.into()]) + } +} + +impl From> for FilesDirs { + fn from(dir: Cow<'_, Path>) -> Self { + Self(vec![dir.into()]) + } +} + +impl From<[P; N]> for FilesDirs +where + P: Into, +{ + fn from(dirs: [P; N]) -> Self { + Self(dirs.into_iter().map(Into::into).collect()) + } +} + +impl From<&[P; N]> for FilesDirs +where + P: Clone + Into, +{ + fn from(dirs: &[P; N]) -> Self { + Self(dirs.iter().cloned().map(Into::into).collect()) + } +} + +impl

From<&[P]> for FilesDirs +where + P: Clone + Into, +{ + fn from(dirs: &[P]) -> Self { + Self(dirs.iter().cloned().map(Into::into).collect()) + } +} + +impl

From> for FilesDirs +where + P: Into, +{ + fn from(dirs: Vec

) -> Self { + Self(dirs.into_iter().map(Into::into).collect()) + } +} + impl Files { /// Create new `Files` instance for a specified base directory. /// @@ -90,34 +217,34 @@ impl Files { /// 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 the location on disk at which files are loaded. - /// This can be a relative path. For example, `./` would serve files from the current - /// working directory. + /// The second argument (`serve_from`) is the location on disk that files are served from. This + /// can be a single path or an ordered collection of paths. Relative paths are resolved from the + /// current working directory. + /// + /// When multiple directories are provided, they are checked in order. The first directory that + /// can serve the requested path is used. + /// + /// Directory listings are generated from the first matching directory and are not merged across + /// roots. When [`Files::index_file()`] is configured, later roots are searched if an earlier + /// matching directory does not contain the index file. + /// + /// Empty root collections never match files; requests fall through to the default handler, or + /// return `404 Not Found` if none is configured. /// /// # Implementation Notes /// If the mount path is set as the root path `/`, services registered after this one will /// be inaccessible. Register more specific handlers and services first. /// - /// If `serve_from` cannot be canonicalized at startup, an error is logged and the original - /// path is preserved. Requests will return `404 Not Found` until the path exists. + /// If a `serve_from` path cannot be canonicalized at startup, an error is logged and the + /// original path is preserved. Requests will return `404 Not Found` until the path exists. /// /// `Files` utilizes the existing Tokio thread-pool for blocking filesystem operations. /// 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 - } - }; - + pub fn new>(mount_path: &str, serve_from: T) -> Files { Files { mount_path: mount_path.trim_end_matches('/').to_owned(), - directory: dir, + directories: serve_from.into().canonicalize(), index: None, show_index: false, redirect_to_slash: false, @@ -149,6 +276,9 @@ impl Files { /// Redirects to a slash-ended path when browsing a directory. /// /// By default never redirect. + /// + /// When multiple root directories are configured, a matching directory in an earlier root can + /// trigger a redirect before later roots are checked for a file at the same path. pub fn redirect_to_slash_directory(mut self) -> Self { self.redirect_to_slash = true; self @@ -407,7 +537,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/lib.rs b/actix-files/src/lib.rs index bf5397ecf97..3312a621df5 100644 --- a/actix-files/src/lib.rs +++ b/actix-files/src/lib.rs @@ -37,8 +37,14 @@ mod range; mod service; pub use self::{ - chunked::ChunkedReadFile, directory::Directory, error::UriSegmentError, files::Files, - named::NamedFile, path_buf::PathBufWrap, range::HttpRange, service::FilesService, + chunked::ChunkedReadFile, + directory::Directory, + error::UriSegmentError, + files::{Files, FilesDirs}, + named::NamedFile, + path_buf::PathBufWrap, + range::HttpRange, + service::FilesService, }; use self::{ directory::{directory_listing, DirectoryRenderer}, @@ -63,9 +69,11 @@ type PathFilter = dyn Fn(&Path, &RequestHead) -> bool; #[cfg(test)] mod tests { use std::{ + ffi::OsString, fmt::Write as _, fs::{self}, ops::Add, + path::PathBuf, time::{Duration, SystemTime}, }; @@ -832,6 +840,243 @@ mod tests { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + #[actix_rt::test] + async fn test_static_files_accepts_borrowed_os_string_directory() { + let dir = OsString::from("."); + let service = Files::new("/", &dir).new_service(()).await.unwrap(); + + let req = TestRequest::with_uri("/Cargo.toml").to_srv_request(); + let resp = test::call_service(&service, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + } + + #[actix_rt::test] + async fn test_static_files_empty_directories() { + let service = Files::new("/", Vec::::new()) + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/Cargo.toml").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let service = Files::new("/", Vec::::new()) + .default_handler(|req: ServiceRequest| async { + Ok(req.into_response(HttpResponse::Ok().body("default content"))) + }) + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/Cargo.toml").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + test::read_body(resp).await, + Bytes::from_static(b"default content") + ); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::write(first_dir.path().join("shared.txt"), "first").unwrap(); + fs::write(second_dir.path().join("shared.txt"), "second").unwrap(); + fs::write(second_dir.path().join("fallback.txt"), "fallback").unwrap(); + + let service = Files::new("/", [first_dir.path(), second_dir.path()]) + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/shared.txt").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(test::read_body(resp).await, Bytes::from_static(b"first")); + + let req = TestRequest::with_uri("/fallback.txt").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback")); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories_file_as_parent_falls_back() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::write(first_dir.path().join("assets"), "file").unwrap(); + fs::create_dir(second_dir.path().join("assets")).unwrap(); + fs::write( + second_dir.path().join("assets").join("fallback.txt"), + "fallback", + ) + .unwrap(); + + let service = Files::new("/", [first_dir.path(), second_dir.path()]) + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/assets/fallback.txt").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback")); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories_default_handler() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::write(second_dir.path().join("fallback.txt"), "fallback").unwrap(); + + let service = Files::new("/", vec![first_dir.path(), second_dir.path()]) + .default_handler(|req: ServiceRequest| async { + Ok(req.into_response(HttpResponse::Ok().body("default content"))) + }) + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/fallback.txt").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback")); + + let req = TestRequest::with_uri("/missing.txt").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + test::read_body(resp).await, + Bytes::from_static(b"default content") + ); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories_index_file() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::create_dir(first_dir.path().join("nested")).unwrap(); + fs::create_dir(second_dir.path().join("nested")).unwrap(); + fs::write( + second_dir.path().join("nested").join("index.html"), + "second index", + ) + .unwrap(); + + let service = Files::new("/", [first_dir.path(), second_dir.path()]) + .index_file("index.html") + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/nested/").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + test::read_body(resp).await, + Bytes::from_static(b"second index") + ); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories_index_file_as_parent_falls_back() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::create_dir(first_dir.path().join("nested")).unwrap(); + fs::write(first_dir.path().join("nested").join("index.html"), "file").unwrap(); + fs::create_dir(second_dir.path().join("nested")).unwrap(); + fs::create_dir(second_dir.path().join("nested").join("index.html")).unwrap(); + fs::write( + second_dir + .path() + .join("nested") + .join("index.html") + .join("fallback.txt"), + "fallback", + ) + .unwrap(); + + let service = Files::new("/", [first_dir.path(), second_dir.path()]) + .index_file("index.html/fallback.txt") + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/nested/").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(test::read_body(resp).await, Bytes::from_static(b"fallback")); + } + + #[actix_rt::test] + async fn test_static_files_index_file_error_falls_back_to_listing() { + let dir = tempfile::tempdir().unwrap(); + + fs::write(dir.path().join("listed.txt"), "listed").unwrap(); + + let service = Files::new("/", dir.path()) + .index_file("index.html\0") + .show_files_listing() + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let bytes = test::read_body(resp).await; + assert!(format!("{bytes:?}").contains("listed.txt")); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories_show_files_listing() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::write(first_dir.path().join("listed.txt"), "listed").unwrap(); + + let service = Files::new("/", [first_dir.path(), second_dir.path()]) + .show_files_listing() + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let bytes = test::read_body(resp).await; + assert!(format!("{bytes:?}").contains("listed.txt")); + } + + #[actix_rt::test] + async fn test_static_files_multiple_directories_redirect_precedence() { + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + fs::create_dir(first_dir.path().join("item")).unwrap(); + fs::write(second_dir.path().join("item"), "file").unwrap(); + + let service = Files::new("/", [first_dir.path(), second_dir.path()]) + .show_files_listing() + .redirect_to_slash_directory() + .new_service(()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/item").to_srv_request(); + let resp = test::call_service(&service, req).await; + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/item/"); + } + #[actix_rt::test] async fn test_default_handler_file_missing() { let st = Files::new("/", ".") diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs index ae67253850f..0dccee5103f 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,8 @@ 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); + fn show_index(&self, req: ServiceRequest, base: PathBuf, path: PathBuf) -> ServiceResponse { + let dir = Directory::new(base, path); let (req, _) = req.into_parts(); @@ -171,70 +171,124 @@ impl Service for FilesService { } } - // full file path - let path = this.directory.join(&path_on_disk); + let mut last_miss = None; + let mut first_index_listing = None; + let mut found_unrenderable_dir = false; - // 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 { + // full file path + 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()); + // 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)); + } + } - let response = if this.with_permanent_redirect { - HttpResponse::PermanentRedirect() - } else { - HttpResponse::TemporaryRedirect() + if let Err(err) = path.canonicalize() { + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) { + last_miss = Some(err); + continue; } - .insert_header((header::LOCATION, redirect_to)) - .finish(); - return Ok(req.into_response(response)); + return this.handle_err(err, req).await; } - 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) - ); + 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(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(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + if this.show_index && first_index_listing.is_none() { + first_index_listing = + Some((directory.to_path_buf(), path.clone())); + } + last_miss = Some(err); + } + Err(_) if this.show_index => { + if first_index_listing.is_none() { + first_index_listing = + Some((directory.to_path_buf(), path.clone())); + } + break; + } + Err(err) => return this.handle_err(err, req).await, } } - // 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 => { + return Ok(this.show_index(req, directory.to_path_buf(), path)); } + None => found_unrenderable_dir = true, + } + } else { + match NamedFile::open_async(&path).await { + Ok(named_file) => return Ok(this.serve_named_file(req, named_file)), + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + last_miss = Some(err); + } + Err(err) => return 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, } } + + if let Some((base, path)) = first_index_listing { + return Ok(this.show_index(req, base, path)); + } + + if found_unrenderable_dir { + return Ok(ServiceResponse::from_err( + FilesError::IsDirectory, + req.into_parts().0, + )); + } + + let err = last_miss + .unwrap_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No such file")); + this.handle_err(err, req).await }) } } diff --git a/actix-files/tests/encoding.rs b/actix-files/tests/encoding.rs index 72e72b9132a..764648a8465 100644 --- a/actix-files/tests/encoding.rs +++ b/actix-files/tests/encoding.rs @@ -166,6 +166,44 @@ async fn test_compression_encodings() { assert_eq!(res.headers().get(header::CONTENT_ENCODING), None); } +#[actix_web::test] +async fn test_compression_encodings_multiple_directories() { + use actix_web::body::MessageBody; + + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + + let compressed_path = second_dir.path().join("fallback.txt.gz"); + std::fs::write(&compressed_path, b"compressed").unwrap(); + let compressed_len = std::fs::metadata(compressed_path).unwrap().len(); + + let srv = test::init_service( + App::new().service(Files::new("/", [first_dir.path(), second_dir.path()]).try_compressed()), + ) + .await; + + let mut req = TestRequest::with_uri("/fallback.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_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("gzip")), + ); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(compressed_len), + ); +} + #[actix_web::test] async fn partial_range_response_encoding() { let srv = test::init_service(App::new().default_service(web::to(|| async {