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..4ba9451 100644 --- a/crates/common/src/geo.rs +++ b/crates/common/src/geo.rs @@ -150,3 +150,33 @@ pub fn get_dma_code(req: &mut Request) -> Option { 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> { + 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")) +} 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..7b09d96 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,94 @@ 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 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#""#, + 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 +838,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("