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::Regex;
12
13use crate::{error_page, handler::RequestHandlerOpts, settings::Redirects, Error};
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        handler::RequestHandlerOpts,
146        settings::{Advanced, Redirects},
147        Error,
148    };
149    use hyper::{Body, Request, Response, StatusCode};
150    use regex::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!(pre_process(
195            &RequestHandlerOpts {
196                advanced_opts: None,
197                ..Default::default()
198            },
199            &make_request("", "/")
200        )
201        .is_none());
202
203        assert!(pre_process(
204            &RequestHandlerOpts {
205                advanced_opts: Some(Advanced {
206                    redirects: None,
207                    ..Default::default()
208                }),
209                ..Default::default()
210            },
211            &make_request("", "/")
212        )
213        .is_none());
214    }
215
216    #[test]
217    fn test_no_match() {
218        assert!(pre_process(
219            &RequestHandlerOpts {
220                advanced_opts: Some(Advanced {
221                    redirects: Some(get_redirects()),
222                    ..Default::default()
223                }),
224                ..Default::default()
225            },
226            &make_request("example.com", "/source2/whatever")
227        )
228        .is_none());
229
230        assert!(pre_process(
231            &RequestHandlerOpts {
232                advanced_opts: Some(Advanced {
233                    redirects: Some(get_redirects()),
234                    ..Default::default()
235                }),
236                ..Default::default()
237            },
238            &make_request("", "/source2")
239        )
240        .is_none());
241    }
242
243    #[test]
244    fn test_match() {
245        assert_eq!(
246            is_redirect(pre_process(
247                &RequestHandlerOpts {
248                    advanced_opts: Some(Advanced {
249                        redirects: Some(get_redirects()),
250                        ..Default::default()
251                    }),
252                    ..Default::default()
253                },
254                &make_request("", "/source1")
255            )),
256            Some((StatusCode::FOUND, "/destination1".into()))
257        );
258
259        assert_eq!(
260            is_redirect(pre_process(
261                &RequestHandlerOpts {
262                    advanced_opts: Some(Advanced {
263                        redirects: Some(get_redirects()),
264                        ..Default::default()
265                    }),
266                    ..Default::default()
267                },
268                &make_request("example.com", "/source2")
269            )),
270            Some((StatusCode::MOVED_PERMANENTLY, "/destination2".into()))
271        );
272
273        assert_eq!(
274            is_redirect(pre_process(
275                &RequestHandlerOpts {
276                    advanced_opts: Some(Advanced {
277                        redirects: Some(get_redirects()),
278                        ..Default::default()
279                    }),
280                    ..Default::default()
281                },
282                &make_request("example.info", "/source3/whatever")
283            )),
284            Some((
285                StatusCode::MOVED_PERMANENTLY,
286                "/destination3/source3/whatever".into()
287            ))
288        );
289    }
290}