Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions actix-files/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
69 changes: 56 additions & 13 deletions actix-files/src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<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);
// Preserve original path so requests don't fall back to CWD.
orig_dir
Comment thread
ban-xiu marked this conversation as resolved.
}
}
})
.collect();

Files {
mount_path: mount_path.trim_end_matches('/').to_owned(),
directory: dir,
directories,
index: None,
show_index: false,
redirect_to_slash: false,
Expand Down Expand Up @@ -407,7 +450,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,
Expand Down
137 changes: 82 additions & 55 deletions actix-files/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -171,70 +174,94 @@ impl Service<ServiceRequest> for FilesService {
}
}

// full file path
let path = this.directory.join(&path_on_disk);
// Try to find file in multiple directories
Comment thread
ban-xiu marked this conversation as resolved.
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 {
Comment thread
ban-xiu marked this conversation as resolved.
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;
})
}
}
Expand Down
Loading
Loading