gatel_core/hoops/
rewrite.rs1use 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
10pub 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 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 let original_uri = req.uri().clone();
93 let original_path = original_uri.path();
94 let original_query = original_uri.query().unwrap_or("");
95
96 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 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 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 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 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 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
216fn 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
235fn 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}