featherbit/plugins/native/
redirect.rs1use async_trait::async_trait;
17use bytes::Bytes;
18use std::collections::HashMap;
19
20use crate::context::Context;
21use crate::plugins::{Plugin, PluginOutput, PluginResult};
22use crate::vars::template::Template;
23use crate::vars::{interpolate, resolve};
24
25pub struct RedirectPlugin {
36 http_to_https: bool,
38 uri_tpl: Option<Template>,
42 ret_code: u16,
44 append_query_string: bool,
46}
47
48impl RedirectPlugin {
49 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
78 let http_to_https = config
79 .get("http_to_https")
80 .and_then(|v| v.as_bool())
81 .unwrap_or(false);
82
83 let uri = config.get("uri").and_then(|v| v.as_str()).map(String::from);
84
85 if http_to_https == uri.is_some() {
86 return Err(
87 "redirect plugin requires exactly one of 'uri' or 'http_to_https: true'"
88 .to_string(),
89 );
90 }
91
92 let ret_code = match config.get("ret_code") {
93 None => 302,
94 Some(v) => {
95 let code = v
96 .as_u64()
97 .filter(|c| (200..600).contains(c))
98 .ok_or("ret_code must be an integer status code >= 200")?;
99 code as u16
100 }
101 };
102
103 let append_query_string = config
104 .get("append_query_string")
105 .and_then(|v| v.as_bool())
106 .unwrap_or(false);
107
108 if http_to_https && append_query_string {
109 return Err(
110 "only one of 'http_to_https' and 'append_query_string' can be configured"
111 .to_string(),
112 );
113 }
114
115 let uri_tpl = uri.as_deref().map(|s| Template::parse(s).0);
118
119 Ok(Self {
120 http_to_https,
121 uri_tpl,
122 ret_code,
123 append_query_string,
124 })
125 }
126}
127
128#[async_trait]
129impl Plugin for RedirectPlugin {
130 fn plugin_type(&self) -> &str {
131 "redirect"
132 }
133
134 async fn execute(&self, mut ctx: Context) -> PluginResult {
135 let (new_uri, ret_code) = if self.http_to_https {
136 let scheme = ctx
138 .request
139 .headers
140 .get("x-forwarded-proto")
141 .and_then(|v| v.first())
142 .cloned()
143 .unwrap_or_else(|| ctx.request.scheme.clone());
144
145 if scheme == "https" {
146 return Ok(PluginOutput::success(ctx));
148 }
149
150 let ret_code = match ctx.request.method.as_str() {
151 "GET" | "HEAD" => 301,
152 _ => 308,
154 };
155 (interpolate(&ctx, "https://$host$request_uri"), ret_code)
156 } else {
157 let mut new_uri = match &self.uri_tpl {
159 Some(tpl) => tpl.render_with_legacy(&ctx),
160 None => String::new(),
161 };
162
163 if self.append_query_string {
164 if let Some(qs) = resolve(&ctx, "query_string") {
165 let sep = if new_uri.contains('?') { '&' } else { '?' };
166 new_uri.push(sep);
167 new_uri.push_str(&qs);
168 }
169 }
170 (new_uri, self.ret_code)
171 };
172
173 ctx.response.status_code = ret_code;
174 ctx.response
175 .headers
176 .insert("location".to_string(), vec![new_uri]);
177 ctx.response.body = Bytes::new();
180 ctx.response.headers.remove("content-length");
181 ctx.response.headers.remove("content-encoding");
182
183 Ok(PluginOutput::on_port(ctx, "redirect"))
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
194
195 fn test_context(path: &str) -> Context {
196 Context {
197 request: GatewayRequest {
198 method: "GET".to_string(),
199 path: path.to_string(),
200 host: "example.com".to_string(),
201 scheme: "http".to_string(),
202 headers: HashMap::new(),
203 query_params: HashMap::new(),
204 body: Bytes::new(),
205 remote_addr: "127.0.0.1:12345".to_string(),
206 protocol: Protocol::Http1,
207 },
208 response: GatewayResponse {
209 status_code: 0,
210 headers: HashMap::new(),
211 body: Bytes::new(),
212 stream: None,
213 },
214 message: HashMap::new(),
215 errors: Vec::new(),
216 }
217 }
218
219 fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
220 serde_json::from_value(json).unwrap()
221 }
222
223 #[test]
224 fn test_redirect_config_validation() {
225 assert!(RedirectPlugin::from_config(&HashMap::new()).is_err());
227 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
229 "uri": "/new",
230 "http_to_https": true
231 })))
232 .is_err());
233 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
235 "http_to_https": false
236 })))
237 .is_err());
238 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
240 "http_to_https": true,
241 "append_query_string": true
242 })))
243 .is_err());
244 assert!(RedirectPlugin::from_config(&config(serde_json::json!({
246 "uri": "/new",
247 "ret_code": 100
248 })))
249 .is_err());
250 assert!(RedirectPlugin::from_config(&config(serde_json::json!({"uri": "/new"}))).is_ok());
252 assert!(
253 RedirectPlugin::from_config(&config(serde_json::json!({"http_to_https": true})))
254 .is_ok()
255 );
256 }
257
258 #[tokio::test]
259 async fn test_redirect_uri_template() {
260 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
261 "uri": "https://$host/moved$uri"
262 })))
263 .unwrap();
264
265 let result = plugin.execute(test_context("/old/path")).await.unwrap();
266 assert_eq!(result.port, Some("redirect"));
267 let ctx = result.context;
268 assert_eq!(ctx.response.status_code, 302); assert_eq!(
270 ctx.response.headers.get("location"),
271 Some(&vec!["https://example.com/moved/old/path".to_string()])
272 );
273 assert!(ctx.response.body.is_empty());
274 }
275
276 #[tokio::test]
279 async fn test_redirect_exits_on_redirect_port() {
280 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
281 "uri": "/new"
282 })))
283 .unwrap();
284
285 let out = plugin.execute(test_context("/old")).await.unwrap();
286 assert_eq!(out.port, Some("redirect"));
287 assert_eq!(out.context.response.status_code, 302);
288 }
289
290 #[tokio::test]
291 async fn test_redirect_uri_superset_template_and_legacy_dollar() {
292 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
295 "uri": "{{request.scheme}}://x$uri"
296 })))
297 .unwrap();
298
299 let result = plugin.execute(test_context("/p")).await.unwrap();
300 assert_eq!(
301 result.context.response.headers.get("location"),
302 Some(&vec!["http://x/p".to_string()])
303 );
304 }
305
306 #[tokio::test]
307 async fn test_redirect_custom_ret_code() {
308 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
309 "uri": "/new",
310 "ret_code": 301
311 })))
312 .unwrap();
313
314 let result = plugin.execute(test_context("/old")).await.unwrap();
315 assert_eq!(result.port, Some("redirect"));
316 assert_eq!(result.context.response.status_code, 301);
317 }
318
319 #[tokio::test]
320 async fn test_redirect_append_query_string() {
321 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
322 "uri": "/new",
323 "append_query_string": true
324 })))
325 .unwrap();
326
327 let mut ctx = test_context("/old");
328 ctx.request
329 .query_params
330 .insert("a".to_string(), vec!["1".to_string()]);
331 let result = plugin.execute(ctx).await.unwrap();
332 assert_eq!(result.port, Some("redirect"));
333 assert_eq!(
334 result.context.response.headers.get("location"),
335 Some(&vec!["/new?a=1".to_string()])
336 );
337
338 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
340 "uri": "/new?x=y",
341 "append_query_string": true
342 })))
343 .unwrap();
344 let mut ctx = test_context("/old");
345 ctx.request
346 .query_params
347 .insert("a".to_string(), vec!["1".to_string()]);
348 let result = plugin.execute(ctx).await.unwrap();
349 assert_eq!(result.port, Some("redirect"));
350 assert_eq!(
351 result.context.response.headers.get("location"),
352 Some(&vec!["/new?x=y&a=1".to_string()])
353 );
354
355 let result = plugin.execute(test_context("/old")).await.unwrap();
357 assert_eq!(result.port, Some("redirect"));
358 assert_eq!(
359 result.context.response.headers.get("location"),
360 Some(&vec!["/new?x=y".to_string()])
361 );
362 }
363
364 #[tokio::test]
365 async fn test_redirect_http_to_https() {
366 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
367 "http_to_https": true
368 })))
369 .unwrap();
370
371 let mut ctx = test_context("/path");
373 ctx.request
374 .query_params
375 .insert("a".to_string(), vec!["1".to_string()]);
376 let result = plugin.execute(ctx).await.unwrap();
377 assert_eq!(result.port, Some("redirect"));
378 assert_eq!(result.context.response.status_code, 301);
379 assert_eq!(
380 result.context.response.headers.get("location"),
381 Some(&vec!["https://example.com/path?a=1".to_string()])
382 );
383
384 let mut ctx = test_context("/path");
386 ctx.request.method = "POST".to_string();
387 let result = plugin.execute(ctx).await.unwrap();
388 assert_eq!(result.port, Some("redirect"));
389 assert_eq!(result.context.response.status_code, 308);
390 }
391
392 #[tokio::test]
393 async fn test_redirect_http_to_https_already_https_passthrough() {
394 let plugin = RedirectPlugin::from_config(&config(serde_json::json!({
395 "http_to_https": true
396 })))
397 .unwrap();
398
399 let mut ctx = test_context("/path");
401 ctx.request.scheme = "https".to_string();
402 let result = plugin.execute(ctx).await.unwrap();
403 assert_eq!(result.port, None);
404 assert_eq!(result.context.response.status_code, 0);
405 assert!(!result.context.response.headers.contains_key("location"));
406
407 let mut ctx = test_context("/path");
409 ctx.request
410 .headers
411 .insert("x-forwarded-proto".to_string(), vec!["https".to_string()]);
412 let result = plugin.execute(ctx).await.unwrap();
413 assert_eq!(result.port, None);
414 assert_eq!(result.context.response.status_code, 0);
415 }
416}