feat: optional `Host` URI support for the URL Redirects feature (#301)
* feat: optional `host` uri support for URL redirects
which allows redirecting based on a host's incoming uri making it
possible to perform for example www to non-www redirects.
config example:
```toml
[advanced]
[[advanced.redirects]]
host = "127.0.0.1:4433"
source = "/{*}"
destination = "https://localhost:4433/$1"
kind = 301
```
* chore: add test cases
Diff
src/handler.rs | 10 +++++-
src/lib.rs | 2 +-
src/redirects.rs | 11 ++++++-
src/settings/file.rs | 2 +-
src/settings/mod.rs | 3 ++-
src/testing.rs | 65 ++++++++++++++++++++++++++++++++++++-
tests/fixtures/toml/redirects.toml | 17 +++++++++-
tests/redirects.rs | 71 +++++++++++++++++++++++++++++++++++++++-
tests/toml/config.toml | 5 +++-
9 files changed, 186 insertions(+)
@@ -163,6 +163,11 @@ impl RequestHandler {
);
}
let host = headers
.get(http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
async move {
if health_request {
@@ -252,7 +257,12 @@ impl RequestHandler {
if let Some(advanced) = &self.opts.advanced_opts {
let mut uri_host = uri.host().unwrap_or(host).to_owned();
if let Some(uri_port) = uri.port_u16() {
uri_host.push_str(&format!(":{}", uri_port));
}
if let Some(redirects) = redirects::get_redirection(
&uri_host,
uri_path.clone().as_str(),
advanced.redirects.as_deref(),
) {
@@ -157,6 +157,8 @@ pub mod error;
#[doc(hidden)]
mod helpers;
#[doc(hidden)]
pub mod testing;
pub use error::*;
@@ -11,11 +11,22 @@ use crate::settings::Redirects;
pub fn get_redirection<'a>(
uri_host: &'a str,
uri_path: &'a str,
redirects_opts: Option<&'a [Redirects]>,
) -> Option<&'a Redirects> {
if let Some(redirects_vec) = redirects_opts {
for redirect_entry in redirects_vec.iter() {
if let Some(host) = &redirect_entry.host {
tracing::debug!(
"checking host '{host}' redirect entry against uri host '{uri_host}'"
);
if !host.eq(uri_host) {
continue;
}
}
if redirect_entry.source.is_match(uri_path) {
return Some(redirect_entry);
@@ -70,6 +70,8 @@ pub enum RedirectsKind {
#[serde(rename_all = "kebab-case")]
pub struct Redirects {
pub host: Option<String>,
pub source: String,
@@ -45,6 +45,8 @@ pub struct Rewrites {
pub struct Redirects {
pub host: Option<String>,
pub source: Regex,
@@ -439,6 +441,7 @@ impl Settings {
let status_code = redirects_entry.kind.to_owned() as u16;
redirects_vec.push(Redirects {
host: redirects_entry.host.to_owned(),
source,
destination: redirects_entry.destination.to_owned(),
kind: StatusCode::from_u16(status_code).with_context(|| {
@@ -0,0 +1,65 @@
#[doc(hidden)]
pub mod fixtures {
use std::{path::PathBuf, sync::Arc};
use crate::{
handler::{RequestHandler, RequestHandlerOpts},
Settings,
};
pub const REMOTE_ADDR: &str = "127.0.0.1:1234";
pub fn fixture_req_handler(fixture_toml: &str) -> RequestHandler {
let f = PathBuf::from("tests/fixtures").join(fixture_toml);
std::env::set_var("SERVER_CONFIG_FILE", f);
let opts = Settings::get(false).unwrap();
let req_handler_opts = RequestHandlerOpts {
root_dir: opts.general.root,
compression: opts.general.compression,
compression_static: opts.general.compression_static,
#[cfg(feature = "directory-listing")]
dir_listing: opts.general.directory_listing,
#[cfg(feature = "directory-listing")]
dir_listing_order: opts.general.directory_listing_order,
#[cfg(feature = "directory-listing")]
dir_listing_format: opts.general.directory_listing_format,
cors: None,
security_headers: opts.general.security_headers,
cache_control_headers: opts.general.cache_control_headers,
page404: opts.general.page404,
page50x: opts.general.page50x,
#[cfg(feature = "fallback-page")]
page_fallback: vec![],
#[cfg(feature = "basic-auth")]
basic_auth: opts.general.basic_auth,
log_remote_address: opts.general.log_remote_address,
redirect_trailing_slash: opts.general.redirect_trailing_slash,
ignore_hidden_files: opts.general.ignore_hidden_files,
index_files: vec![opts.general.index_files],
health: opts.general.health,
maintenance_mode: opts.general.maintenance_mode,
maintenance_mode_status: opts.general.maintenance_mode_status,
maintenance_mode_file: opts.general.maintenance_mode_file,
advanced_opts: opts.advanced,
};
RequestHandler {
opts: Arc::from(req_handler_opts),
}
}
}
@@ -0,0 +1,17 @@
[general]
root = "docker/public"
[advanced]
[[advanced.redirects]]
host = "127.0.0.1:1234"
source = "/{*}"
destination = "http://localhost:1234/$1"
kind = 301
[[advanced.redirects]]
source = "**/{*}.{*}"
destination = "http://localhost:1234/files/$1.$2"
kind = 302
@@ -0,0 +1,71 @@
#![forbid(unsafe_code)]
#![deny(warnings)]
#![deny(rust_2018_idioms)]
#![deny(dead_code)]
pub mod tests {
use hyper::Request;
use std::net::SocketAddr;
use static_web_server::testing::fixtures::{fixture_req_handler, REMOTE_ADDR};
#[tokio::test]
async fn redirects_default() {
let req_handler = fixture_req_handler("toml/redirects.toml");
let remote_addr = Some(REMOTE_ADDR.parse::<SocketAddr>().unwrap());
let mut req = Request::default();
*req.uri_mut() = "http://localhost:1234/assets/favicon.ico".parse().unwrap();
match req_handler.handle(&mut req, remote_addr).await {
Ok(res) => {
assert_eq!(res.status(), 302);
assert_eq!(
res.headers()["location"],
"http://localhost:1234/files/assets/favicon.ico"
);
}
Err(status) => {
panic!("expected a status 302 but got {status}")
}
};
}
#[tokio::test]
async fn redirects_host() {
let req_handler = fixture_req_handler("toml/redirects.toml");
let remote_addr = Some(REMOTE_ADDR.parse::<SocketAddr>().unwrap());
let mut req = Request::default();
*req.uri_mut() = "http://127.0.0.1:1234".parse().unwrap();
match req_handler.handle(&mut req, remote_addr).await {
Ok(res) => {
assert_eq!(res.status(), 301);
assert_eq!(res.headers()["location"], "http://localhost:1234/");
}
Err(status) => {
panic!("expected a status 301 but got {status}")
}
};
}
#[tokio::test]
async fn redirects_skipped() {
let req_handler = fixture_req_handler("toml/redirects.toml");
let remote_addr = Some(REMOTE_ADDR.parse::<SocketAddr>().unwrap());
let mut req = Request::default();
*req.uri_mut() = "http://localhost:1234".parse().unwrap();
match req_handler.handle(&mut req, remote_addr).await {
Ok(res) => {
assert_eq!(res.status(), 200);
assert_eq!(res.headers()["content-type"], "text/html");
}
Err(status) => {
panic!("expected a status 200 but got {status}")
}
};
}
}
@@ -103,6 +103,11 @@ headers.Strict-Transport-Security = "max-age=63072000; includeSubDomains; preloa
[[advanced.redirects]]
host = "127.0.0.1:4433"
source = "/{*}"
destination = "https://localhost:4433/$1"
kind = 301
[[advanced.redirects]]
source = "**/{*}.{jpg,jpeg}"