From 61ef195c1ea7abb0d02b95391de260c349114d51 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 10 Feb 2026 21:21:07 +0530 Subject: [PATCH 1/5] Expose Fastly geo data to replace third-party lookups Third-party geo and DMA lookups add avoidable latency and introduce additional privacy and compliance risk. This change enables geo information to be derived from Fastly at the edge and passed through the Trusted Server instead of relying on browser-side APIs. Centralizing geo handling reduces external dependencies, improves reliability when third-party services fail, and supports ongoing efforts to minimize client-side data exposure while preserving existing ad and bidding functionality. Resolves: #280 --- crates/common/src/error.rs | 5 + crates/common/src/html_processor.rs | 8 + crates/common/src/integrations/nextjs/mod.rs | 1 + crates/common/src/integrations/prebid.rs | 309 ++++++++++++++++++- crates/common/src/integrations/registry.rs | 4 + crates/common/src/publisher.rs | 58 ++++ crates/fastly/src/main.rs | 6 +- 7 files changed, 383 insertions(+), 8 deletions(-) diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index c5964cfe..a3c52832 100644 --- a/crates/common/src/error.rs +++ b/crates/common/src/error.rs @@ -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 }, @@ -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, diff --git a/crates/common/src/html_processor.rs b/crates/common/src/html_processor.rs index f4909aa7..c4a63b2e 100644 --- a/crates/common/src/html_processor.rs +++ b/crates/common/src/html_processor.rs @@ -24,6 +24,7 @@ struct HtmlWithPostProcessing { request_host: String, request_scheme: String, document_state: IntegrationDocumentState, + geo_info: Option, } impl StreamProcessor for HtmlWithPostProcessing { @@ -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. @@ -90,6 +92,7 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, + pub geo_info: Option, } impl HtmlProcessorConfig { @@ -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(), } } } @@ -450,6 +455,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, } } @@ -471,6 +477,7 @@ mod tests { request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + geo_info: None, } } @@ -577,6 +584,7 @@ mod tests { "origin.test-publisher.com", "proxy.example.com", "https", + None, ); assert_eq!(config.origin_host, "origin.test-publisher.com"); diff --git a/crates/common/src/integrations/nextjs/mod.rs b/crates/common/src/integrations/nextjs/mod.rs index 549e420d..7483423d 100644 --- a/crates/common/src/integrations/nextjs/mod.rs +++ b/crates/common/src/integrations/nextjs/mod.rs @@ -121,6 +121,7 @@ mod tests { "origin.example.com", "test.example.com", "https", + None, ) } diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 2a5ddba6..380342e7 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -54,6 +54,18 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + #[serde(default)] + pub auto_configure: bool, + + #[serde(default)] + pub inject_geo: bool, + + #[serde(default = "default_geo_cache_key")] + pub geo_cache_key: String, +} + +fn default_geo_cache_key() -> String { + "cwgl".to_string() } impl IntegrationConfig for PrebidIntegrationConfig { @@ -192,15 +204,138 @@ fn build(settings: &Settings) -> Option> { Some(PrebidIntegration::new(config)) } +pub struct PrebidHtmlInjector { + config: PrebidIntegrationConfig, +} + +impl PrebidHtmlInjector { + fn new(config: PrebidIntegrationConfig) -> Arc { + 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(", + ) -> bool { + // Construct the S2S config + // Note: We use the trusted server itself as the endpoint + let s2s_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), + }); + + // Create geo injection script if enabled and geo info is available + let geo_script = if self.config.inject_geo { + if let Some(geo) = ctx.geo { + let geo_data = json!({ + "country": geo.country, + "region": geo.region, + "city": geo.city, + "postal_code": "", // Not currently available in GeoInfo + }); + + // Script to inject geo data into localStorage + // We use the configured cache key + // We also set the timestamp key (usually cache_key + "t") + // And we set window._tudeGeo as a fallback/optimization for Aditude wrapper + format!( + r#""#, + geo_data, self.config.geo_cache_key, self.config.geo_cache_key + ) + } else { + String::new() + } + } else { + String::new() + }; + + let script = format!( + r#""#, + s2s_config + ); + + let final_injection = format!("{geo_script}{script}"); + + // Inject after + if let Some(idx) = html.find("") { + let insert_point = idx + 6; + html.insert_str(insert_point, &final_injection); + true + } else if let Some(idx) = html.find(" + if let Some(close_idx) = html[idx..].find('>') { + let insert_point = idx + close_idx + 1; + html.insert_str(insert_point, &final_injection); + true + } else { + false + } + } else { + false + } + } +} + #[must_use] pub fn register(settings: &Settings) -> Option { 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)] @@ -753,7 +888,10 @@ pub fn register_auction_provider(settings: &Settings) -> Vec + let mut html = "Test".to_string(); + assert!(injector.post_process(&mut html, &ctx)); + assert!(html.starts_with(""#, - geo_data, self.config.geo_cache_key, self.config.geo_cache_key - ) - } else { - String::new() - } - } else { - String::new() - }; - + // Script to inject configuration and initialize Prebid via tsjs let script = format!( r#""#, - s2s_config + config ); - let final_injection = format!("{geo_script}{script}"); - // Inject after if let Some(idx) = html.find("") { let insert_point = idx + 6; - html.insert_str(insert_point, &final_injection); + html.insert_str(insert_point, &script); true } else if let Some(idx) = html.find(" if let Some(close_idx) = html[idx..].find('>') { let insert_point = idx + close_idx + 1; - html.insert_str(insert_point, &final_injection); + html.insert_str(insert_point, &script); true } else { false @@ -913,8 +863,7 @@ mod tests { debug_query_params: None, script_patterns: default_script_patterns(), auto_configure: false, - inject_geo: false, - geo_cache_key: "cwgl".to_string(), + ad_units: vec![], } } @@ -1271,8 +1220,7 @@ server_url = "https://prebid.example" debug_query_params: None, script_patterns: vec![], auto_configure: true, - inject_geo: false, - geo_cache_key: "cwgl".to_string(), + ad_units: vec![], }; let injector = PrebidHtmlInjector::new(config); let params = IntegrationDocumentState::default(); @@ -1288,7 +1236,8 @@ server_url = "https://prebid.example" let mut html = "Test".to_string(); assert!(injector.post_process(&mut html, &ctx)); assert!(html.starts_with("