use super::{Error, get_bool_conf, get_hash_key, get_int_conf, get_str_conf};
use async_trait::async_trait;
use http::{HeaderValue, StatusCode, header};
use pingap_config::{PluginCategory, PluginConf};
use pingap_core::{
Ctx, HttpResponse, Plugin, PluginStep, RequestPluginResult, get_host,
new_internal_error,
};
use pingora::proxy::Session;
use std::borrow::Cow;
use tracing::debug;
type Result<T, E = Error> = std::result::Result<T, E>;
pub struct Redirect {
prefix: String,
http_to_https: bool,
status: StatusCode,
plugin_step: PluginStep,
hash_value: String,
}
impl Redirect {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(params = params.to_string(), "new redirect plugin");
let hash_value = get_hash_key(params);
let mut prefix = get_str_conf(params, "prefix");
if prefix.len() <= 1 {
prefix = "".to_string();
} else if !prefix.starts_with("/") {
prefix = format!("/{prefix}");
}
let status = match get_int_conf(params, "status") {
0 | 307 => StatusCode::TEMPORARY_REDIRECT,
301 => StatusCode::MOVED_PERMANENTLY,
302 => StatusCode::FOUND,
303 => StatusCode::SEE_OTHER,
308 => StatusCode::PERMANENT_REDIRECT,
other => {
return Err(Error::Invalid {
category: PluginCategory::Redirect.to_string(),
message: format!(
"Invalid status({other}), expect 301, 302, 303, 307 or 308"
),
});
},
};
Ok(Self {
hash_value,
prefix,
http_to_https: get_bool_conf(params, "http_to_https"),
status,
plugin_step: PluginStep::Request,
})
}
}
#[async_trait]
impl Plugin for Redirect {
#[inline]
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
#[inline]
async fn handle_request(
&self,
step: PluginStep,
session: &mut Session,
ctx: &mut Ctx,
) -> pingora::Result<RequestPluginResult> {
if step != self.plugin_step {
return Ok(RequestPluginResult::Skipped);
}
let schema_match = ctx.conn.tls_version.is_some() == self.http_to_https;
if schema_match
&& session.req_header().uri.path().starts_with(&self.prefix)
{
return Ok(RequestPluginResult::Skipped);
}
let host = get_host(session.req_header()).unwrap_or_default();
let schema = if self.http_to_https { "https" } else { "http" };
let prefix =
if session.req_header().uri.path().starts_with(&self.prefix) {
""
} else {
self.prefix.as_str()
};
let location = HeaderValue::from_str(&format!(
"{schema}://{host}{prefix}{}",
session.req_header().uri
))
.map_err(|e| new_internal_error(400, e))?;
Ok(RequestPluginResult::Respond(HttpResponse {
status: self.status,
headers: Some(vec![(header::LOCATION, location)]),
..Default::default()
}))
}
}
register_plugin!("redirect", Redirect);
#[cfg(test)]
mod tests {
use super::*;
use http::StatusCode;
use pingap_config::PluginConf;
use pingap_core::{Ctx, PluginStep};
use pingora::proxy::Session;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
#[tokio::test]
async fn test_redirect() {
let redirect = Redirect::new(
&toml::from_str::<PluginConf>(
r###"
http_to_https = true
prefix = "/api"
"###,
)
.unwrap(),
)
.unwrap();
let headers = ["Host: github.com"].join("\r\n");
let input_header =
format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = redirect
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(StatusCode::TEMPORARY_REDIRECT, resp.status);
assert_eq!(
r###"Some([("location", "https://github.com/api/vicanso/pingap?size=1")])"###,
format!("{:?}", resp.headers)
);
}
#[test]
fn test_redirect_rejects_other_statuses() {
for status in [200, 304, 404, 399] {
let err = Redirect::new(
&toml::from_str::<PluginConf>(&format!(
"http_to_https = true\nstatus = {status}"
))
.unwrap(),
)
.err()
.unwrap()
.to_string();
assert_eq!(
format!(
"Plugin redirect invalid, message: Invalid status({status}), expect 301, 302, 303, 307 or 308"
),
err
);
}
}
#[tokio::test]
async fn test_redirect_with_status() {
for (status_conf, expected_status) in [
(301, StatusCode::MOVED_PERMANENTLY),
(302, StatusCode::FOUND),
(303, StatusCode::SEE_OTHER),
(307, StatusCode::TEMPORARY_REDIRECT),
(308, StatusCode::PERMANENT_REDIRECT),
] {
let redirect = Redirect::new(
&toml::from_str::<PluginConf>(&format!(
r###"
http_to_https = true
status = {status_conf}
"###,
))
.unwrap(),
)
.unwrap();
let headers = ["Host: github.com"].join("\r\n");
let input_header =
format!("GET /vicanso/pingap HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = redirect
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond for status {status_conf}");
};
assert_eq!(expected_status, resp.status);
}
}
#[tokio::test]
async fn test_redirect_keeps_existing_prefix() {
let redirect = Redirect::new(
&toml::from_str::<PluginConf>(
r###"
http_to_https = true
prefix = "/api"
"###,
)
.unwrap(),
)
.unwrap();
let headers = ["Host: github.com"].join("\r\n");
let input_header =
format!("GET /api/vicanso?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
let mock_io = Builder::new().read(input_header.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = redirect
.handle_request(
PluginStep::Request,
&mut session,
&mut Ctx::default(),
)
.await
.unwrap();
let RequestPluginResult::Respond(resp) = result else {
panic!("result is not Respond");
};
assert_eq!(
r###"Some([("location", "https://github.com/api/vicanso?size=1")])"###,
format!("{:?}", resp.headers)
);
}
}