Skip to content

Commit e4f7fed

Browse files
wip - permutive proxy
1 parent c4eb489 commit e4f7fed

File tree

18 files changed

+565
-4
lines changed

18 files changed

+565
-4
lines changed

crates/common/src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ pub enum TrustedServerError {
5050
#[display("Prebid error: {message}")]
5151
Prebid { message: String },
5252

53+
/// Permutive SDK proxy error.
54+
#[display("Permutive SDK error: {message}")]
55+
PermutiveSdk { message: String },
56+
57+
/// Permutive API proxy error.
58+
#[display("Permutive API error: {message}")]
59+
PermutiveApi { message: String },
60+
5361
/// Proxy error.
5462
#[display("Proxy error: {message}")]
5563
Proxy { message: String },
@@ -91,6 +99,8 @@ impl IntoHttpResponse for TrustedServerError {
9199
Self::InvalidUtf8 { .. } => StatusCode::BAD_REQUEST,
92100
Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
93101
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
102+
Self::PermutiveSdk { .. } => StatusCode::BAD_GATEWAY,
103+
Self::PermutiveApi { .. } => StatusCode::BAD_GATEWAY,
94104
Self::Proxy { .. } => StatusCode::BAD_GATEWAY,
95105
Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR,
96106
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,

crates/common/src/html_processor.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub struct HtmlProcessorConfig {
1717
pub request_host: String,
1818
pub request_scheme: String,
1919
pub enable_prebid: bool,
20+
pub enable_permutive: bool,
2021
}
2122

2223
impl HtmlProcessorConfig {
@@ -32,6 +33,7 @@ impl HtmlProcessorConfig {
3233
request_host: request_host.to_string(),
3334
request_scheme: request_scheme.to_string(),
3435
enable_prebid: settings.prebid.auto_configure,
36+
enable_permutive: settings.permutive.auto_configure,
3537
}
3638
}
3739
}
@@ -85,6 +87,14 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
8587
)
8688
}
8789

90+
fn is_permutive_sdk_url(url: &str) -> bool {
91+
let lower = url.to_ascii_lowercase();
92+
// Match: https://*.edge.permutive.app/*-web.js
93+
// Match: https://cdn.permutive.com/*-web.js
94+
(lower.contains(".edge.permutive.app") || lower.contains("cdn.permutive.com"))
95+
&& lower.ends_with("-web.js")
96+
}
97+
8898
let rewriter_settings = RewriterSettings {
8999
element_content_handlers: vec![
90100
// Inject tsjs once at the start of <head>
@@ -103,12 +113,16 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
103113
element!("[href]", {
104114
let patterns = patterns.clone();
105115
let rewrite_prebid = config.enable_prebid;
116+
let rewrite_permutive = config.enable_permutive;
106117
move |el| {
107118
if let Some(href) = el.get_attribute("href") {
108119
// If Prebid auto-config is enabled and this looks like a Prebid script href, rewrite to our extension
109120
if rewrite_prebid && is_prebid_script_url(&href) {
110121
let ext_src = tsjs::ext_script_src();
111122
el.set_attribute("href", &ext_src)?;
123+
} else if rewrite_permutive && is_permutive_sdk_url(&href) {
124+
let permutive_src = tsjs::permutive_script_src();
125+
el.set_attribute("href", &permutive_src)?;
112126
} else {
113127
let new_href = href
114128
.replace(&patterns.https_origin(), &patterns.replacement_url())
@@ -125,11 +139,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
125139
element!("[src]", {
126140
let patterns = patterns.clone();
127141
let rewrite_prebid = config.enable_prebid;
142+
let rewrite_permutive = config.enable_permutive;
128143
move |el| {
129144
if let Some(src) = el.get_attribute("src") {
130145
if rewrite_prebid && is_prebid_script_url(&src) {
131146
let ext_src = tsjs::ext_script_src();
132147
el.set_attribute("src", &ext_src)?;
148+
} else if rewrite_permutive && is_permutive_sdk_url(&src) {
149+
let permutive_src = tsjs::permutive_script_src();
150+
el.set_attribute("src", &permutive_src)?;
133151
} else {
134152
let new_src = src
135153
.replace(&patterns.https_origin(), &patterns.replacement_url())
@@ -238,6 +256,7 @@ mod tests {
238256
request_host: "test.example.com".to_string(),
239257
request_scheme: "https".to_string(),
240258
enable_prebid: false,
259+
enable_permutive: false,
241260
}
242261
}
243262

@@ -318,6 +337,81 @@ mod tests {
318337
assert!(processed.contains("/static/tsjs=tsjs-core.min.js"));
319338
}
320339

340+
#[test]
341+
fn test_injects_tsjs_script_and_rewrites_permutive_refs() {
342+
let html = r#"<html><head>
343+
<script async src="https://myorg.edge.permutive.app/workspace-12345-web.js"></script>
344+
</head><body></body></html>"#;
345+
346+
let mut config = create_test_config();
347+
config.enable_permutive = true; // enable rewriting of Permutive URLs
348+
let processor = create_html_processor(config);
349+
let pipeline_config = PipelineConfig {
350+
input_compression: Compression::None,
351+
output_compression: Compression::None,
352+
chunk_size: 8192,
353+
};
354+
let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
355+
356+
let mut output = Vec::new();
357+
let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
358+
assert!(result.is_ok());
359+
let processed = String::from_utf8_lossy(&output);
360+
assert!(processed.contains("/static/tsjs=tsjs-core.min.js"));
361+
// Permutive references are rewritten to our permutive bundle when auto-configure is on
362+
assert!(processed.contains("/static/tsjs=tsjs-permutive.min.js"));
363+
assert!(!processed.contains("edge.permutive.app"));
364+
}
365+
366+
#[test]
367+
fn test_permutive_cdn_url_rewriting() {
368+
let html = r#"<html><head>
369+
<script async src="https://cdn.permutive.com/autoblog-abc123-web.js"></script>
370+
</head><body></body></html>"#;
371+
372+
let mut config = create_test_config();
373+
config.enable_permutive = true;
374+
let processor = create_html_processor(config);
375+
let pipeline_config = PipelineConfig {
376+
input_compression: Compression::None,
377+
output_compression: Compression::None,
378+
chunk_size: 8192,
379+
};
380+
let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
381+
382+
let mut output = Vec::new();
383+
let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
384+
assert!(result.is_ok());
385+
let processed = String::from_utf8_lossy(&output);
386+
assert!(processed.contains("/static/tsjs=tsjs-permutive.min.js"));
387+
assert!(!processed.contains("cdn.permutive.com"));
388+
}
389+
390+
#[test]
391+
fn test_permutive_disabled_does_not_rewrite() {
392+
let html = r#"<html><head>
393+
<script async src="https://myorg.edge.permutive.app/workspace-12345-web.js"></script>
394+
</head><body></body></html>"#;
395+
396+
let mut config = create_test_config();
397+
config.enable_permutive = false; // disabled
398+
let processor = create_html_processor(config);
399+
let pipeline_config = PipelineConfig {
400+
input_compression: Compression::None,
401+
output_compression: Compression::None,
402+
chunk_size: 8192,
403+
};
404+
let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
405+
406+
let mut output = Vec::new();
407+
let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
408+
assert!(result.is_ok());
409+
let processed = String::from_utf8_lossy(&output);
410+
// When disabled, Permutive URL should remain unchanged
411+
assert!(processed.contains("edge.permutive.app"));
412+
assert!(!processed.contains("tsjs-permutive"));
413+
}
414+
321415
#[test]
322416
fn test_create_html_processor_url_replacement() {
323417
let config = create_test_config();

crates/common/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub mod html_processor;
3535
pub mod http_util;
3636
pub mod models;
3737
pub mod openrtb;
38+
pub mod permutive_proxy;
39+
pub mod permutive_sdk;
3840
pub mod prebid_proxy;
3941
pub mod proxy;
4042
pub mod publisher;
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//! Permutive API proxy for transparent first-party API proxying.
2+
//!
3+
//! This module handles proxying Permutive API calls from the first-party domain
4+
//! to Permutive's API servers, preserving query parameters, headers, and request bodies.
5+
6+
use error_stack::{Report, ResultExt};
7+
use fastly::http::{header, Method};
8+
use fastly::{Request, Response};
9+
10+
use crate::backend::ensure_backend_from_url;
11+
use crate::error::TrustedServerError;
12+
use crate::settings::Settings;
13+
14+
const PERMUTIVE_API_BASE: &str = "https://api.permutive.com";
15+
16+
/// Handles transparent proxying of Permutive API requests.
17+
///
18+
/// This function:
19+
/// 1. Extracts the path after `/permutive/api/`
20+
/// 2. Preserves query parameters
21+
/// 3. Copies request headers and body
22+
/// 4. Forwards to `api.permutive.com`
23+
/// 5. Returns response transparently
24+
///
25+
/// # Example Request Flow
26+
///
27+
/// ```text
28+
/// Browser: GET /permutive/api/v2/projects/abc?key=123
29+
/// ↓
30+
/// Trusted Server processes and forwards:
31+
/// ↓
32+
/// Permutive: GET https://api.permutive.com/v2/projects/abc?key=123
33+
/// ```
34+
///
35+
/// # Errors
36+
///
37+
/// Returns a [`TrustedServerError`] if:
38+
/// - Path extraction fails
39+
/// - Backend communication fails
40+
/// - Request forwarding fails
41+
pub async fn handle_permutive_api_proxy(
42+
_settings: &Settings,
43+
mut req: Request,
44+
) -> Result<Response, Report<TrustedServerError>> {
45+
let original_path = req.get_path();
46+
let method = req.get_method();
47+
48+
log::info!(
49+
"Proxying Permutive API request: {} {}",
50+
method,
51+
original_path
52+
);
53+
54+
// Extract the path after /permutive/api
55+
let api_path = original_path
56+
.strip_prefix("/permutive/api")
57+
.ok_or_else(|| TrustedServerError::PermutiveApi {
58+
message: format!("Invalid Permutive API path: {}", original_path),
59+
})?;
60+
61+
// Build the full Permutive API URL with query parameters
62+
let permutive_url = build_permutive_url(api_path, &req)?;
63+
64+
log::info!("Forwarding to Permutive API: {}", permutive_url);
65+
66+
// Create new request to Permutive
67+
let mut permutive_req = Request::new(method.clone(), &permutive_url);
68+
69+
// Copy relevant headers
70+
copy_request_headers(&req, &mut permutive_req);
71+
72+
// Copy body for POST requests
73+
if has_body(method) {
74+
let body = req.take_body();
75+
permutive_req.set_body(body);
76+
}
77+
78+
// Get backend and forward request
79+
let backend_name = ensure_backend_from_url(PERMUTIVE_API_BASE)?;
80+
81+
let permutive_response = permutive_req
82+
.send(backend_name)
83+
.change_context(TrustedServerError::PermutiveApi {
84+
message: format!("Failed to forward request to {}", permutive_url),
85+
})?;
86+
87+
log::info!(
88+
"Permutive API responded with status: {}",
89+
permutive_response.get_status()
90+
);
91+
92+
// Return response transparently
93+
Ok(permutive_response)
94+
}
95+
96+
/// Builds the full Permutive API URL including query parameters.
97+
fn build_permutive_url(
98+
api_path: &str,
99+
req: &Request,
100+
) -> Result<String, Report<TrustedServerError>> {
101+
// Get query string if present
102+
let query = req
103+
.get_url()
104+
.query()
105+
.map(|q| format!("?{}", q))
106+
.unwrap_or_default();
107+
108+
// Build full URL
109+
let url = format!("{}{}{}", PERMUTIVE_API_BASE, api_path, query);
110+
111+
Ok(url)
112+
}
113+
114+
/// Copies relevant headers from the original request to the Permutive request.
115+
fn copy_request_headers(from: &Request, to: &mut Request) {
116+
// Headers that should be forwarded to Permutive
117+
let headers_to_copy = [
118+
header::CONTENT_TYPE,
119+
header::ACCEPT,
120+
header::USER_AGENT,
121+
header::AUTHORIZATION,
122+
header::ACCEPT_LANGUAGE,
123+
header::ACCEPT_ENCODING,
124+
];
125+
126+
for header_name in &headers_to_copy {
127+
if let Some(value) = from.get_header(header_name) {
128+
to.set_header(header_name, value);
129+
}
130+
}
131+
132+
// Copy any X-* custom headers
133+
for header_name in from.get_header_names() {
134+
let name_str = header_name.as_str();
135+
if name_str.starts_with("x-") || name_str.starts_with("X-") {
136+
if let Some(value) = from.get_header(header_name) {
137+
to.set_header(header_name, value);
138+
}
139+
}
140+
}
141+
}
142+
143+
/// Checks if the HTTP method typically includes a request body.
144+
fn has_body(method: &Method) -> bool {
145+
matches!(method, &Method::POST | &Method::PUT | &Method::PATCH)
146+
}
147+
148+
#[cfg(test)]
149+
mod tests {
150+
use super::*;
151+
152+
#[test]
153+
fn test_path_extraction() {
154+
let test_cases = vec![
155+
("/permutive/api/v2/projects", "/v2/projects"),
156+
("/permutive/api/v1/track", "/v1/track"),
157+
("/permutive/api/", "/"),
158+
("/permutive/api", ""),
159+
];
160+
161+
for (input, expected) in test_cases {
162+
let result = input.strip_prefix("/permutive/api").unwrap_or("");
163+
assert_eq!(result, expected, "Failed for input: {}", input);
164+
}
165+
}
166+
167+
#[test]
168+
fn test_url_building_without_query() {
169+
let api_path = "/v2/projects/abc";
170+
let expected = "https://api.permutive.com/v2/projects/abc";
171+
172+
let url = format!("{}{}", PERMUTIVE_API_BASE, api_path);
173+
assert_eq!(url, expected);
174+
}
175+
176+
#[test]
177+
fn test_url_building_with_query() {
178+
let api_path = "/v2/projects/abc";
179+
let query = "?key=123&foo=bar";
180+
let expected = "https://api.permutive.com/v2/projects/abc?key=123&foo=bar";
181+
182+
let url = format!("{}{}{}", PERMUTIVE_API_BASE, api_path, query);
183+
assert_eq!(url, expected);
184+
}
185+
186+
#[test]
187+
fn test_has_body() {
188+
assert!(has_body(&Method::POST));
189+
assert!(has_body(&Method::PUT));
190+
assert!(has_body(&Method::PATCH));
191+
assert!(!has_body(&Method::GET));
192+
assert!(!has_body(&Method::DELETE));
193+
assert!(!has_body(&Method::HEAD));
194+
assert!(!has_body(&Method::OPTIONS));
195+
}
196+
}

0 commit comments

Comments
 (0)