New flag to make trailing slash redirect optional (#131)
* added redirect-trailing-slash flag
* add default option to test config
* fixed existing tests
* added tests
* added flag to docs
* refactor: grouping static-files handle parameters into a new type
* implemented change requests
Co-authored-by: Jose Quintana <joseluisquintana20@gmail.com>
Diff
docker/public/assets/index.html | 18 +-
docs/content/configuration/command-line-arguments.md | 3 +-
docs/content/configuration/config-file.md | 3 +-
docs/content/configuration/environment-variables.md | 3 +-
src/handler.rs | 16 +-
src/server.rs | 8 +-
src/settings/cli.rs | 9 +-
src/settings/file.rs | 2 +-
src/settings/mod.rs | 5 +-
src/static_files.rs | 37 +-
tests/dir_listing.rs | 21 +-
tests/static_files.rs | 437 ++++++++++++++------
tests/toml/config.toml | 3 +-
13 files changed, 414 insertions(+), 151 deletions(-)
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Assets Page</title>
<link rel="stylesheet" href="/assets/main.css">
<link rel="shortcut icon" href="/assets/favicon.ico">
</head>
<body>
<h1>Assets Page</h1>
<p>A blazing fast and asynchronous web server for static files-serving. ⚡</p>
<p><a href="https://github.com/joseluisq/static-web-server/" target="_blank">View on GitHub</a></p>
<script src="/assets/main.js"></script>
</body>
</html>
@@ -88,6 +88,9 @@ OPTIONS:
HTML file path for 50x errors. If the path is not specified or simply doesn't exist then the server will use
a generic HTML error message [env: SERVER_ERROR_PAGE_50X=] [default: ./public/50x.html]
-p, --port <port> Host port [env: SERVER_PORT=] [default: 80]
--redirect-trailing-slash <redirect-trailing-slash>
Check for trailing slash in the requested directory uri and redirect permanent (308) to the same path with a
trailing slash suffix if it is missing [env: REDIRECT_TRAILING_SLASH=] [default: true]
-d, --root <root>
Root directory path of static files [env: SERVER_ROOT=] [default: ./public]
@@ -63,6 +63,9 @@ grace-period = 0
#### Log request Remote Address if available
log-remote-address = false
#### Redirect to trailing slash in the requested directory uri
redirect-trailing-slash = true
### Windows Only
@@ -75,6 +75,9 @@ Enable cache control headers for incoming requests based on a set of file types.
### SERVER_BASIC_AUTH
It provides [The "Basic" HTTP Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc7617) using credentials as `user-id:password` pairs, encoded using `Base64`. Password must be encoded using the [BCrypt](https://en.wikipedia.org/wiki/Bcrypt) password-hashing function. Default empty (disabled).
### REDIRECT_TRAILING_SLASH
Check for trailing slash in the requested directory uri and redirect permanent (308) to the same path with a trailing slash suffix if it is missing. Default `true` (enabled).
## Windows
The following options and commands are Windows platform-specific.
@@ -4,7 +4,10 @@ use std::{future::Future, net::SocketAddr, path::PathBuf, sync::Arc};
use crate::{
basic_auth, compression, control_headers, cors, custom_headers, error_page, fallback_page,
redirects, rewrites, security_headers, settings::Advanced, static_files, Error, Result,
redirects, rewrites, security_headers,
settings::Advanced,
static_files::{self, HandleOpts},
Error, Result,
};
@@ -22,6 +25,7 @@ pub struct RequestHandlerOpts {
pub page_fallback: Vec<u8>,
pub basic_auth: String,
pub log_remote_address: bool,
pub redirect_trailing_slash: bool,
pub advanced_opts: Option<Advanced>,
@@ -43,12 +47,13 @@ impl RequestHandler {
let headers = req.headers();
let uri = req.uri();
let root_dir = &self.opts.root_dir;
let base_path = &self.opts.root_dir;
let mut uri_path = uri.path();
let uri_query = uri.query();
let dir_listing = self.opts.dir_listing;
let dir_listing_order = self.opts.dir_listing_order;
let log_remote_addr = self.opts.log_remote_address;
let redirect_trailing_slash = self.opts.redirect_trailing_slash;
let mut cors_headers: Option<http::HeaderMap> = None;
@@ -164,15 +169,16 @@ impl RequestHandler {
}
match static_files::handle(
match static_files::handle(&HandleOpts {
method,
headers,
root_dir,
base_path,
uri_path,
uri_query,
dir_listing,
dir_listing_order,
)
redirect_trailing_slash,
})
.await
{
Ok(mut resp) => {
@@ -167,6 +167,13 @@ impl Server {
let log_remote_address = general.log_remote_address;
tracing::info!("log remote address: enabled={}", log_remote_address);
let redirect_trailing_slash = general.redirect_trailing_slash;
tracing::info!(
"redirect trailing slash: enabled={}",
redirect_trailing_slash
);
let grace_period = general.grace_period;
tracing::info!("grace period before graceful shutdown: {}s", grace_period);
@@ -186,6 +193,7 @@ impl Server {
page_fallback,
basic_auth,
log_remote_address,
redirect_trailing_slash,
advanced_opts,
}),
});
@@ -179,6 +179,15 @@ pub struct General {
pub log_remote_address: bool,
#[structopt(
long,
parse(try_from_str),
default_value = "true",
env = "REDIRECT_TRAILING_SLASH"
)]
pub redirect_trailing_slash: bool,
@@ -128,6 +128,8 @@ pub struct General {
pub log_remote_address: Option<bool>,
pub redirect_trailing_slash: Option<bool>,
#[cfg(windows)]
pub windows_service: Option<bool>,
}
@@ -83,6 +83,7 @@ impl Settings {
let mut grace_period = opts.grace_period;
let mut page_fallback = opts.page_fallback;
let mut log_remote_address = opts.log_remote_address;
let mut redirect_trailing_slash = opts.redirect_trailing_slash;
#[cfg(windows)]
@@ -174,6 +175,9 @@ impl Settings {
if let Some(v) = general.log_remote_address {
log_remote_address = v
}
if let Some(v) = general.redirect_trailing_slash {
redirect_trailing_slash = v
}
#[cfg(windows)]
@@ -300,6 +304,7 @@ impl Settings {
grace_period,
page_fallback,
log_remote_address,
redirect_trailing_slash,
#[cfg(windows)]
@@ -39,30 +39,37 @@ impl AsRef<Path> for ArcPath {
}
}
pub struct HandleOpts<'a> {
pub method: &'a Method,
pub headers: &'a HeaderMap<HeaderValue>,
pub base_path: &'a PathBuf,
pub uri_path: &'a str,
pub uri_query: Option<&'a str>,
pub dir_listing: bool,
pub dir_listing_order: u8,
pub redirect_trailing_slash: bool,
}
pub async fn handle(
method: &Method,
headers: &HeaderMap<HeaderValue>,
base_path: impl Into<PathBuf>,
uri_path: &str,
uri_query: Option<&str>,
dir_listing: bool,
dir_listing_order: u8,
) -> Result<Response<Body>, StatusCode> {
pub async fn handle<'a>(opts: &HandleOpts<'a>) -> Result<Response<Body>, StatusCode> {
let method = opts.method;
let uri_path = opts.uri_path;
if !(method == Method::GET || method == Method::HEAD || method == Method::OPTIONS) {
return Err(StatusCode::METHOD_NOT_ALLOWED);
}
let base = Arc::new(base_path.into());
let base = Arc::new(opts.base_path.into());
let (filepath, meta, auto_index) = path_from_tail(base, uri_path).await?;
if auto_index && !uri_path.ends_with('/') {
if opts.redirect_trailing_slash && auto_index && !uri_path.ends_with('/') {
let uri = [uri_path, "/"].concat();
let loc = match HeaderValue::from_str(uri.as_str()) {
Ok(val) => val,
@@ -97,18 +104,18 @@ pub async fn handle(
if dir_listing && auto_index && !filepath.as_ref().exists() {
if opts.dir_listing && auto_index && !filepath.as_ref().exists() {
return directory_listing(
method,
uri_path,
uri_query,
opts.uri_query,
filepath.as_ref(),
dir_listing_order,
opts.dir_listing_order,
)
.await;
}
file_reply(headers, (filepath, &meta, auto_index)).await
file_reply(opts.headers, (filepath, &meta, auto_index)).await
}
@@ -9,7 +9,7 @@ mod tests {
use http::{Method, StatusCode};
use std::path::PathBuf;
use static_web_server::static_files;
use static_web_server::static_files::{self, HandleOpts};
fn root_dir() -> PathBuf {
PathBuf::from("docker/public/")
@@ -28,15 +28,16 @@ mod tests {
Method::TRACE,
];
for method in methods {
match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"/assets",
None,
true,
6,
)
match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "/assets",
uri_query: None,
dir_listing: true,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
@@ -11,7 +11,10 @@ mod tests {
use std::fs;
use std::path::PathBuf;
use static_web_server::{compression, static_files};
use static_web_server::{
compression,
static_files::{self, HandleOpts},
};
fn root_dir() -> PathBuf {
PathBuf::from("docker/public/")
@@ -19,15 +22,16 @@ mod tests {
#[tokio::test]
async fn handle_file() {
let mut res = static_files::handle(
&Method::GET,
&HeaderMap::new(),
root_dir(),
"index.html",
None,
false,
6,
)
let mut res = static_files::handle(&HandleOpts {
method: &Method::GET,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
.expect("unexpected error response on `handle` function");
@@ -57,15 +61,16 @@ mod tests {
#[tokio::test]
async fn handle_file_head() {
let mut res = static_files::handle(
&Method::HEAD,
&HeaderMap::new(),
root_dir(),
"index.html",
None,
false,
6,
)
let mut res = static_files::handle(&HandleOpts {
method: &Method::HEAD,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
.expect("unexpected error response on `handle` function");
@@ -96,15 +101,16 @@ mod tests {
#[tokio::test]
async fn handle_file_not_found() {
for method in [Method::HEAD, Method::GET] {
match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"xyz.html",
None,
false,
6,
)
match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "xyz.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(_) => {
@@ -119,15 +125,16 @@ mod tests {
#[tokio::test]
async fn handle_trailing_slash_redirection() {
let mut res = static_files::handle(
&Method::GET,
&HeaderMap::new(),
root_dir(),
"assets",
None,
false,
0,
)
let mut res = static_files::handle(&HandleOpts {
method: &Method::GET,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "assets",
uri_query: None,
dir_listing: false,
dir_listing_order: 0,
redirect_trailing_slash: true,
})
.await
.expect("unexpected error response on `handle` function");
@@ -142,6 +149,53 @@ mod tests {
}
#[tokio::test]
async fn handle_trailing_slash_redirection_subdir() {
match static_files::handle(&HandleOpts {
method: &Method::GET,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "assets",
uri_query: None,
dir_listing: false,
dir_listing_order: 0,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
assert_eq!(res.status(), 308);
assert_eq!(res.headers()["location"], "assets/");
}
Err(status) => {
panic!("expected a status 308 but not a status {}", status)
}
}
}
#[tokio::test]
async fn handle_disabled_trailing_slash_redirection_subdir() {
match static_files::handle(&HandleOpts {
method: &Method::GET,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "assets",
uri_query: None,
dir_listing: false,
dir_listing_order: 0,
redirect_trailing_slash: false,
})
.await
{
Ok(res) => {
assert_eq!(res.status(), 200);
}
Err(status) => {
panic!("expected a status 200 but not a status {}", status)
}
}
}
#[tokio::test]
async fn handle_append_index_on_dir() {
let buf = fs::read(root_dir().join("index.html"))
.expect("unexpected error during index.html reading");
@@ -149,15 +203,16 @@ mod tests {
for method in [Method::HEAD, Method::GET] {
for uri in ["", "/"] {
match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
uri,
None,
false,
6,
)
match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: uri,
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
@@ -192,15 +247,16 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"/index%2ehtml",
None,
false,
6,
)
match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "/index%2ehtml",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
@@ -217,15 +273,16 @@ mod tests {
#[tokio::test]
async fn handle_bad_encoded_path() {
for method in [Method::HEAD, Method::GET] {
match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"/%2E%2e.html",
None,
false,
6,
)
match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "/%2E%2e.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(_) => {
@@ -245,15 +302,16 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
let res1 = match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"index.html",
None,
false,
6,
)
let res1 = match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
@@ -273,8 +331,17 @@ mod tests {
res1.headers()["last-modified"].to_owned(),
);
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 304);
@@ -296,8 +363,17 @@ mod tests {
"Mon, 18 Nov 1974 00:00:00 GMT".parse().unwrap(),
);
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 200);
@@ -317,15 +393,16 @@ mod tests {
#[tokio::test]
async fn handle_precondition() {
for method in [Method::HEAD, Method::GET] {
let res1 = match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"index.html",
None,
false,
6,
)
let res1 = match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
@@ -344,8 +421,17 @@ mod tests {
res1.headers()["last-modified"].to_owned(),
);
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
assert_eq!(res.status(), 200);
@@ -362,8 +448,17 @@ mod tests {
"Mon, 18 Nov 1974 00:00:00 GMT".parse().unwrap(),
);
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 412);
@@ -394,15 +489,16 @@ mod tests {
Method::TRACE,
];
for method in methods {
match static_files::handle(
&method,
&HeaderMap::new(),
root_dir(),
"index.html",
None,
false,
6,
)
match static_files::handle(&HandleOpts {
method: &method,
headers: &HeaderMap::new(),
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => match method {
@@ -451,8 +547,17 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(http::header::ACCEPT_ENCODING, enc.parse().unwrap());
match static_files::handle(method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
let res = compression::auto(method, &headers, res)
@@ -503,8 +608,17 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 206);
@@ -535,8 +649,17 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 416);
@@ -568,8 +691,17 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(res) => {
assert_eq!(res.status(), 200);
@@ -593,8 +725,17 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 206);
@@ -628,8 +769,17 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 206);
@@ -660,8 +810,17 @@ mod tests {
let buf = Bytes::from(buf);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 416);
@@ -695,8 +854,17 @@ mod tests {
);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 416);
@@ -728,8 +896,17 @@ mod tests {
headers.insert("range", "bytes=".parse().unwrap());
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 200);
@@ -756,8 +933,17 @@ mod tests {
headers.insert("range", format!("bytes=100-{}", buf.len()).parse().unwrap());
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 206);
@@ -795,8 +981,17 @@ mod tests {
);
for method in [Method::HEAD, Method::GET] {
match static_files::handle(&method, &headers, root_dir(), "index.html", None, false, 6)
.await
match static_files::handle(&HandleOpts {
method: &method,
headers: &headers,
base_path: &root_dir(),
uri_path: "index.html",
uri_query: None,
dir_listing: false,
dir_listing_order: 6,
redirect_trailing_slash: true,
})
.await
{
Ok(mut res) => {
assert_eq!(res.status(), 206);
@@ -49,6 +49,9 @@ page-fallback = ""
log-remote-address = false
redirect-trailing-slash = true