Multiple index files support (#267)
* feat: multiple index files support
option: --index-files="a.html, b.htm, etc"
env: SERVER_INDEX_FILES
default value: "index.html"
Diff
README.md | 1 +-
docs/content/configuration/command-line-arguments.md | 2 +-
docs/content/configuration/config-file.md | 3 +-
docs/content/configuration/environment-variables.md | 3 +-
docs/content/features/multiple-index-files.md | 17 ++++-
docs/content/index.md | 1 +-
docs/mkdocs.yml | 1 +-
src/handler.rs | 5 +-
src/server.rs | 13 +++-
src/settings/cli.rs | 5 +-
src/settings/file.rs | 3 +-
src/settings/mod.rs | 5 +-
src/static_files.rs | 89 +++++++++++++--------
tests/compression_static.rs | 3 +-
tests/dir_listing.rs | 14 ++-
tests/fixtures/public/index.htm | 1 +-
tests/static_files.rs | 73 +++++++++++++++++-
tests/toml/config.toml | 3 +-
18 files changed, 207 insertions(+), 35 deletions(-)
@@ -65,6 +65,7 @@ Cross-platform and available for `Linux`, `macOS`, `Windows`, `FreeBSD`, `NetBSD
- Support for serving pre-compressed (Gzip/Brotli/Zstd) files directly from disk.
- Custom URL rewrites and redirects via glob patterns with replacements.
- Virtual hosting support.
- Multiple index files.
- Available as a library crate with opt-in features.
- First-class [Docker](https://docs.docker.com/get-started/overview/) support. [Scratch](https://hub.docker.com/_/scratch), latest [Alpine Linux](https://hub.docker.com/_/alpine) and [Debian](https://hub.docker.com/_/alpine) Docker images.
- Ability to accept a socket listener as a file descriptor for sandboxing and on-demand applications (e.g. [systemd](http://0pointer.de/blog/projects/socket-activation.html)).
@@ -55,6 +55,8 @@ Options:
HTTP host port where the redirect server will listen for requests to redirect them to HTTPS. It depends on "https_redirect" to be enabled [env: SERVER_HTTPS_REDIRECT_FROM_PORT=] [default: 80]
--https-redirect-from-hosts <HTTPS_REDIRECT_FROM_HOSTS>
List of host names or IPs allowed to redirect from. HTTP requests must contain the HTTP 'Host' header and match against this list. It depends on "https_redirect" to be enabled [env: SERVER_HTTPS_REDIRECT_FROM_HOSTS=] [default: localhost]
--index-files <INDEX_FILES>
List of files that will be used as an index for requests ending with the slash character (‘/’). Files are checked in the specified order [env: SERVER_INDEX_FILES=] [default: index.html]
-x, --compression[=<COMPRESSION>]
Gzip, Deflate, Brotli or Zstd compression on demand determined by the Accept-Encoding header and applied to text-based web file types only [env: SERVER_COMPRESSION=] [default: true] [possible values: true, false]
--compression-static[=<COMPRESSION_STATIC>]
@@ -78,6 +78,9 @@ compression-static = true
#### Health-check endpoint (GET or HEAD `/health`)
health = false
#### List of index files
# index-files = "index.html, index.htm"
### Windows Only
#### Run the web server as a Windows Service
@@ -108,6 +108,9 @@ Ignore hidden files/directories (dotfiles), preventing them to be served and bei
### SERVER_HEALTH
Activate the health endpoint.
### SERVER_INDEX_FILES
List of files that will be used as an index for requests ending with the slash character (‘/’). Files are checked in the specified order. Default `index.html`.
## Windows
The following options and commands are Windows platform-specific.
@@ -0,0 +1,17 @@
# Multiple index files
**`SWS`** allows to provide a list of files that will be used as an index for requests ending with the slash character (‘/’).
!!! info "Notes"
- Files are checked in the specified order from left to right.
- The option value can be a single index or comma-separated when multiple values.
- The default value is `index.html`.
This feature is disabled by default and can be controlled by the string list `--index-files` option or the equivalent [SERVER_INDEX_FILES](./../configuration/environment-variables.md#server_index_files) env.
Here is an example:
```sh
static-web-server -p 8787 -d ./public \
--index-files="index.html, index.htm, default.html"
```
@@ -69,6 +69,7 @@ Cross-platform and available for `Linux`, `macOS`, `Windows`, `FreeBSD`, `NetBSD
- Support for serving pre-compressed (Gzip/Brotli/Zstd) files directly from disk.
- Custom URL rewrites and redirects via glob patterns with replacements.
- Virtual hosting support.
- Multiple index files.
- Available as a library crate with opt-in features.
- First-class [Docker](https://docs.docker.com/get-started/overview/) support. [Scratch](https://hub.docker.com/_/scratch), latest [Alpine Linux](https://hub.docker.com/_/alpine) and [Debian](https://hub.docker.com/_/alpine) Docker images.
- Ability to accept a socket listener as a file descriptor for sandboxing and on-demand applications (e.g. [systemd](http://0pointer.de/blog/projects/socket-activation.html)).
@@ -163,6 +163,7 @@ nav:
- 'Ignore Files': 'features/ignore-files.md'
- 'Health endpoint': 'features/health-endpoint.md'
- 'Virtual Hosting': 'features/virtual-hosting.md'
- 'Multiple Index Files': 'features/multiple-index-files.md'
- 'WebAssembly': 'features/webassembly.md'
- 'Platforms & Architectures': 'platforms-architectures.md'
- 'Migrating from v1 to v2': 'migration.md'
@@ -70,6 +70,8 @@ pub struct RequestHandlerOpts {
#[cfg(feature = "basic-auth")]
#[cfg_attr(docsrs, doc(cfg(feature = "basic-auth")))]
pub basic_auth: String,
pub index_files: Vec<String>,
pub log_remote_address: bool,
@@ -114,6 +116,7 @@ impl RequestHandler {
let compression_static = self.opts.compression_static;
let ignore_hidden_files = self.opts.ignore_hidden_files;
let health = self.opts.health;
let index_files: Vec<&str> = self.opts.index_files.iter().map(|s| s.as_str()).collect();
let mut cors_headers: Option<http::HeaderMap> = None;
@@ -359,6 +362,7 @@ impl RequestHandler {
}
let uri_path = &uri_path;
let index_files = index_files.as_ref();
match static_files::handle(&HandleOpts {
@@ -376,6 +380,7 @@ impl RequestHandler {
redirect_trailing_slash,
compression_static,
ignore_hidden_files,
index_files,
})
.await
{
@@ -252,6 +252,17 @@ impl Server {
let grace_period = general.grace_period;
server_info!("grace period before graceful shutdown: {}s", grace_period);
let index_files = general
.index_files
.split(',')
.map(|s| s.trim().to_owned())
.collect::<Vec<_>>();
if index_files.is_empty() {
bail!("index files list is empty, provide at least one index file")
}
server_info!("index files: {}", general.index_files);
let health = general.health;
server_info!("health endpoint: enabled={}", health);
@@ -280,6 +291,7 @@ impl Server {
log_remote_address,
redirect_trailing_slash,
ignore_hidden_files,
index_files,
health,
advanced_opts,
}),
@@ -419,6 +431,7 @@ impl Server {
bail!("https redirect allowed hosts is empty, provide at least one host or IP")
}
let redirect_opts = Arc::new(https_redirect::RedirectOpts {
https_hostname: general.https_redirect_host,
https_port: general.port,
@@ -226,6 +226,11 @@ pub struct General {
pub https_redirect_from_hosts: String,
#[arg(long, default_value = "index.html", env = "SERVER_INDEX_FILES")]
pub index_files: String,
#[cfg(feature = "compression")]
#[cfg_attr(docsrs, doc(cfg(feature = "compression")))]
#[arg(
@@ -187,6 +187,9 @@ pub struct General {
pub cors_expose_headers: Option<String>,
pub index_files: Option<String>,
#[cfg(feature = "directory-listing")]
#[cfg_attr(docsrs, doc(cfg(feature = "directory-listing")))]
@@ -145,6 +145,7 @@ impl Settings {
let mut log_remote_address = opts.log_remote_address;
let mut redirect_trailing_slash = opts.redirect_trailing_slash;
let mut ignore_hidden_files = opts.ignore_hidden_files;
let mut index_files = opts.index_files;
let mut health = opts.health;
@@ -284,6 +285,9 @@ impl Settings {
if let Some(v) = general.health {
health = v
}
if let Some(v) = general.index_files {
index_files = v
}
#[cfg(windows)]
@@ -508,6 +512,7 @@ impl Settings {
log_remote_address,
redirect_trailing_slash,
ignore_hidden_files,
index_files,
health,
@@ -39,6 +39,8 @@ use crate::{
directory_listing::{DirListFmt, DirListOpts},
};
const DEFAULT_INDEX_FILES: &[&str; 1] = &["index.html"];
pub struct HandleOpts<'a> {
@@ -49,6 +51,8 @@ pub struct HandleOpts<'a> {
pub base_path: &'a PathBuf,
pub uri_path: &'a str,
pub index_files: &'a [&'a str],
pub uri_query: Option<&'a str>,
@@ -91,7 +95,13 @@ pub async fn handle<'a>(opts: &HandleOpts<'a>) -> Result<(Response<Body>, bool),
metadata,
is_dir,
precompressed_variant,
} = composed_file_metadata(&mut file_path, headers_opt, compression_static_opt).await?;
} = composed_file_metadata(
&mut file_path,
headers_opt,
compression_static_opt,
opts.index_files,
)
.await?;
if opts.ignore_hidden_files && file_path.is_hidden() {
@@ -215,48 +225,63 @@ async fn composed_file_metadata<'a>(
mut file_path: &'a mut PathBuf,
_headers: &'a HeaderMap<HeaderValue>,
_compression_static: bool,
mut index_files: &'a [&'a str],
) -> Result<FileMetadata<'a>, StatusCode> {
tracing::trace!("getting metadata for file {}", file_path.display());
match file_metadata(file_path) {
Ok((mut metadata, is_dir)) => {
if is_dir {
tracing::debug!("dir: appending an index.html to the directory path");
file_path.push("index.html");
#[cfg(feature = "compression")]
if _compression_static {
if let Some(p) =
compression_static::precompressed_variant(file_path, _headers).await
{
return Ok(FileMetadata {
file_path,
metadata: p.metadata,
is_dir: false,
precompressed_variant: Some((p.file_path, p.extension)),
});
}
if index_files.is_empty() {
index_files = DEFAULT_INDEX_FILES;
}
let mut index_found = false;
for index in index_files {
tracing::debug!("dir: appending {} to the directory path", index);
file_path.push(index);
#[cfg(feature = "compression")]
if _compression_static {
if let Some(p) =
compression_static::precompressed_variant(file_path, _headers).await
{
return Ok(FileMetadata {
file_path,
metadata: p.metadata,
is_dir: false,
precompressed_variant: Some((p.file_path, p.extension)),
});
}
}
if let Ok(meta_res) = file_metadata(file_path) {
(metadata, _) = meta_res
} else {
file_path.pop();
let new_meta: Option<Metadata>;
(file_path, new_meta) = suffix_file_html_metadata(file_path);
if let Some(new_meta) = new_meta {
metadata = new_meta;
if let Ok(meta_res) = file_metadata(file_path) {
(metadata, _) = meta_res;
index_found = true;
break;
} else {
file_path.push("index.html");
file_path.pop();
let new_meta: Option<Metadata>;
(file_path, new_meta) = suffix_file_html_metadata(file_path);
if let Some(new_meta) = new_meta {
metadata = new_meta;
index_found = true;
break;
}
}
}
if !index_found && !index_files.is_empty() {
file_path.push(index_files.last().unwrap());
}
} else {
#[cfg(feature = "compression")]
@@ -48,6 +48,7 @@ mod tests {
#[cfg(feature = "compression")]
compression_static: true,
ignore_hidden_files: false,
index_files: &[],
})
.await
.expect("unexpected error response on `handle` function");
@@ -106,6 +107,7 @@ mod tests {
#[cfg(feature = "compression")]
compression_static: true,
ignore_hidden_files: false,
index_files: &[],
})
.await
.expect("unexpected error response on `handle` function");
@@ -158,6 +160,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: true,
ignore_hidden_files: false,
index_files: &[],
})
.await
.expect("unexpected error response on `handle` function");
@@ -49,6 +49,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -79,6 +80,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -119,6 +121,7 @@ mod tests {
redirect_trailing_slash: false,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -159,6 +162,7 @@ mod tests {
redirect_trailing_slash: false,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -189,6 +193,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -240,6 +245,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: true,
index_files: &[],
})
.await
{
@@ -254,7 +260,7 @@ mod tests {
if method == Method::GET {
let entries: Vec<FileEntry> = serde_json::from_str(body_str).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries.len(), 3);
let first_entry = entries.first().unwrap();
assert_eq!(first_entry.name, "spécial directöry");
@@ -263,10 +269,10 @@ mod tests {
assert!(first_entry.size.is_none());
let last_entry = entries.last().unwrap();
assert_eq!(last_entry.name, "index.html.gz");
assert_eq!(last_entry.name, "index.htm");
assert_eq!(last_entry.typed, "file");
assert!(!last_entry.mtime.is_empty());
assert!(last_entry.size.unwrap() > 300);
assert!(last_entry.size.unwrap() >= 36);
} else {
assert!(body_str.is_empty());
}
@@ -309,6 +315,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -351,6 +358,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: true,
index_files: &[],
})
.await
{
@@ -0,0 +1 @@
<h1>this is a custom index file</h1>
@@ -39,6 +39,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
.expect("unexpected error response on `handle` function");
@@ -80,6 +81,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
.expect("unexpected error response on `handle` function");
@@ -122,6 +124,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -152,6 +155,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
.expect("unexpected error response on `handle` function");
@@ -183,6 +187,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -213,6 +218,7 @@ mod tests {
redirect_trailing_slash: false,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -248,6 +254,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -298,6 +305,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -330,6 +338,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -365,6 +374,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -400,6 +410,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -438,6 +449,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -474,6 +486,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -508,6 +521,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -541,6 +555,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -588,6 +603,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -649,6 +665,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -712,6 +729,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -759,6 +777,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -806,6 +825,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -854,6 +874,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -894,6 +915,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -944,6 +966,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -991,6 +1014,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -1041,6 +1065,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -1089,6 +1114,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -1132,6 +1158,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -1186,6 +1213,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: false,
ignore_hidden_files: false,
index_files: &[],
})
.await
{
@@ -1232,6 +1260,7 @@ mod tests {
redirect_trailing_slash: true,
compression_static: true,
ignore_hidden_files: true,
index_files: &[],
})
.await
{
@@ -1244,4 +1273,48 @@ mod tests {
}
}
}
#[tokio::test]
async fn handle_multiple_index_files() {
let root_dir = PathBuf::from("tests/fixtures/public/");
let headers = HeaderMap::new();
let buf = fs::read(root_dir.join("index.htm"))
.expect("unexpected error during index.htm reading");
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir,
uri_path: "/",
uri_query: None,
#[cfg(feature = "directory-listing")]
dir_listing: false,
#[cfg(feature = "directory-listing")]
dir_listing_order: 6,
#[cfg(feature = "directory-listing")]
dir_listing_format: &DirListFmt::Html,
redirect_trailing_slash: true,
compression_static: true,
ignore_hidden_files: true,
index_files: &["index.html", "index.htm"],
})
.await
{
Ok((mut res, _)) => {
assert_eq!(res.status(), 200);
assert_eq!(res.headers()["content-length"], format!("{}", buf.len()));
let body = hyper::body::to_bytes(res.body_mut())
.await
.expect("unexpected bytes error during `body` conversion");
assert_eq!(body, &buf);
}
Err(_) => {
panic!("expected a normal response rather than a status error")
}
}
}
}
}
@@ -65,6 +65,9 @@ redirect-trailing-slash = true
compression-static = false
index-files = "index.html, index.htm"