use super::{Error, get_hash_key, get_str_conf, get_str_slice_conf};
use async_trait::async_trait;
use bstr::ByteSlice;
use bytes::{Bytes, BytesMut};
use http::header::CONTENT_ENCODING;
use http::{Method, StatusCode};
use pingap_config::{PluginCategory, PluginConf};
use pingap_core::{
Ctx, HTTP_HEADER_TRANSFER_CHUNKED, ModifyResponseBody, Plugin,
ResponseBodyPluginResult, ResponsePluginResult,
};
use pingora::http::ResponseHeader;
use pingora::proxy::Session;
use regex::Regex;
use regex::bytes::RegexBuilder;
use std::borrow::Cow;
use std::sync::{Arc, LazyLock};
const PLUGIN_ID: &str = "_sub_filter_";
type Result<T, E = Error> = std::result::Result<T, E>;
pub struct SubFilter {
path: Option<Regex>,
filters: Arc<[SubFilterParams]>,
hash_value: String,
status_codes: Option<Vec<u16>>,
}
static SUBS_FILTER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(subs_filter|sub_filter)\s+'([^']+)'\s+'([^']+)'(?:\s+([ig]+))?",
)
.expect("Failed to compile subs filter regex")
});
#[derive(Debug)]
struct SubFilterParams {
regex_pattern: Option<regex::bytes::Regex>,
pattern: Vec<u8>,
replacement: Vec<u8>,
global: bool,
}
impl SubFilterParams {
fn apply(&self, data: Vec<u8>) -> Vec<u8> {
if let Some(regex_pattern) = &self.regex_pattern {
let replaced = if self.global {
regex_pattern.replace_all(&data, &self.replacement)
} else {
regex_pattern.replace(&data, &self.replacement)
};
match replaced {
Cow::Borrowed(_) => data,
Cow::Owned(replaced) => replaced,
}
} else if data.find(&self.pattern).is_none() {
data
} else if self.global {
data.replace(&self.pattern, &self.replacement)
} else {
data.replacen(&self.pattern, &self.replacement, 1)
}
}
}
fn parse_subs_filter(rule: &str) -> Option<SubFilterParams> {
let captures = SUBS_FILTER_REGEX.captures(rule)?;
let flags = captures.get(4).map(|m| m.as_str()).unwrap_or_default();
let pattern = captures.get(2)?.as_str();
let mut params = SubFilterParams {
regex_pattern: None,
pattern: vec![],
replacement: captures.get(3)?.as_str().as_bytes().to_vec(),
global: flags.contains('g'),
};
if captures.get(1)?.as_str() == "subs_filter" {
params.regex_pattern = Some(
RegexBuilder::new(pattern)
.case_insensitive(flags.contains('i'))
.build()
.ok()?,
);
} else {
params.pattern = pattern.as_bytes().to_vec();
}
Some(params)
}
struct SubFilterReplacer {
filters: Arc<[SubFilterParams]>,
buffer: BytesMut,
}
impl ModifyResponseBody for SubFilterReplacer {
fn handle(
&mut self,
_session: &Session,
body: &mut Option<bytes::Bytes>,
end_of_stream: bool,
) -> pingora::Result<()> {
if let Some(data) = body {
self.buffer.extend_from_slice(data);
data.clear();
}
if !end_of_stream {
return Ok(());
}
let mut data: Vec<u8> = std::mem::take(&mut self.buffer).into();
for item in self.filters.iter() {
data = item.apply(data);
}
*body = Some(Bytes::from(data));
Ok(())
}
fn name(&self) -> &str {
"sub_filter"
}
}
fn has_no_rewritable_body(
session: &Session,
upstream_response: &ResponseHeader,
) -> bool {
let status = upstream_response.status;
session.req_header().method == Method::HEAD
|| status.is_informational()
|| status == StatusCode::NO_CONTENT
|| status == StatusCode::NOT_MODIFIED
|| upstream_response.headers.get(CONTENT_ENCODING).is_some_and(
|value| !value.as_bytes().eq_ignore_ascii_case(b"identity"),
)
}
impl TryFrom<&PluginConf> for SubFilter {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let invalid = |message: String| Error::Invalid {
category: PluginCategory::SubFilter.to_string(),
message,
};
let path_value = get_str_conf(value, "path");
let path = if path_value.is_empty() {
None
} else {
Some(Regex::new(&path_value).map_err(|e| invalid(e.to_string()))?)
};
let filters = get_str_slice_conf(value, "filters")
.iter()
.map(|s| {
parse_subs_filter(s)
.ok_or_else(|| invalid(format!("invalid subs filter: {s}")))
})
.collect::<Result<Vec<_>>>()?;
let status_codes = get_str_conf(value, "status_codes");
let status_codes = if !status_codes.is_empty() {
Some(
status_codes
.split(',')
.map(|s| {
s.trim().parse::<u16>().map_err(|e| {
invalid(format!("invalid status code({s}): {e}"))
})
})
.collect::<Result<Vec<_>>>()?,
)
} else {
None
};
let hash_value = get_hash_key(value);
Ok(Self {
path,
filters: filters.into(),
hash_value,
status_codes,
})
}
}
impl SubFilter {
pub fn new(params: &PluginConf) -> Result<Self> {
Self::try_from(params)
}
}
#[async_trait]
impl Plugin for SubFilter {
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
async fn handle_response(
&self,
session: &mut Session,
ctx: &mut Ctx,
upstream_response: &mut ResponseHeader,
) -> pingora::Result<ResponsePluginResult> {
if let Some(status_codes) = &self.status_codes
&& !status_codes.contains(&upstream_response.status.as_u16())
{
return Ok(ResponsePluginResult::Unchanged);
}
if has_no_rewritable_body(session, upstream_response) {
return Ok(ResponsePluginResult::Unchanged);
}
if let Some(regex) = &self.path
&& !regex.is_match(session.req_header().uri.path())
{
return Ok(ResponsePluginResult::Unchanged);
}
upstream_response.remove_header(&http::header::CONTENT_LENGTH);
let _ = upstream_response.insert_header(
http::header::TRANSFER_ENCODING,
HTTP_HEADER_TRANSFER_CHUNKED.1.clone(),
);
ctx.add_modify_body_handler(
PLUGIN_ID,
Box::new(SubFilterReplacer {
filters: self.filters.clone(),
buffer: BytesMut::new(),
}),
);
Ok(ResponsePluginResult::Modified)
}
fn handle_response_body(
&self,
session: &mut Session,
ctx: &mut Ctx,
body: &mut Option<bytes::Bytes>,
end_of_stream: bool,
) -> pingora::Result<ResponseBodyPluginResult> {
if let Some(modifier) = ctx.get_modify_body_handler(PLUGIN_ID) {
modifier.handle(session, body, end_of_stream)?;
let result = if end_of_stream {
ResponseBodyPluginResult::FullyReplaced
} else {
ResponseBodyPluginResult::PartialReplaced
};
Ok(result)
} else {
Ok(ResponseBodyPluginResult::Unchanged)
}
}
}
register_plugin!("sub_filter", SubFilter);
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
#[test]
fn test_parse_subs_filter() {
let rule = "subs_filter 'http://pingap.io' 'https://pingap.io/api' ig";
let params = parse_subs_filter(rule).unwrap();
assert_eq!(params.regex_pattern.unwrap().as_str(), "http://pingap.io");
assert_eq!(params.pattern, b"");
assert_eq!(params.replacement, b"https://pingap.io/api");
assert_eq!(true, params.global);
let rule = "sub_filter 'http://pingap.io' 'https://pingap.io/api'";
let params = parse_subs_filter(rule).unwrap();
assert_eq!(params.regex_pattern.is_none(), true);
assert_eq!(params.pattern, b"http://pingap.io");
assert_eq!(params.replacement, b"https://pingap.io/api");
assert_eq!(false, params.global);
assert_eq!(true, parse_subs_filter("sub_filter 'a'").is_none());
assert_eq!(true, parse_subs_filter("subs_filter '(' 'b'").is_none());
}
#[test]
fn test_apply() {
let apply = |rule: &str, data: &[u8]| {
String::from_utf8(
parse_subs_filter(rule).unwrap().apply(data.to_vec()),
)
.unwrap()
};
let data = b"http://pingap.io http://PinGap.io http://pingap.io";
assert_eq!(
"https://pingap.io/api https://pingap.io/api https://pingap.io/api",
apply(
"subs_filter 'http://pingap.io' 'https://pingap.io/api' ig",
data
)
);
assert_eq!(
"https://pingap.io/api http://PinGap.io https://pingap.io/api",
apply(
"subs_filter 'http://pingap.io' 'https://pingap.io/api' g",
data
)
);
assert_eq!(
"https://pingap.io/api http://PinGap.io http://pingap.io",
apply(
"subs_filter 'http://pingap.io' 'https://pingap.io/api'",
data
)
);
assert_eq!(
"https://pingap.io/api http://PinGap.io http://pingap.io",
apply(
"sub_filter 'http://pingap.io' 'https://pingap.io/api'",
data
)
);
assert_eq!(
"https://pingap.io/api http://PinGap.io https://pingap.io/api",
apply(
"sub_filter 'http://pingap.io' 'https://pingap.io/api' g",
data
)
);
assert_eq!(
"<title>Docs - x</title>",
apply(
"subs_filter '<title>(.*?)</title>' '<title>Docs - $1</title>'",
b"<title>x</title>"
)
);
assert_eq!("untouched", apply("sub_filter 'zzz' 'y' g", b"untouched"));
}
async fn new_session(input: &str) -> Session {
let mock_io = Builder::new().read(input.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
session
}
fn new_plugin(extra: &str) -> SubFilter {
SubFilter::try_from(
&toml::from_str::<PluginConf>(&format!(
"filters = [\"sub_filter 'old' 'new' g\"]\n{extra}"
))
.unwrap(),
)
.unwrap()
}
#[tokio::test]
async fn test_sub_filter() {
let plugin = new_plugin("status_codes = \"200, 201\"");
assert_eq!(Some(vec![200, 201]), plugin.status_codes);
let mut session = new_session("GET / HTTP/1.1\r\n\r\n").await;
let mut ctx = Ctx::default();
let mut resp = ResponseHeader::build(200, None).unwrap();
resp.append_header("Content-Length", "7").unwrap();
let result = plugin
.handle_response(&mut session, &mut ctx, &mut resp)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Modified, result);
assert_eq!(false, resp.headers.contains_key("Content-Length"));
let mut body = Some(Bytes::from_static(b"old "));
let result = plugin
.handle_response_body(&mut session, &mut ctx, &mut body, false)
.unwrap();
assert_eq!(ResponseBodyPluginResult::PartialReplaced, result);
assert_eq!(true, body.as_ref().unwrap().is_empty());
let mut body = Some(Bytes::from_static(b"old"));
let result = plugin
.handle_response_body(&mut session, &mut ctx, &mut body, true)
.unwrap();
assert_eq!(ResponseBodyPluginResult::FullyReplaced, result);
assert_eq!(b"new new".as_ref(), body.unwrap().as_ref());
let mut resp = ResponseHeader::build(404, None).unwrap();
let result = plugin
.handle_response(&mut session, &mut Ctx::default(), &mut resp)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Unchanged, result);
let err = SubFilter::try_from(
&toml::from_str::<PluginConf>("status_codes = \"200,abc\"")
.unwrap(),
)
.err()
.unwrap()
.to_string();
assert_eq!(true, err.contains("invalid status code(abc)"), "{err}");
}
#[tokio::test]
async fn test_sub_filter_leaves_bodiless_responses() {
let plugin = new_plugin("");
let mut session = new_session("GET / HTTP/1.1\r\n\r\n").await;
for status in [204, 304, 100] {
let mut resp = ResponseHeader::build(status, None).unwrap();
let result = plugin
.handle_response(&mut session, &mut Ctx::default(), &mut resp)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Unchanged, result, "{status}");
}
let mut resp = ResponseHeader::build(200, None).unwrap();
resp.append_header("Content-Encoding", "gzip").unwrap();
resp.append_header("Content-Length", "7").unwrap();
let result = plugin
.handle_response(&mut session, &mut Ctx::default(), &mut resp)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Unchanged, result);
assert_eq!(true, resp.headers.contains_key("Content-Length"));
let mut session = new_session("HEAD / HTTP/1.1\r\n\r\n").await;
let mut resp = ResponseHeader::build(200, None).unwrap();
let result = plugin
.handle_response(&mut session, &mut Ctx::default(), &mut resp)
.await
.unwrap();
assert_eq!(ResponsePluginResult::Unchanged, result);
}
}