diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index c5964cf..a3c5283 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/geo.rs b/crates/common/src/geo.rs index a903c31..b0e210e 100644 --- a/crates/common/src/geo.rs +++ b/crates/common/src/geo.rs @@ -8,7 +8,7 @@ use fastly::Request; use crate::constants::{ HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY, - HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, + HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, }; /// Geographic information extracted from a request. @@ -81,72 +81,54 @@ impl GeoInfo { pub fn has_metro_code(&self) -> bool { self.metro_code > 0 } + + /// Sets the geographic information headers on the given request. + /// + /// This sets the following headers: + /// - `x-geo-city` + /// - `x-geo-country` + /// - `x-geo-continent` + /// - `x-geo-coordinates` + /// - `x-geo-metro-code` + /// - `x-geo-region` (if available) + pub fn set_headers(&self, req: &mut Request) { + req.set_header(HEADER_X_GEO_CITY, &self.city); + req.set_header(HEADER_X_GEO_COUNTRY, &self.country); + req.set_header(HEADER_X_GEO_CONTINENT, &self.continent); + req.set_header(HEADER_X_GEO_COORDINATES, self.coordinates_string()); + req.set_header(HEADER_X_GEO_METRO_CODE, self.metro_code.to_string()); + if let Some(region) = &self.region { + req.set_header(HEADER_X_GEO_REGION, region); + } + } } -/// Extracts the DMA (Designated Market Area) code from the request's geolocation data. +/// Returns the geographic information for the request as a JSON response. /// -/// This function: -/// 1. Checks if running in Fastly environment -/// 2. Performs geo lookup based on client IP -/// 3. Sets various geo headers on the request -/// 4. Returns the metro code (DMA) if available +/// Use this endpoint to get the client's location data (City, Country, DMA, etc.) +/// without making a third-party API call. /// -/// # Arguments +/// # Errors /// -/// * `req` - The request to extract DMA code from -/// -/// # Returns -/// -/// The DMA/metro code as a string if available, None otherwise -pub fn get_dma_code(req: &mut Request) -> Option { - // Debug: Check if we're running in Fastly environment - log::info!("Fastly Environment Check:"); - log::info!( - " FASTLY_POP: {}", - std::env::var("FASTLY_POP").unwrap_or_else(|_| "not in Fastly".to_string()) - ); - log::info!( - " FASTLY_REGION: {}", - std::env::var("FASTLY_REGION").unwrap_or_else(|_| "not in Fastly".to_string()) - ); - - // Get detailed geo information using geo_lookup - if let Some(geo) = req.get_client_ip_addr().and_then(geo_lookup) { - log::info!("Geo Information Found:"); +/// Returns a 500 error if JSON serialization fails (unlikely). +pub fn handle_first_party_geo( + req: &Request, +) -> Result> { + use crate::error::TrustedServerError; + use error_stack::ResultExt; + use fastly::http::{header, StatusCode}; + use fastly::Response; - // Set all available geo information in headers - let city = geo.city(); - req.set_header(HEADER_X_GEO_CITY, city); - log::info!(" City: {}", city); + let geo_info = GeoInfo::from_request(req); - let country = geo.country_code(); - req.set_header(HEADER_X_GEO_COUNTRY, country); - log::info!(" Country: {}", country); - - req.set_header(HEADER_X_GEO_CONTINENT, format!("{:?}", geo.continent())); - log::info!(" Continent: {:?}", geo.continent()); - - req.set_header( - HEADER_X_GEO_COORDINATES, - format!("{},{}", geo.latitude(), geo.longitude()), - ); - log::info!(" Location: ({}, {})", geo.latitude(), geo.longitude()); - - // Get and set the metro code (DMA) - let metro_code = geo.metro_code(); - req.set_header(HEADER_X_GEO_METRO_CODE, metro_code.to_string()); - log::info!("Found DMA/Metro code: {}", metro_code); - return Some(metro_code.to_string()); - } else { - log::info!("No geo information available for the request"); - req.set_header(HEADER_X_GEO_INFO_AVAILABLE, "false"); - } - - // If no metro code is found, log all request headers for debugging - log::info!("No DMA/Metro code found. All request headers:"); - for (name, value) in req.get_headers() { - log::info!(" {}: {:?}", name, value); - } + // Create a JSON response + let body = + serde_json::to_string(&geo_info).change_context(TrustedServerError::Serialization { + message: "Failed to serialize geo info".to_string(), + })?; - None + Ok(Response::from_body(body) + .with_status(StatusCode::OK) + .with_header(header::CONTENT_TYPE, "application/json") + .with_header("Cache-Control", "private, no-store")) } diff --git a/crates/common/src/html_processor.rs b/crates/common/src/html_processor.rs index fb161e0..d62ead7 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(), } } } @@ -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 { @@ -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(); @@ -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()); @@ -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, } } @@ -488,6 +497,7 @@ mod tests { request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), + geo_info: None, } } @@ -665,6 +675,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 549e420..7483423 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 6313281..c98cfd2 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -54,6 +54,12 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + #[serde(default)] + pub auto_configure: bool, + + /// Ad Units configuration + #[serde(default)] + pub ad_units: Vec, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -192,15 +198,107 @@ 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 Prebid configuration object + let mut 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, + }); + + // Inject Geo information if available + if let Some(geo) = ctx.geo { + config["geo"] = json!({ + "city": geo.city, + "country": geo.country, + "continent": geo.continent, + "lat": geo.latitude, + "lon": geo.longitude, + "metroCode": geo.metro_code, + "region": geo.region, + }); + } + + // Script to inject configuration and initialize Prebid via tsjs + let script = format!( + r#""#, + config + ); + + // Inject after + if let Some(idx) = html.find("") { + let insert_point = idx + 6; + 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, &script); + 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 +851,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("