Skip to main content

gatel_core/hoops/
rewrite.rs

1use std::path::Path;
2
3use http::Uri;
4use regex::Regex;
5use salvo::{Depot, FlowCtrl, Request, Response, async_trait};
6use tracing::debug;
7
8use crate::ProxyError;
9
10/// URI rewrite middleware.
11///
12/// Supports:
13/// - **strip_prefix**: remove a path prefix before forwarding (e.g., strip "/api" so
14///   "/api/users?q=1" becomes "/users?q=1")
15/// - **uri**: full URI rewrite with simple pattern matching. The pattern may contain `{path}` which
16///   is replaced by the original path, and `{query}` which is replaced by the original query
17///   string.
18/// - **regex_rules**: sequential regex replacements applied to the path.
19/// - **if_not_file** / **if_not_dir**: only apply rewrite when the path does not resolve to an
20///   existing file or directory under `root`.
21pub struct RewriteHoop {
22    strip_prefix: Option<String>,
23    uri_template: Option<String>,
24    regex_rules: Vec<(Regex, String)>,
25    if_not_file: bool,
26    if_not_dir: bool,
27    root: Option<String>,
28    normalize_slashes: bool,
29}
30
31impl RewriteHoop {
32    pub fn new(
33        strip_prefix: Option<String>,
34        uri_template: Option<String>,
35        regex_rules: Vec<(Regex, String)>,
36        if_not_file: bool,
37        if_not_dir: bool,
38        root: Option<String>,
39        normalize_slashes: bool,
40    ) -> Self {
41        Self {
42            strip_prefix,
43            uri_template,
44            regex_rules,
45            if_not_file,
46            if_not_dir,
47            root,
48            normalize_slashes,
49        }
50    }
51}
52
53#[async_trait]
54impl salvo::Handler for RewriteHoop {
55    async fn handle(
56        &self,
57        req: &mut Request,
58        depot: &mut Depot,
59        res: &mut Response,
60        ctrl: &mut FlowCtrl,
61    ) {
62        if let Err(e) = apply_rewrite(self, req) {
63            debug!(error = %e, "rewrite error");
64            res.status_code(salvo::http::StatusCode::INTERNAL_SERVER_ERROR);
65            res.body(e.to_string());
66            ctrl.skip_rest();
67            return;
68        }
69
70        ctrl.call_next(req, depot, res).await;
71    }
72}
73
74fn apply_rewrite(mw: &RewriteHoop, req: &mut Request) -> Result<(), ProxyError> {
75    let original_uri = req.uri().clone();
76
77    // 0. Normalize consecutive slashes BEFORE any other rewrite rule.
78    if mw.normalize_slashes {
79        let path = original_uri.path();
80        if path.contains("//") {
81            let normalized = collapse_slashes(path);
82            let pq = match original_uri.query() {
83                Some(q) if !q.is_empty() => format!("{normalized}?{q}"),
84                _ => normalized,
85            };
86            let new_uri = rebuild_uri(&original_uri, &pq)?;
87            *req.uri_mut() = new_uri;
88        }
89    }
90
91    // Re-read the (possibly updated) URI.
92    let original_uri = req.uri().clone();
93    let original_path = original_uri.path();
94    let original_query = original_uri.query().unwrap_or("");
95
96    // Conditional checks: if_not_file / if_not_dir.
97    if (mw.if_not_file || mw.if_not_dir)
98        && let Some(root) = &mw.root
99    {
100        let fs_path = Path::new(root).join(original_path.trim_start_matches('/'));
101        if mw.if_not_file && fs_path.is_file() {
102            debug!(
103                path = original_path,
104                "rewrite skipped: path resolves to existing file"
105            );
106            return Ok(());
107        }
108        if mw.if_not_dir && fs_path.is_dir() {
109            debug!(
110                path = original_path,
111                "rewrite skipped: path resolves to existing directory"
112            );
113            return Ok(());
114        }
115    }
116
117    let mut new_path_and_query: Option<String> = None;
118
119    // 1. Strip prefix.
120    if let Some(prefix) = &mw.strip_prefix {
121        let stripped = if original_path == prefix {
122            "/".to_string()
123        } else if let Some(rest) = original_path.strip_prefix(prefix.as_str()) {
124            if rest.is_empty() || rest.starts_with('/') {
125                if rest.is_empty() {
126                    "/".to_string()
127                } else {
128                    rest.to_string()
129                }
130            } else {
131                // Prefix does not align on a segment boundary — no rewrite.
132                original_path.to_string()
133            }
134        } else {
135            original_path.to_string()
136        };
137
138        let pq = if original_query.is_empty() {
139            stripped
140        } else {
141            format!("{stripped}?{original_query}")
142        };
143        new_path_and_query = Some(pq);
144
145        debug!(
146            prefix = prefix.as_str(),
147            original = original_path,
148            rewritten = new_path_and_query.as_deref().unwrap_or(""),
149            "stripped path prefix"
150        );
151    }
152
153    // 2. Full URI rewrite template.
154    if let Some(template) = &mw.uri_template {
155        let current_path = new_path_and_query
156            .as_deref()
157            .map(|pq| pq.split('?').next().unwrap_or(pq))
158            .unwrap_or(original_path);
159        let current_query = new_path_and_query
160            .as_deref()
161            .and_then(|pq| pq.split_once('?').map(|(_, q)| q))
162            .unwrap_or(original_query);
163
164        let rewritten = template
165            .replace("{path}", current_path)
166            .replace("{query}", current_query);
167        new_path_and_query = Some(rewritten.clone());
168
169        debug!(
170            template = template.as_str(),
171            result = rewritten.as_str(),
172            "applied URI rewrite template"
173        );
174    }
175
176    // 3. Regex rules — applied sequentially to the current path.
177    if !mw.regex_rules.is_empty() {
178        let current_path = new_path_and_query
179            .as_deref()
180            .map(|pq| pq.split('?').next().unwrap_or(pq))
181            .unwrap_or(original_path);
182        let current_query = new_path_and_query
183            .as_deref()
184            .and_then(|pq| pq.split_once('?').map(|(_, q)| q))
185            .unwrap_or(original_query);
186
187        let mut path = current_path.to_string();
188        for (re, replacement) in &mw.regex_rules {
189            let replaced = re.replace(&path, replacement.as_str()).into_owned();
190            debug!(
191                pattern = re.as_str(),
192                before = path.as_str(),
193                after = replaced.as_str(),
194                "applied regex rewrite rule"
195            );
196            path = replaced;
197        }
198
199        let pq = if current_query.is_empty() {
200            path
201        } else {
202            format!("{path}?{current_query}")
203        };
204        new_path_and_query = Some(pq);
205    }
206
207    // Apply the rewritten URI.
208    if let Some(pq) = new_path_and_query {
209        let new_uri = rebuild_uri(&original_uri, &pq)?;
210        *req.uri_mut() = new_uri;
211    }
212
213    Ok(())
214}
215
216/// Collapse consecutive slashes in a path segment.
217/// E.g. `//foo///bar` → `/foo/bar`.
218fn collapse_slashes(path: &str) -> String {
219    let mut result = String::with_capacity(path.len());
220    let mut prev_slash = false;
221    for ch in path.chars() {
222        if ch == '/' {
223            if !prev_slash {
224                result.push(ch);
225            }
226            prev_slash = true;
227        } else {
228            result.push(ch);
229            prev_slash = false;
230        }
231    }
232    result
233}
234
235/// Rebuild a URI, replacing only the path-and-query portion.
236fn rebuild_uri(original: &Uri, new_path_and_query: &str) -> Result<Uri, ProxyError> {
237    let mut builder = Uri::builder();
238    if let Some(scheme) = original.scheme() {
239        builder = builder.scheme(scheme.clone());
240    }
241    if let Some(authority) = original.authority() {
242        builder = builder.authority(authority.clone());
243    }
244    builder = builder.path_and_query(new_path_and_query.to_string());
245    builder
246        .build()
247        .map_err(|e| ProxyError::Internal(format!("failed to build rewritten URI: {e}")))
248}