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]
- 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

View File

@ -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<PathBuf>,
index: Option<String>,
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<T: Into<PathBuf>>(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<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 {
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<ServiceRequest> 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,

View File

@ -33,7 +33,7 @@ impl Deref for FilesService {
}
pub struct FilesServiceInner {
pub(crate) directory: PathBuf,
pub(crate) directories: Vec<PathBuf>,
pub(crate) index: Option<String>,
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<ServiceRequest> 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<ServiceRequest> 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('/')

View File

@ -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");
}