static_web_server/
redirects.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! Redirection module to handle config redirect URLs with pattern matching support.
7//!
8
9use headers::HeaderValue;
10use hyper::{Body, Request, Response, StatusCode};
11use regex_lite::Regex;
12
13use crate::{Error, error_page, handler::RequestHandlerOpts, settings::Redirects};
14
15/// Applies redirect rules to a request if necessary.
16pub(crate) fn pre_process<T>(
17    opts: &RequestHandlerOpts,
18    req: &Request<T>,
19) -> Option<Result<Response<Body>, Error>> {
20    let redirects = opts.advanced_opts.as_ref()?.redirects.as_deref()?;
21
22    let uri = req.uri();
23    let uri_path = uri.path();
24    let host = req
25        .headers()
26        .get(http::header::HOST)
27        .and_then(|v| v.to_str().ok())
28        .unwrap_or("");
29    let mut uri_host = uri.host().unwrap_or(host).to_owned();
30    if let Some(uri_port) = uri.port_u16() {
31        uri_host.push_str(&format!(":{uri_port}"));
32    }
33    let matched = get_redirection(&uri_host, uri_path, Some(redirects))?;
34    let dest = match replace_placeholders(uri_path, &matched.source, &matched.destination) {
35        Ok(dest) => dest,
36        Err(err) => return handle_error(err, opts, req),
37    };
38
39    match HeaderValue::from_str(&dest) {
40        Ok(loc) => {
41            let mut resp = Response::new(Body::empty());
42            resp.headers_mut().insert(hyper::header::LOCATION, loc);
43            *resp.status_mut() = matched.kind;
44            tracing::trace!(
45                "uri matches redirects glob pattern, redirecting with status '{}'",
46                matched.kind
47            );
48            Some(Ok(resp))
49        }
50        Err(err) => handle_error(
51            Error::new(err).context("invalid header value from current uri"),
52            opts,
53            req,
54        ),
55    }
56}
57
58/// Replaces placeholders in the destination URI by matching capture groups from the original URI.
59pub(crate) fn replace_placeholders(
60    orig_uri: &str,
61    regex: &Regex,
62    dest_uri: &str,
63) -> Result<String, Error> {
64    let regex_caps = if let Some(regex_caps) = regex.captures(orig_uri) {
65        regex_caps
66    } else {
67        return Err(Error::msg("regex didn't match, extracting captures failed"));
68    };
69
70    let caps_range = 0..regex_caps.len();
71    let caps = caps_range
72        .clone()
73        .map(|i| regex_caps.get(i).map(|s| s.as_str()).unwrap_or(""))
74        .collect::<Vec<&str>>();
75
76    let patterns = caps_range.map(|i| format!("${i}")).collect::<Vec<String>>();
77
78    tracing::debug!("url redirects/rewrites glob pattern: {patterns:?}");
79    tracing::debug!("url redirects/rewrites regex equivalent: {regex}");
80    tracing::debug!("url redirects/rewrites glob pattern captures: {caps:?}");
81    tracing::debug!("url redirects/rewrites glob pattern destination: {dest_uri:?}");
82
83    let ac = match aho_corasick::AhoCorasick::new(patterns) {
84        Ok(ac) => ac,
85        Err(err) => return Err(Error::new(err).context("failed creating Aho-Corasick matcher")),
86    };
87    match ac.try_replace_all(dest_uri, &caps) {
88        Ok(dest) => {
89            tracing::debug!("url redirects/rewrites glob pattern destination replaced: {dest:?}");
90            Ok(dest.to_string())
91        }
92        Err(err) => Err(Error::new(err).context("failed replacing captures")),
93    }
94}
95
96/// Logs error and produces an Internal Server Error response.
97pub(crate) fn handle_error<T>(
98    err: Error,
99    opts: &RequestHandlerOpts,
100    req: &Request<T>,
101) -> Option<Result<Response<Body>, Error>> {
102    tracing::error!("{err:?}");
103    Some(error_page::error_response(
104        req.uri(),
105        req.method(),
106        &StatusCode::INTERNAL_SERVER_ERROR,
107        &opts.page404,
108        &opts.page50x,
109    ))
110}
111
112/// It returns a redirect's destination path and status code if the current request uri
113/// matches against the provided redirect's array.
114pub fn get_redirection<'a>(
115    uri_host: &'a str,
116    uri_path: &'a str,
117    redirects_opts: Option<&'a [Redirects]>,
118) -> Option<&'a Redirects> {
119    if let Some(redirects_vec) = redirects_opts {
120        for redirect_entry in redirects_vec {
121            // Match `host` redirect against `uri_host` if specified
122            if let Some(host) = &redirect_entry.host {
123                tracing::debug!(
124                    "checking host '{host}' redirect entry against uri host '{uri_host}'"
125                );
126                if !host.eq(uri_host) {
127                    continue;
128                }
129            }
130
131            // Match source glob pattern against the request uri path
132            if redirect_entry.source.is_match(uri_path) {
133                return Some(redirect_entry);
134            }
135        }
136    }
137
138    None
139}
140
141#[cfg(test)]
142mod tests {
143    use super::pre_process;
144    use crate::{
145        Error,
146        handler::RequestHandlerOpts,
147        settings::{Advanced, Redirects},
148    };
149    use hyper::{Body, Request, Response, StatusCode};
150    use regex_lite::Regex;
151
152    fn make_request(host: &str, uri: &str) -> Request<Body> {
153        let mut builder = Request::builder();
154        if !host.is_empty() {
155            builder = builder.header("Host", host);
156        }
157        builder.method("GET").uri(uri).body(Body::empty()).unwrap()
158    }
159
160    fn get_redirects() -> Vec<Redirects> {
161        vec![
162            Redirects {
163                host: None,
164                source: Regex::new(r"/source1$").unwrap(),
165                destination: "/destination1".into(),
166                kind: StatusCode::FOUND,
167            },
168            Redirects {
169                host: Some("example.com".into()),
170                source: Regex::new(r"/source2$").unwrap(),
171                destination: "/destination2".into(),
172                kind: StatusCode::MOVED_PERMANENTLY,
173            },
174            Redirects {
175                host: Some("example.info".into()),
176                source: Regex::new(r"/(prefix/)?(source3)/(.*)").unwrap(),
177                destination: "/destination3/$2/$3".into(),
178                kind: StatusCode::MOVED_PERMANENTLY,
179            },
180        ]
181    }
182
183    fn is_redirect(result: Option<Result<Response<Body>, Error>>) -> Option<(StatusCode, String)> {
184        if let Some(Ok(response)) = result {
185            let location = response.headers().get("Location")?.to_str().unwrap().into();
186            Some((response.status(), location))
187        } else {
188            None
189        }
190    }
191
192    #[test]
193    fn test_no_redirects() {
194        assert!(
195            pre_process(
196                &RequestHandlerOpts {
197                    advanced_opts: None,
198                    ..Default::default()
199                },
200                &make_request("", "/")
201            )
202            .is_none()
203        );
204
205        assert!(
206            pre_process(
207                &RequestHandlerOpts {
208                    advanced_opts: Some(Advanced {
209                        redirects: None,
210                        ..Default::default()
211                    }),
212                    ..Default::default()
213                },
214                &make_request("", "/")
215            )
216            .is_none()
217        );
218    }
219
220    #[test]
221    fn test_no_match() {
222        assert!(
223            pre_process(
224                &RequestHandlerOpts {
225                    advanced_opts: Some(Advanced {
226                        redirects: Some(get_redirects()),
227                        ..Default::default()
228                    }),
229                    ..Default::default()
230                },
231                &make_request("example.com", "/source2/whatever")
232            )
233            .is_none()
234        );
235
236        assert!(
237            pre_process(
238                &RequestHandlerOpts {
239                    advanced_opts: Some(Advanced {
240                        redirects: Some(get_redirects()),
241                        ..Default::default()
242                    }),
243                    ..Default::default()
244                },
245                &make_request("", "/source2")
246            )
247            .is_none()
248        );
249    }
250
251    #[test]
252    fn test_match() {
253        assert_eq!(
254            is_redirect(pre_process(
255                &RequestHandlerOpts {
256                    advanced_opts: Some(Advanced {
257                        redirects: Some(get_redirects()),
258                        ..Default::default()
259                    }),
260                    ..Default::default()
261                },
262                &make_request("", "/source1")
263            )),
264            Some((StatusCode::FOUND, "/destination1".into()))
265        );
266
267        assert_eq!(
268            is_redirect(pre_process(
269                &RequestHandlerOpts {
270                    advanced_opts: Some(Advanced {
271                        redirects: Some(get_redirects()),
272                        ..Default::default()
273                    }),
274                    ..Default::default()
275                },
276                &make_request("example.com", "/source2")
277            )),
278            Some((StatusCode::MOVED_PERMANENTLY, "/destination2".into()))
279        );
280
281        assert_eq!(
282            is_redirect(pre_process(
283                &RequestHandlerOpts {
284                    advanced_opts: Some(Advanced {
285                        redirects: Some(get_redirects()),
286                        ..Default::default()
287                    }),
288                    ..Default::default()
289                },
290                &make_request("example.info", "/source3/whatever")
291            )),
292            Some((
293                StatusCode::MOVED_PERMANENTLY,
294                "/destination3/source3/whatever".into()
295            ))
296        );
297    }
298}