Add multi-directory static file serving

This commit is contained in:
lanbinshen 2026-02-12 17:12:09 +08:00
parent d66f89b7b6
commit 02e4bef723
4 changed files with 159 additions and 21 deletions

View File

@ -4,8 +4,10 @@
- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] - Add `Files::try_compressed()` to support serving pre-compressed static files [#2615]
- Fix handling of `bytes=0-` - 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 [#2615]: https://github.com/actix/actix-web/pull/2615
[#3402]: https://github.com/actix/actix-web/issues/3402
## 0.6.10 ## 0.6.10

View File

@ -35,9 +35,19 @@ use crate::{
/// let app = App::new() /// let app = App::new()
/// .service(Files::new("/static", ".")); /// .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 { pub struct Files {
mount_path: String, mount_path: String,
directory: PathBuf, directories: Vec<PathBuf>,
index: Option<String>, index: Option<String>,
show_index: bool, show_index: bool,
redirect_to_slash: bool, redirect_to_slash: bool,
@ -63,7 +73,7 @@ impl fmt::Debug for Files {
impl Clone for Files { impl Clone for Files {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
directory: self.directory.clone(), directories: self.directories.clone(),
index: self.index.clone(), index: self.index.clone(),
show_index: self.show_index, show_index: self.show_index,
redirect_to_slash: self.redirect_to_slash, 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 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. /// the number of server [workers](actix_web::HttpServer::workers), by default.
pub fn new<T: Into<PathBuf>>(mount_path: &str, serve_from: T) -> Files { pub fn new<T: Into<PathBuf>>(mount_path: &str, serve_from: T) -> Files {
let orig_dir = serve_from.into(); Self::new_multiple(mount_path, std::iter::once(serve_from))
let dir = match orig_dir.canonicalize() { }
Ok(canon_dir) => canon_dir,
Err(_) => { /// Create new `Files` instance for specified base directories from an array.
log::error!("Specified path is not a directory: {:?}", orig_dir); ///
// Preserve original path so requests don't fall back to CWD. /// # Argument Order
orig_dir /// 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<T: Into<PathBuf> + 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<I, T>(mount_path: &str, serve_from: I) -> Files
where
I: IntoIterator<Item = T>,
T: Into<PathBuf>,
{
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 { Files {
mount_path: mount_path.trim_end_matches('/').to_owned(), mount_path: mount_path.trim_end_matches('/').to_owned(),
directory: dir, directories,
index: None, index: None,
show_index: false, show_index: false,
redirect_to_slash: false, redirect_to_slash: false,
@ -403,7 +445,7 @@ impl ServiceFactory<ServiceRequest> for Files {
fn new_service(&self, _: ()) -> Self::Future { fn new_service(&self, _: ()) -> Self::Future {
let mut inner = FilesServiceInner { let mut inner = FilesServiceInner {
directory: self.directory.clone(), directories: self.directories.clone(),
index: self.index.clone(), index: self.index.clone(),
show_index: self.show_index, show_index: self.show_index,
redirect_to_slash: self.redirect_to_slash, redirect_to_slash: self.redirect_to_slash,

View File

@ -33,7 +33,7 @@ impl Deref for FilesService {
} }
pub struct FilesServiceInner { pub struct FilesServiceInner {
pub(crate) directory: PathBuf, pub(crate) directories: Vec<PathBuf>,
pub(crate) index: Option<String>, pub(crate) index: Option<String>,
pub(crate) show_index: bool, pub(crate) show_index: bool,
pub(crate) redirect_to_slash: 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) 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 { 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(); let (req, _) = req.into_parts();
@ -171,8 +174,34 @@ impl Service<ServiceRequest> for FilesService {
} }
} }
// full file path // Try to find file in multiple directories
let path = this.directory.join(&path_on_disk); 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. // Try serving pre-compressed file even if the uncompressed file doesn't exist yet.
// Still handle directories (index/listing) through the normal branch below. // Still handle directories (index/listing) through the normal branch below.
@ -182,10 +211,6 @@ impl Service<ServiceRequest> for FilesService {
} }
} }
if let Err(err) = path.canonicalize() {
return this.handle_err(err, req).await;
}
if path.is_dir() { if path.is_dir() {
if this.redirect_to_slash if this.redirect_to_slash
&& !req.path().ends_with('/') && !req.path().ends_with('/')

View File

@ -193,3 +193,72 @@ async fn partial_range_response_encoding() {
"identity" "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");
}