From 8c6ab533fd23fb766c13ceddd2b0640b776ad4db Mon Sep 17 00:00:00 2001 From: Jose Quintana <1700322+joseluisq@users.noreply.github.com> Date: Tue, 16 Jan 2024 00:43:14 +0100 Subject: [PATCH] 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 --- 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(+) create mode 100644 src/testing.rs create mode 100644 tests/fixtures/toml/redirects.toml create mode 100644 tests/redirects.rs diff --git a/src/handler.rs b/src/handler.rs index 1e5606c..a2f5177 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -163,6 +163,11 @@ impl RequestHandler { ); } + let host = headers + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + async move { // Health endpoint check if health_request { @@ -252,7 +257,12 @@ impl RequestHandler { // Advanced options if let Some(advanced) = &self.opts.advanced_opts { // Redirects + 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(), ) { diff --git a/src/lib.rs b/src/lib.rs index 56698cd..ebc37aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -157,6 +157,8 @@ pub mod error; // Private modules #[doc(hidden)] mod helpers; +#[doc(hidden)] +pub mod testing; // Re-exports pub use error::*; diff --git a/src/redirects.rs b/src/redirects.rs index 38f5435..ea5c59a 100644 --- a/src/redirects.rs +++ b/src/redirects.rs @@ -11,11 +11,22 @@ use crate::settings::Redirects; /// It returns a redirect's destination path and status code if the current request uri /// matches against the provided redirect's array. 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() { + // Match `host` redirect against `uri_host` if specified + 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; + } + } + // Match source glob pattern against the request uri path if redirect_entry.source.is_match(uri_path) { return Some(redirect_entry); diff --git a/src/settings/file.rs b/src/settings/file.rs index 2e047c5..6dd57db 100644 --- a/src/settings/file.rs +++ b/src/settings/file.rs @@ -70,6 +70,8 @@ pub enum RedirectsKind { #[serde(rename_all = "kebab-case")] /// Represents redirects types. pub struct Redirects { + /// Optional host to match against an incoming URI host if specified + pub host: Option, /// Source of the redirect. pub source: String, /// Redirect destination. diff --git a/src/settings/mod.rs b/src/settings/mod.rs index e7f00f5..f1379cb 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -45,6 +45,8 @@ pub struct Rewrites { /// The `Redirects` file options. pub struct Redirects { + /// Optional host to match against an incoming URI host if specified + pub host: Option, /// Source pattern Regex matcher pub source: Regex, /// A local file that must exist @@ -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(|| { diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..9621210 --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana + +//! Development utilities for testing of SWS. +//! + +/// SWS fixtures module. +#[doc(hidden)] +pub mod fixtures { + use std::{path::PathBuf, sync::Arc}; + + use crate::{ + handler::{RequestHandler, RequestHandlerOpts}, + Settings, + }; + + /// Testing Remote address + pub const REMOTE_ADDR: &str = "127.0.0.1:1234"; + + /// Create a `RequestHandler` from a custom TOML config file (fixture). + pub fn fixture_req_handler(fixture_toml: &str) -> RequestHandler { + // Replace default config file and load the fixture TOML settings + 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, + // TODO: add support or `cors` when required + cors: None, + security_headers: opts.general.security_headers, + cache_control_headers: opts.general.cache_control_headers, + page404: opts.general.page404, + page50x: opts.general.page50x, + // TODO: add support or `page_fallback` when required + #[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), + } + } +} diff --git a/tests/fixtures/toml/redirects.toml b/tests/fixtures/toml/redirects.toml new file mode 100644 index 0000000..d6a6563 --- /dev/null +++ b/tests/fixtures/toml/redirects.toml @@ -0,0 +1,17 @@ +[general] + +root = "docker/public" + +[advanced] + +### URL Redirects +[[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 diff --git a/tests/redirects.rs b/tests/redirects.rs new file mode 100644 index 0000000..4329e2e --- /dev/null +++ b/tests/redirects.rs @@ -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::().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::().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::().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}") + } + }; + } +} diff --git a/tests/toml/config.toml b/tests/toml/config.toml index 8b29a20..c8702c7 100644 --- a/tests/toml/config.toml +++ b/tests/toml/config.toml @@ -103,6 +103,11 @@ headers.Strict-Transport-Security = "max-age=63072000; includeSubDomains; preloa ### URL Redirects +[[advanced.redirects]] +host = "127.0.0.1:4433" +source = "/{*}" +destination = "https://localhost:4433/$1" +kind = 301 [[advanced.redirects]] source = "**/{*}.{jpg,jpeg}" -- libgit2 1.7.2