Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub enum TrustedServerError {
#[display("Invalid UTF-8 data: {message}")]
InvalidUtf8 { message: String },

/// Serialization error.
#[display("Serialization error: {message}")]
Serialization { message: String },

/// HTTP header value creation failed.
#[display("Invalid HTTP header value: {message}")]
InvalidHeaderValue { message: String },
Expand Down Expand Up @@ -100,6 +104,7 @@ impl IntoHttpResponse for TrustedServerError {
Self::GdprConsent { .. } => StatusCode::BAD_REQUEST,
Self::InsecureSecretKey => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST,
Self::Serialization { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidUtf8 { .. } => StatusCode::BAD_REQUEST,
Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
Expand Down
30 changes: 30 additions & 0 deletions crates/common/src/geo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,33 @@ pub fn get_dma_code(req: &mut Request) -> Option<String> {

None
}

/// Returns the geographic information for the request as a JSON response.
///
/// Use this endpoint to get the client's location data (City, Country, DMA, etc.)
/// without making a third-party API call.
///
/// # Errors
///
/// Returns a 500 error if JSON serialization fails (unlikely).
pub fn handle_first_party_geo(
req: &Request,
) -> Result<fastly::Response, error_stack::Report<crate::error::TrustedServerError>> {
use crate::error::TrustedServerError;
use error_stack::ResultExt;
use fastly::http::{header, StatusCode};
use fastly::Response;

let geo_info = GeoInfo::from_request(req);

// Create a JSON response
let body =
serde_json::to_string(&geo_info).change_context(TrustedServerError::Serialization {
message: "Failed to serialize geo info".to_string(),
})?;

Ok(Response::from_body(body)
.with_status(StatusCode::OK)
.with_header(header::CONTENT_TYPE, "application/json")
.with_header("Cache-Control", "private, no-store"))
}
11 changes: 11 additions & 0 deletions crates/common/src/html_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct HtmlWithPostProcessing {
request_host: String,
request_scheme: String,
document_state: IntegrationDocumentState,
geo_info: Option<crate::geo::GeoInfo>,
}

impl StreamProcessor for HtmlWithPostProcessing {
Expand All @@ -42,6 +43,7 @@ impl StreamProcessor for HtmlWithPostProcessing {
request_scheme: &self.request_scheme,
origin_host: &self.origin_host,
document_state: &self.document_state,
geo: self.geo_info.as_ref(),
};

// Preflight to avoid allocating a `String` unless at least one post-processor wants to run.
Expand Down Expand Up @@ -90,6 +92,7 @@ pub struct HtmlProcessorConfig {
pub request_host: String,
pub request_scheme: String,
pub integrations: IntegrationRegistry,
pub geo_info: Option<crate::geo::GeoInfo>,
}

impl HtmlProcessorConfig {
Expand All @@ -101,12 +104,14 @@ impl HtmlProcessorConfig {
origin_host: &str,
request_host: &str,
request_scheme: &str,
geo_info: Option<&crate::geo::GeoInfo>,
) -> Self {
Self {
origin_host: origin_host.to_string(),
request_host: request_host.to_string(),
request_scheme: request_scheme.to_string(),
integrations: integrations.clone(),
geo_info: geo_info.cloned(),
}
}
}
Expand All @@ -116,6 +121,7 @@ impl HtmlProcessorConfig {
pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor {
let post_processors = config.integrations.html_post_processors();
let document_state = IntegrationDocumentState::default();
let geo_info = config.geo_info.clone();

// Simplified URL patterns structure - stores only core data and generates variants on-demand
struct UrlPatterns {
Expand Down Expand Up @@ -194,6 +200,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
let integrations = integration_registry.clone();
let patterns = patterns.clone();
let document_state = document_state.clone();
let geo_info = geo_info.clone();
move |el| {
if !injected_tsjs.get() {
let mut snippet = String::new();
Expand All @@ -202,6 +209,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
document_state: &document_state,
geo: geo_info.as_ref(),
};
// First inject the unified TSJS bundle (defines tsjs.setConfig, etc.)
snippet.push_str(&tsjs::unified_script_tag());
Expand Down Expand Up @@ -466,6 +474,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
request_host: config.request_host,
request_scheme: config.request_scheme,
document_state,
geo_info: config.geo_info,
}
}

Expand All @@ -488,6 +497,7 @@ mod tests {
request_host: "test.example.com".to_string(),
request_scheme: "https".to_string(),
integrations: IntegrationRegistry::default(),
geo_info: None,
}
}

Expand Down Expand Up @@ -665,6 +675,7 @@ mod tests {
"origin.test-publisher.com",
"proxy.example.com",
"https",
None,
);

assert_eq!(config.origin_host, "origin.test-publisher.com");
Expand Down
1 change: 1 addition & 0 deletions crates/common/src/integrations/nextjs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ mod tests {
"origin.example.com",
"test.example.com",
"https",
None,
)
}

Expand Down
197 changes: 190 additions & 7 deletions crates/common/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ pub struct PrebidIntegrationConfig {
deserialize_with = "crate::settings::vec_from_seq_or_map"
)]
pub script_patterns: Vec<String>,
#[serde(default)]
pub auto_configure: bool,

/// Ad Units configuration
#[serde(default)]
pub ad_units: Vec<serde_json::Value>,
}

impl IntegrationConfig for PrebidIntegrationConfig {
Expand Down Expand Up @@ -192,15 +198,94 @@ fn build(settings: &Settings) -> Option<Arc<PrebidIntegration>> {
Some(PrebidIntegration::new(config))
}

pub struct PrebidHtmlInjector {
config: PrebidIntegrationConfig,
}

impl PrebidHtmlInjector {
fn new(config: PrebidIntegrationConfig) -> Arc<Self> {
Arc::new(Self { config })
}
}

impl crate::integrations::IntegrationHtmlPostProcessor for PrebidHtmlInjector {
fn integration_id(&self) -> &'static str {
PREBID_INTEGRATION_ID
}

fn should_process(
&self,
html: &str,
_ctx: &crate::integrations::IntegrationHtmlContext<'_>,
) -> bool {
// Only inject if there's a head tag (to be safe) and we haven't already injected
html.contains("<head")
}

fn post_process(
&self,
html: &mut String,
ctx: &crate::integrations::IntegrationHtmlContext<'_>,
) -> bool {
// Construct the Prebid configuration object
let config = json!({
"accountId": "trusted-server",
"enabled": true,
"bidders": self.config.bidders,
"timeout": self.config.timeout_ms,
"adapter": "prebidServer",
"endpoint": format!("{}://{}/openrtb2/auction", ctx.request_scheme, ctx.request_host),
"syncEndpoint": format!("{}://{}/cookie_sync", ctx.request_scheme, ctx.request_host),
"cookieSet": true,
"cookiesetUrl": format!("{}://{}/setuid", ctx.request_scheme, ctx.request_host),
"adUnits": self.config.ad_units,
"debug": self.config.debug,
});

// Script to inject configuration and initialize Prebid via tsjs
let script = format!(
r#"<script>
window.__tsjs_prebid = {};
if (window.tsjs && window.tsjs.modules && window.tsjs.modules.prebid) {{
window.tsjs.modules.prebid.init();
}}
</script>"#,
config
);

// Inject after <head>
if let Some(idx) = html.find("<head>") {
let insert_point = idx + 6;
html.insert_str(insert_point, &script);
true
} else if let Some(idx) = html.find("<head") {
// Handle case with attributes like <head class="...">
if let Some(close_idx) = html[idx..].find('>') {
let insert_point = idx + close_idx + 1;
html.insert_str(insert_point, &script);
true
} else {
false
}
} else {
false
}
}
}

#[must_use]
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
let integration = build(settings)?;
Some(
IntegrationRegistration::builder(PREBID_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
.build(),
)
let mut builder = IntegrationRegistration::builder(PREBID_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration.clone());

if integration.config.auto_configure {
builder =
builder.with_html_post_processor(PrebidHtmlInjector::new(integration.config.clone()));
}

Some(builder.build())
}

#[async_trait(?Send)]
Expand Down Expand Up @@ -753,7 +838,10 @@ pub fn register_auction_provider(settings: &Settings) -> Vec<Arc<dyn AuctionProv
mod tests {
use super::*;
use crate::html_processor::{create_html_processor, HtmlProcessorConfig};
use crate::integrations::{AttributeRewriteAction, IntegrationRegistry};
use crate::integrations::{
AttributeRewriteAction, IntegrationDocumentState, IntegrationHtmlContext,
IntegrationHtmlPostProcessor, IntegrationRegistry,
};
use crate::settings::Settings;
use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
use crate::test_support::tests::crate_test_settings_str;
Expand All @@ -774,6 +862,8 @@ mod tests {
debug: false,
debug_query_params: None,
script_patterns: default_script_patterns(),
auto_configure: false,
ad_units: vec![],
}
}

Expand All @@ -787,6 +877,7 @@ mod tests {
"origin.example.com",
"test.example.com",
"https",
None,
)
}

Expand Down Expand Up @@ -1117,4 +1208,96 @@ server_url = "https://prebid.example"
// Should have 0 routes when no script patterns configured
assert_eq!(routes.len(), 0);
}

#[test]
fn test_prebid_html_injector_injection_logic() {
let config = PrebidIntegrationConfig {
enabled: true,
server_url: "http://localhost:8080".to_string(),
timeout_ms: 2000,
bidders: vec!["bidderA".to_string(), "bidderB".to_string()],
debug: false,
debug_query_params: None,
script_patterns: vec![],
auto_configure: true,
ad_units: vec![],
};
let injector = PrebidHtmlInjector::new(config);
let params = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &params,
geo: None,
};

// Case 1: Simple <head>
let mut html = "<html><head><title>Test</title></head><body></body></html>".to_string();
assert!(injector.post_process(&mut html, &ctx));
assert!(html.starts_with("<html><head><script>"));
assert!(html.contains("window.__tsjs_prebid ="));
assert!(html.contains("window.tsjs.modules.prebid.init()"));

// Case 2: Head with attributes
let mut html =
r#"<html><head lang="en"><title>Test</title></head><body></body></html>"#.to_string();
assert!(injector.post_process(&mut html, &ctx));
assert!(html.starts_with(r#"<html><head lang="en"><script>"#));

// Case 3: No head
let mut html = "<html><body></body></html>".to_string();
assert!(!injector.post_process(&mut html, &ctx));
assert!(!html.contains("window.__tsjs_prebid ="));
}

#[test]
fn test_prebid_html_injector_config_content() {
let config = PrebidIntegrationConfig {
enabled: true,
server_url: "http://localhost:8080".to_string(),
timeout_ms: 2000,
bidders: vec!["bidderA".to_string(), "bidderB".to_string()],
debug: false,
debug_query_params: None,
script_patterns: vec![],
auto_configure: true,
ad_units: vec![],
};
let injector = PrebidHtmlInjector::new(config);
let params = IntegrationDocumentState::default();
let ctx = IntegrationHtmlContext {
request_host: "pub.example",
request_scheme: "https",
origin_host: "origin.example",
document_state: &params,
geo: None,
};

let mut html = "<html><head></head></html>".to_string();
injector.post_process(&mut html, &ctx);

// Extract the JSON config from the injected script
// Script pattern: window.__tsjs_prebid = { ... };
let start_marker = "window.__tsjs_prebid = ";
let start_idx = html
.find(start_marker)
.expect("should find window.__tsjs_prebid =")
+ start_marker.len();
let end_idx = html[start_idx..]
.find(";")
.expect("should find closing semicolon")
+ start_idx;

let json_str = &html[start_idx..end_idx];
let json: serde_json::Value =
serde_json::from_str(json_str).expect("should parse valid JSON config");

assert_eq!(json["timeout"], 2000);
assert_eq!(json["bidders"][0], "bidderA");
assert_eq!(json["bidders"][1], "bidderB");
assert_eq!(json["endpoint"], "https://pub.example/openrtb2/auction");
assert_eq!(json["accountId"], "trusted-server");
assert_eq!(json["enabled"], true);
}
}
Loading