diff --git a/crates/common/src/ad.rs b/crates/common/src/ad.rs deleted file mode 100644 index 97f99c0..0000000 --- a/crates/common/src/ad.rs +++ /dev/null @@ -1,435 +0,0 @@ -use error_stack::{Report, ResultExt}; -use fastly::{Request, Response}; -use serde::Deserialize; -use serde_json::Value as JsonValue; -use std::collections::HashMap; - -use crate::error::TrustedServerError; -use crate::openrtb; -use crate::settings::Settings; -use fastly::http::{header, StatusCode}; -use serde_json::Value as Json; -// pixel HTML rewrite lives in crate::pixel - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct BannerUnit { - sizes: Vec>, // [[w,h], ...] -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MediaTypes { - #[allow(dead_code)] - banner: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AdUnit { - code: String, - #[allow(dead_code)] - media_types: Option, - #[serde(default)] - bids: Option>, // Prebid-style bids in adUnit -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AdRequest { - ad_units: Vec, - #[allow(dead_code)] - config: Option, -} - -#[derive(Debug, Deserialize)] -struct Bid { - bidder: String, - #[serde(default)] - params: JsonValue, -} - -/// Build a minimal typed OpenRTB request from tsjs ad units. -fn build_openrtb_from_ts(req: &AdRequest, settings: &Settings) -> openrtb::OpenRtbRequest { - use openrtb::{Banner, Format, Imp, ImpExt, OpenRtbRequest, PrebidImpExt, Site}; - use uuid::Uuid; - - let imps: Vec = req - .ad_units - .iter() - .map(|unit| { - let formats: Vec = unit - .media_types - .as_ref() - .and_then(|mt| mt.banner.as_ref()) - .map(|b| { - b.sizes - .iter() - .filter(|&s| s.len() >= 2) - .map(|s| Format { w: s[0], h: s[1] }) - .collect::>() - }) - .unwrap_or_else(|| vec![Format { w: 300, h: 250 }]); - - // Build bidder map from unit.bids or fallback to settings.prebid.bidders - let mut bidder: HashMap = HashMap::new(); - if let Some(bids) = &unit.bids { - for b in bids { - bidder.insert(b.bidder.clone(), b.params.clone()); - } - } - if bidder.is_empty() { - for b in &settings.prebid.bidders { - bidder.insert(b.clone(), JsonValue::Object(serde_json::Map::new())); - } - } - - Imp { - id: unit.code.clone(), - banner: Some(Banner { format: formats }), - ext: Some(ImpExt { - prebid: PrebidImpExt { bidder }, - }), - } - }) - .collect(); - - OpenRtbRequest { - id: Uuid::new_v4().to_string(), - imp: imps, - site: Some(Site { - domain: Some(settings.publisher.domain.clone()), - page: Some(format!("https://{}", settings.publisher.domain)), - }), - } -} - -// Wrapper that allows tests to intercept the PBS call used by the GET handler. -async fn pbs_auction_for_get( - settings: &Settings, - req: Request, -) -> Result> { - #[cfg(test)] - { - if let Some(body) = MOCK_PBS_JSON.with(|c| c.borrow_mut().take()) { - return Ok(Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "application/json") - .with_body(body)); - } - } - crate::prebid_proxy::handle_prebid_auction(settings, req).await -} - -#[cfg(test)] -thread_local! { - static MOCK_PBS_JSON: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; -} - -#[cfg(test)] -pub(super) fn set_mock_pbs_response(body: Vec) { - MOCK_PBS_JSON.with(|c| *c.borrow_mut() = Some(body)); -} - -/// Handle tsjs ad requests and proxy to Prebid Server using the existing proxy pipeline. -pub async fn handle_server_ad( - settings: &Settings, - mut req: Request, -) -> Result> { - // Parse incoming tsjs request - let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context( - TrustedServerError::Prebid { - message: "Failed to parse tsjs auction request".to_string(), - }, - )?; - - log::info!("/third-party/ad: received {} adUnits", body.ad_units.len()); - for u in &body.ad_units { - if let Some(mt) = &u.media_types { - if let Some(b) = &mt.banner { - log::debug!("unit={} sizes={:?}", u.code, b.sizes); - } else { - log::debug!("unit={} sizes=(none)", u.code); - } - } else { - log::debug!("unit={} mediaTypes=(none)", u.code); - } - } - - // Build minimal OpenRTB request - let openrtb = build_openrtb_from_ts(&body, settings); - // Serialize once for logging/debug - if let Ok(preview) = serde_json::to_string(&openrtb) { - log::debug!( - "OpenRTB payload (truncated): {}", - &preview.chars().take(512).collect::() - ); - } - - // Reuse the existing Prebid Server proxy path by setting the body and delegating - req.set_body_json(&openrtb) - .change_context(TrustedServerError::Prebid { - message: "Failed to set OpenRTB body".to_string(), - })?; - - crate::prebid_proxy::handle_prebid_auction(settings, req).await -} - -/// GET variant for first-party slot rendering: /first-party/ad?slot=code[&w=300&h=250] -pub async fn handle_server_ad_get( - settings: &Settings, - mut req: Request, -) -> Result> { - // Parse query - let url = req.get_url_str(); - let parsed = url::Url::parse(url).change_context(TrustedServerError::Prebid { - message: "Invalid first-party serve-ad URL".to_string(), - })?; - let qp = parsed - .query_pairs() - .into_owned() - .collect::>(); - let slot = qp.get("slot").cloned().unwrap_or_default(); - let w = qp - .get("w") - .and_then(|s| s.parse::().ok()) - .unwrap_or(300); - let h = qp - .get("h") - .and_then(|s| s.parse::().ok()) - .unwrap_or(250); - if slot.is_empty() { - return Err(Report::new(TrustedServerError::BadRequest { - message: "missing slot".to_string(), - })); - } - - // Build a synthetic AdRequest with a single unit for this slot - let ad_req = AdRequest { - ad_units: vec![AdUnit { - code: slot.clone(), - media_types: Some(MediaTypes { - banner: Some(BannerUnit { - sizes: vec![vec![w, h]], - }), - }), - bids: None, - }], - config: None, - }; - - // Convert to OpenRTB and delegate to PBS - let ortb = build_openrtb_from_ts(&ad_req, settings); - req.set_body_json(&ortb) - .change_context(TrustedServerError::Prebid { - message: "Failed to set OpenRTB body".to_string(), - })?; - let mut pbs_resp = pbs_auction_for_get(settings, req).await?; - - // Try to extract HTML creative for this slot - let body_bytes = pbs_resp.take_body_bytes(); - let html = match serde_json::from_slice::(&body_bytes) { - Ok(json) => { - extract_adm_for_slot(&json, &slot).unwrap_or_else(|| "".to_string()) - } - Err(_) => String::from_utf8(body_bytes).unwrap_or_else(|_| "".to_string()), - }; - - let rewritten = crate::creative::rewrite_creative_html(&html, settings); - - Ok(Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .with_body(rewritten)) -} - -fn extract_adm_for_slot(json: &Json, slot: &str) -> Option { - let seatbids = json.get("seatbid")?.as_array()?; - for sb in seatbids { - if let Some(bids) = sb.get("bid").and_then(|b| b.as_array()) { - for b in bids { - let impid = b.get("impid").and_then(|v| v.as_str()).unwrap_or(""); - if impid == slot { - if let Some(adm) = b.get("adm").and_then(|v| v.as_str()) { - return Some(adm.to_string()); - } - } - } - } - } - // Fallback to first available adm - for sb in seatbids { - if let Some(bids) = sb.get("bid").and_then(|b| b.as_array()) { - for b in bids { - if let Some(adm) = b.get("adm").and_then(|v| v.as_str()) { - return Some(adm.to_string()); - } - } - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::tests::create_test_settings; - use fastly::http::{Method, StatusCode}; - use fastly::Request; - use serde_json::json; - - #[test] - fn build_openrtb_defaults_when_missing_sizes_and_bids() { - let settings = create_test_settings(); - let req = AdRequest { - ad_units: vec![AdUnit { - code: "slot1".to_string(), - media_types: None, - bids: None, - }], - config: None, - }; - let ortb = build_openrtb_from_ts(&req, &settings); - assert_eq!(ortb.imp.len(), 1); - let imp = &ortb.imp[0]; - assert_eq!(imp.id, "slot1"); - let banner = imp.banner.as_ref().expect("banner present"); - assert_eq!(banner.format.len(), 1); - assert_eq!(banner.format[0].w, 300); - assert_eq!(banner.format[0].h, 250); - let bidders = &imp.ext.as_ref().unwrap().prebid.bidder; - for b in &settings.prebid.bidders { - assert!(bidders.contains_key(b), "missing bidder {}", b); - } - assert_eq!(bidders.len(), settings.prebid.bidders.len()); - let site = ortb.site.as_ref().expect("site present"); - assert_eq!( - site.domain.as_deref(), - Some(settings.publisher.domain.as_str()) - ); - assert_eq!( - site.page.as_deref(), - Some(format!("https://{}", settings.publisher.domain).as_str()) - ); - } - - #[test] - fn build_openrtb_uses_provided_sizes_and_bids() { - let settings = create_test_settings(); - let req = AdRequest { - ad_units: vec![AdUnit { - code: "slot2".to_string(), - media_types: Some(MediaTypes { - banner: Some(BannerUnit { - sizes: vec![vec![728, 90], vec![300, 250]], - }), - }), - bids: Some(vec![ - Bid { - bidder: "openx".to_string(), - params: json!({"unit":"123"}), - }, - Bid { - bidder: "rubicon".to_string(), - params: json!({}), - }, - ]), - }], - config: None, - }; - let ortb = build_openrtb_from_ts(&req, &settings); - let imp = &ortb.imp[0]; - let banner = imp.banner.as_ref().unwrap(); - assert_eq!(banner.format.len(), 2); - assert!(banner.format.iter().any(|f| f.w == 728 && f.h == 90)); - assert!(banner.format.iter().any(|f| f.w == 300 && f.h == 250)); - let bidders = &imp.ext.as_ref().unwrap().prebid.bidder; - assert!(bidders.contains_key("openx")); - assert!(bidders.contains_key("rubicon")); - // When bids provided, do not add defaults - assert_eq!(bidders.len(), 2); - } - - #[test] - fn extract_adm_picks_matching_slot_then_fallback() { - let json = json!({ - "seatbid": [ - { "bid": [ - { "impid": "slot2", "adm": "
two
" }, - { "impid": "slot1", "adm": "
one
" } - ]} - ] - }); - let adm = extract_adm_for_slot(&json, "slot1").expect("adm present"); - assert!(adm.contains("one")); - - let json2 = json!({ - "seatbid": [ - { "bid": [ { "impid": "other", "adm": "
x
" } ] } - ] - }); - let adm2 = extract_adm_for_slot(&json2, "slot-missing").expect("fallback adm"); - assert!(adm2.contains("x")); - } - - #[tokio::test] - async fn handle_server_ad_get_missing_slot_returns_400() { - let settings = create_test_settings(); - let req = Request::new( - Method::GET, - "https://example.com/first-party/ad?w=300&h=250", - ); - let err = handle_server_ad_get(&settings, req) - .await - .expect_err("expected error"); - // ensure this is a BadRequest surfacing a 400 mapping - assert!(err.to_string().contains("missing slot")); - } - - #[tokio::test] - async fn handle_server_ad_get_returns_html_ct_when_adm_present() { - let settings = create_test_settings(); - // Mock PBS JSON with matching impid and simple HTML adm - let mock = serde_json::json!({ - "seatbid": [{ - "bid": [{ "impid": "slotA", "adm": "
creative
" }] - }] - }); - super::set_mock_pbs_response(serde_json::to_vec(&mock).unwrap()); - - let req = Request::new( - Method::GET, - "https://example.com/first-party/ad?slot=slotA&w=300&h=250", - ); - let mut res = handle_server_ad_get(&settings, req).await.unwrap(); - assert_eq!(res.get_status(), StatusCode::OK); - let ct = res - .get_header(header::CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string(); - assert!(ct.contains("text/html")); - let body = String::from_utf8(res.take_body_bytes()).unwrap(); - assert!(body.contains("creative")); - } - - #[tokio::test] - async fn handle_server_ad_get_rewrites_1x1_pixels() { - let settings = create_test_settings(); - // Mock PBS JSON with a 1x1 img pixel in adm - let mock = serde_json::json!({ - "seatbid": [{ - "bid": [{ "impid": "slotP", "adm": "" }] - }] - }); - super::set_mock_pbs_response(serde_json::to_vec(&mock).unwrap()); - - let req = Request::new( - Method::GET, - "https://example.com/first-party/ad?slot=slotP&w=300&h=250", - ); - let mut res = handle_server_ad_get(&settings, req).await.unwrap(); - assert_eq!(res.get_status(), StatusCode::OK); - let body = String::from_utf8(res.take_body_bytes()).unwrap(); - // Should rewrite to unified first-party proxy endpoint with clear tsurl + tstoken - assert!(body.contains("/first-party/proxy?tsurl=")); - } -} diff --git a/crates/common/src/html_processor.rs b/crates/common/src/html_processor.rs index d59ee8b..f786c78 100644 --- a/crates/common/src/html_processor.rs +++ b/crates/common/src/html_processor.rs @@ -21,7 +21,6 @@ pub struct HtmlProcessorConfig { pub origin_host: String, pub request_host: String, pub request_scheme: String, - pub enable_prebid: bool, pub integrations: IntegrationRegistry, pub nextjs_enabled: bool, pub nextjs_attributes: Vec, @@ -40,7 +39,6 @@ impl HtmlProcessorConfig { origin_host: origin_host.to_string(), request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), - enable_prebid: settings.prebid.auto_configure, integrations: integrations.clone(), nextjs_enabled: settings.publisher.nextjs.enabled, nextjs_attributes: settings.publisher.nextjs.rewrite_attributes.clone(), @@ -124,16 +122,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); - fn is_prebid_script_url(url: &str) -> bool { - let lower = url.to_ascii_lowercase(); - let without_query = lower.split('?').next().unwrap_or(""); - let filename = without_query.rsplit('/').next().unwrap_or(""); - matches!( - filename, - "prebid.js" | "prebid.min.js" | "prebidjs.js" | "prebidjs.min.js" - ) - } - let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of element!("head", { @@ -150,21 +138,15 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso // Replace URLs in href attributes element!("[href]", { let patterns = patterns.clone(); - let rewrite_prebid = config.enable_prebid; let integrations = integration_registry.clone(); move |el| { if let Some(mut href) = el.get_attribute("href") { let original_href = href.clone(); - if rewrite_prebid && is_prebid_script_url(&href) { - el.remove(); - return Ok(()); - } else { - let new_href = href - .replace(&patterns.https_origin(), &patterns.replacement_url()) - .replace(&patterns.http_origin(), &patterns.replacement_url()); - if new_href != href { - href = new_href; - } + let new_href = href + .replace(&patterns.https_origin(), &patterns.replacement_url()) + .replace(&patterns.http_origin(), &patterns.replacement_url()); + if new_href != href { + href = new_href; } match integrations.rewrite_attribute( @@ -197,23 +179,16 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso // Replace URLs in src attributes element!("[src]", { let patterns = patterns.clone(); - let rewrite_prebid = config.enable_prebid; let integrations = integration_registry.clone(); move |el| { if let Some(mut src) = el.get_attribute("src") { let original_src = src.clone(); - if rewrite_prebid && is_prebid_script_url(&src) { - el.remove(); - return Ok(()); - } else { - let new_src = src - .replace(&patterns.https_origin(), &patterns.replacement_url()) - .replace(&patterns.http_origin(), &patterns.replacement_url()); - if new_src != src { - src = new_src; - } + let new_src = src + .replace(&patterns.https_origin(), &patterns.replacement_url()) + .replace(&patterns.http_origin(), &patterns.replacement_url()); + if new_src != src { + src = new_src; } - match integrations.rewrite_attribute( "src", &src, @@ -443,8 +418,12 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso #[cfg(test)] mod tests { use super::*; - use crate::integrations::{AttributeRewriteAction, IntegrationAttributeRewriter}; + use crate::integrations::{ + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + }; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; + use crate::test_support::tests::create_test_settings; + use serde_json::json; use std::io::Cursor; use std::sync::Arc; @@ -453,82 +432,49 @@ mod tests { origin_host: "origin.example.com".to_string(), request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), - enable_prebid: false, integrations: IntegrationRegistry::default(), nextjs_enabled: false, nextjs_attributes: vec!["href".to_string(), "link".to_string(), "url".to_string()], } } - #[test] - fn integration_attribute_rewriter_can_remove_elements() { - struct RemovingLinkRewriter; - - impl IntegrationAttributeRewriter for RemovingLinkRewriter { - fn integration_id(&self) -> &'static str { - "removing" - } - - fn handles_attribute(&self, attribute: &str) -> bool { - attribute == "href" - } - - fn rewrite( - &self, - _attr_name: &str, - attr_value: &str, - _ctx: &IntegrationAttributeContext<'_>, - ) -> AttributeRewriteAction { - if attr_value.contains("remove-me") { - AttributeRewriteAction::remove_element() - } else { - AttributeRewriteAction::keep() - } - } - } - - let html = r#" - remove - keep - "#; - - let mut config = create_test_config(); - config.integrations = - IntegrationRegistry::from_rewriters(vec![Arc::new(RemovingLinkRewriter)], Vec::new()); - - let processor = create_html_processor(config); - let pipeline_config = PipelineConfig { - input_compression: Compression::None, - output_compression: Compression::None, - chunk_size: 8192, - }; - let mut pipeline = StreamingPipeline::new(pipeline_config, processor); - - let mut output = Vec::new(); - pipeline - .process(Cursor::new(html.as_bytes()), &mut output) - .unwrap(); - let processed = String::from_utf8(output).unwrap(); - - assert!( - processed.contains("keep-me"), - "Expected keep link to remain" - ); - assert!( - !processed.contains("remove-me"), - "Removing rewriter should drop matching elements" - ); + fn config_from_settings( + settings: &Settings, + registry: &IntegrationRegistry, + ) -> HtmlProcessorConfig { + HtmlProcessorConfig::from_settings( + settings, + registry, + "origin.example.com", + "test.example.com", + "https", + ) } #[test] - fn test_injects_unified_bundle_and_removes_prebid_refs() { + fn test_always_injects_tsjs_script() { let html = r#" "#; - let mut config = create_test_config(); - config.enable_prebid = true; // enable removal of Prebid URLs + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://test-prebid.com/openrtb2/auction", + "timeout_ms": 1000, + "bidders": ["mocktioneer"], + "auto_configure": false, + "debug": false + }), + ) + .expect("should update prebid config"); + let registry = IntegrationRegistry::new(&settings); + let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -541,20 +487,36 @@ mod tests { let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output); assert!(result.is_ok()); let processed = String::from_utf8_lossy(&output); - assert!(processed.contains("/static/tsjs=tsjs-unified.min.js")); - // Prebid script references should be removed when auto-configure is on - assert!(!processed.contains("prebid.min.js")); - assert!(!processed.contains("cdn.prebid.org/prebid.js")); + // When auto-configure is disabled, do not rewrite Prebid references + assert!(processed.contains("/js/prebid.min.js")); + assert!(processed.contains("cdn.prebid.org/prebid.js")); + assert!(processed.contains("tsjs-unified")); } #[test] - fn test_injects_unified_bundle_and_removes_prebid_with_query_string() { + fn prebid_auto_config_removes_prebid_scripts() { let html = r#" - + + "#; - let mut config = create_test_config(); - config.enable_prebid = true; // enable removal of Prebid URLs + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://test-prebid.com/openrtb2/auction", + "timeout_ms": 1000, + "bidders": ["mocktioneer"], + "auto_configure": true, + "debug": false + }), + ) + .expect("should update prebid config"); + let registry = IntegrationRegistry::new(&settings); + let config = config_from_settings(&settings, ®istry); let processor = create_html_processor(config); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -567,21 +529,56 @@ mod tests { let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output); assert!(result.is_ok()); let processed = String::from_utf8_lossy(&output); - // Should inject unified bundle - assert!(processed.contains("/static/tsjs=tsjs-unified.min.js")); - // Prebid script should be removed - assert!(!processed.contains("prebidjs.min.js")); + assert!( + processed.contains("tsjs-unified"), + "Unified bundle should be injected" + ); + assert!( + !processed.contains("prebid.min.js"), + "Prebid script should be removed" + ); + assert!( + !processed.contains("cdn.prebid.org/prebid.js"), + "Prebid preload should be removed" + ); } #[test] - fn test_always_injects_unified_bundle() { - let html = r#" - - - "#; + fn integration_attribute_rewriter_can_remove_elements() { + struct RemovingLinkRewriter; + + impl IntegrationAttributeRewriter for RemovingLinkRewriter { + fn integration_id(&self) -> &'static str { + "removing" + } + + fn handles_attribute(&self, attribute: &str) -> bool { + attribute == "href" + } + + fn rewrite( + &self, + _attr_name: &str, + attr_value: &str, + _ctx: &IntegrationAttributeContext<'_>, + ) -> AttributeRewriteAction { + if attr_value.contains("remove-me") { + AttributeRewriteAction::remove_element() + } else { + AttributeRewriteAction::keep() + } + } + } + + let html = r#" + remove + keep + "#; let mut config = create_test_config(); - config.enable_prebid = false; // When disabled, don't remove Prebid scripts + config.integrations = + IntegrationRegistry::from_rewriters(vec![Arc::new(RemovingLinkRewriter)], Vec::new()); + let processor = create_html_processor(config); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -591,14 +588,13 @@ mod tests { let mut pipeline = StreamingPipeline::new(pipeline_config, processor); let mut output = Vec::new(); - let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output); - assert!(result.is_ok()); - let processed = String::from_utf8_lossy(&output); - // When auto-configure is disabled, do not remove Prebid references - assert!(processed.contains("/js/prebid.min.js")); - assert!(processed.contains("cdn.prebid.org/prebid.js")); - // But still inject unified bundle - assert!(processed.contains("/static/tsjs=tsjs-unified.min.js")); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .unwrap(); + let processed = String::from_utf8(output).unwrap(); + + assert!(processed.contains("keep-me")); + assert!(!processed.contains("remove-me")); } #[test] @@ -765,13 +761,11 @@ mod tests { #[test] fn test_html_processor_config_from_settings() { - use crate::test_support::tests::create_test_settings; - let settings = create_test_settings(); - let integrations = IntegrationRegistry::default(); + let registry = IntegrationRegistry::new(&settings); let config = HtmlProcessorConfig::from_settings( &settings, - &integrations, + ®istry, "origin.test-publisher.com", "proxy.example.com", "https", @@ -780,7 +774,6 @@ mod tests { assert_eq!(config.origin_host, "origin.test-publisher.com"); assert_eq!(config.request_host, "proxy.example.com"); assert_eq!(config.request_scheme, "https"); - assert!(config.enable_prebid); // Uses default true assert!( !config.nextjs_enabled, "Next.js rewrites should default to disabled" @@ -811,7 +804,6 @@ mod tests { let mut config = create_test_config(); config.origin_host = "www.test-publisher.com".to_string(); // Match what's in the HTML config.request_host = "test-publisher-ts.edgecompute.app".to_string(); - config.enable_prebid = true; // Enable Prebid auto-configuration let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -864,6 +856,54 @@ mod tests { ); } + #[test] + fn test_integration_registry_rewrites_integration_scripts() { + let html = r#" + + "#; + + let mut settings = Settings::default(); + let shim_src = "https://edge.example.com/static/testlight.js".to_string(); + settings + .integrations + .insert_config( + "testlight", + &json!({ + "enabled": true, + "endpoint": "https://example.com/openrtb2/auction", + "rewrite_scripts": true, + "shim_src": shim_src, + }), + ) + .expect("should insert testlight config"); + + let registry = IntegrationRegistry::new(&settings); + let mut config = create_test_config(); + config.integrations = registry; + + let processor = create_html_processor(config); + let pipeline_config = PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }; + let mut pipeline = StreamingPipeline::new(pipeline_config, processor); + + let mut output = Vec::new(); + let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output); + assert!(result.is_ok()); + + let processed = String::from_utf8_lossy(&output); + assert!( + processed.contains(&shim_src), + "Integration shim should replace integration script reference" + ); + assert!( + !processed.contains("cdn.testlight.com"), + "Original integration URL should be removed" + ); + } + #[test] fn test_real_publisher_html_with_gzip() { use flate2::read::GzDecoder; @@ -887,7 +927,6 @@ mod tests { let mut config = create_test_config(); config.origin_host = "www.test-publisher.com".to_string(); // Match what's in the HTML config.request_host = "test-publisher-ts.edgecompute.app".to_string(); - config.enable_prebid = true; let processor = create_html_processor(config); let pipeline_config = PipelineConfig { @@ -1010,7 +1049,6 @@ mod tests { let mut config = create_test_config(); config.origin_host = "www.test-publisher.com".to_string(); // Match what's in the HTML config.request_host = "test-publisher-ts.edgecompute.app".to_string(); - config.enable_prebid = true; let processor = create_html_processor(config); let pipeline_config = PipelineConfig { diff --git a/crates/common/src/integrations/mod.rs b/crates/common/src/integrations/mod.rs index e4577c4..49071b6 100644 --- a/crates/common/src/integrations/mod.rs +++ b/crates/common/src/integrations/mod.rs @@ -2,6 +2,7 @@ use crate::settings::Settings; +pub mod prebid; mod registry; pub mod testlight; @@ -15,5 +16,5 @@ pub use registry::{ type IntegrationBuilder = fn(&Settings) -> Option; pub(crate) fn builders() -> &'static [IntegrationBuilder] { - &[testlight::register] + &[prebid::register, testlight::register] } diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs new file mode 100644 index 0000000..182cf52 --- /dev/null +++ b/crates/common/src/integrations/prebid.rs @@ -0,0 +1,844 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use error_stack::{Report, ResultExt}; +use fastly::http::{header, Method, StatusCode}; +use fastly::{Request, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as Json, Value as JsonValue}; +use url::Url; +use validator::Validate; + +use crate::backend::ensure_backend_from_url; +use crate::constants::{HEADER_SYNTHETIC_FRESH, HEADER_SYNTHETIC_TRUSTED_SERVER}; +use crate::creative; +use crate::error::TrustedServerError; +use crate::geo::GeoInfo; +use crate::integrations::{ + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, +}; +use crate::openrtb::{Banner, Format, Imp, ImpExt, OpenRtbRequest, PrebidImpExt, Site}; +use crate::request_signing::RequestSigner; +use crate::settings::{IntegrationConfig, Settings}; +use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; + +const PREBID_INTEGRATION_ID: &str = "prebid"; +const ROUTE_FIRST_PARTY_AD: &str = "/first-party/ad"; +const ROUTE_THIRD_PARTY_AD: &str = "/third-party/ad"; + +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +pub struct PrebidIntegrationConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + pub server_url: String, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u32, + #[serde( + default = "default_bidders", + deserialize_with = "crate::settings::vec_from_seq_or_map" + )] + pub bidders: Vec, + #[serde(default = "default_auto_configure")] + pub auto_configure: bool, + #[serde(default)] + pub debug: bool, +} + +impl IntegrationConfig for PrebidIntegrationConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +fn default_timeout_ms() -> u32 { + 1000 +} + +fn default_bidders() -> Vec { + vec!["mocktioneer".to_string()] +} + +fn default_auto_configure() -> bool { + true +} + +fn default_enabled() -> bool { + true +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BannerUnit { + sizes: Vec>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MediaTypes { + #[allow(dead_code)] + banner: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AdUnit { + code: String, + #[allow(dead_code)] + media_types: Option, + #[serde(default)] + bids: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AdRequest { + ad_units: Vec, + #[allow(dead_code)] + config: Option, +} + +#[derive(Debug, Deserialize)] +struct Bid { + bidder: String, + #[serde(default)] + params: JsonValue, +} + +pub struct PrebidIntegration { + config: PrebidIntegrationConfig, +} + +impl PrebidIntegration { + fn new(config: PrebidIntegrationConfig) -> Arc { + Arc::new(Self { config }) + } + + fn error(message: impl Into) -> TrustedServerError { + TrustedServerError::Integration { + integration: PREBID_INTEGRATION_ID.to_string(), + message: message.into(), + } + } + + async fn handle_third_party_ad( + &self, + settings: &Settings, + mut req: Request, + ) -> Result> { + let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context( + TrustedServerError::Prebid { + message: "Failed to parse tsjs auction request".to_string(), + }, + )?; + + log::info!("/third-party/ad: received {} adUnits", body.ad_units.len()); + for unit in &body.ad_units { + if let Some(mt) = &unit.media_types { + if let Some(banner) = &mt.banner { + log::debug!("unit={} sizes={:?}", unit.code, banner.sizes); + } + } + } + + let openrtb = build_openrtb_from_ts(&body, settings, &self.config); + if let Ok(preview) = serde_json::to_string(&openrtb) { + log::debug!( + "OpenRTB payload (truncated): {}", + &preview.chars().take(512).collect::() + ); + } + + req.set_body_json(&openrtb) + .change_context(TrustedServerError::Prebid { + message: "Failed to set OpenRTB body".to_string(), + })?; + + handle_prebid_auction(settings, req, &self.config).await + } + + async fn handle_first_party_ad( + &self, + settings: &Settings, + mut req: Request, + ) -> Result> { + let url = req.get_url_str(); + let parsed = Url::parse(url).change_context(TrustedServerError::Prebid { + message: "Invalid first-party serve-ad URL".to_string(), + })?; + let qp = parsed + .query_pairs() + .into_owned() + .collect::>(); + let slot = qp.get("slot").cloned().unwrap_or_default(); + let w = qp + .get("w") + .and_then(|s| s.parse::().ok()) + .unwrap_or(300); + let h = qp + .get("h") + .and_then(|s| s.parse::().ok()) + .unwrap_or(250); + if slot.is_empty() { + return Err(Report::new(TrustedServerError::BadRequest { + message: "missing slot".to_string(), + })); + } + + let ad_req = AdRequest { + ad_units: vec![AdUnit { + code: slot.clone(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![w, h]], + }), + }), + bids: None, + }], + config: None, + }; + + let ortb = build_openrtb_from_ts(&ad_req, settings, &self.config); + req.set_body_json(&ortb) + .change_context(TrustedServerError::Prebid { + message: "Failed to set OpenRTB body".to_string(), + })?; + + let mut pbs_resp = pbs_auction_for_get(settings, req, &self.config).await?; + + let body_bytes = pbs_resp.take_body_bytes(); + let html = match serde_json::from_slice::(&body_bytes) { + Ok(json) => extract_adm_for_slot(&json, &slot) + .unwrap_or_else(|| "".to_string()), + Err(_) => String::from_utf8(body_bytes).unwrap_or_else(|_| "".to_string()), + }; + + let rewritten = creative::rewrite_creative_html(&html, settings); + + Ok(Response::from_status(StatusCode::OK) + .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .with_body(rewritten)) + } +} + +fn build(settings: &Settings) -> Option> { + let config = settings + .integration_config::(PREBID_INTEGRATION_ID) + .ok() + .flatten()?; + if !config.enabled { + return None; + } + if config.server_url.trim().is_empty() { + log::warn!("Prebid integration disabled: prebid.server_url missing"); + return None; + } + Some(PrebidIntegration::new(config)) +} + +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(), + ) +} + +#[async_trait(?Send)] +impl IntegrationProxy for PrebidIntegration { + fn routes(&self) -> Vec { + vec![ + IntegrationEndpoint::get(ROUTE_FIRST_PARTY_AD), + IntegrationEndpoint::post(ROUTE_THIRD_PARTY_AD), + ] + } + + async fn handle( + &self, + settings: &Settings, + req: Request, + ) -> Result> { + let path = req.get_path().to_string(); + let method = req.get_method().clone(); + + if method == Method::GET && path == ROUTE_FIRST_PARTY_AD { + self.handle_first_party_ad(settings, req).await + } else if method == Method::POST && path == ROUTE_THIRD_PARTY_AD { + self.handle_third_party_ad(settings, req).await + } else { + Err(Report::new(Self::error(format!( + "Unsupported Prebid route: {path}" + )))) + } + } +} + +impl IntegrationAttributeRewriter for PrebidIntegration { + fn integration_id(&self) -> &'static str { + PREBID_INTEGRATION_ID + } + + fn handles_attribute(&self, attribute: &str) -> bool { + self.config.auto_configure && matches!(attribute, "src" | "href") + } + + fn rewrite( + &self, + _attr_name: &str, + attr_value: &str, + _ctx: &IntegrationAttributeContext<'_>, + ) -> AttributeRewriteAction { + if self.config.auto_configure && is_prebid_script_url(attr_value) { + AttributeRewriteAction::remove_element() + } else { + AttributeRewriteAction::keep() + } + } +} + +fn build_openrtb_from_ts( + req: &AdRequest, + settings: &Settings, + prebid: &PrebidIntegrationConfig, +) -> OpenRtbRequest { + use uuid::Uuid; + + let imps: Vec = req + .ad_units + .iter() + .map(|unit| { + let formats: Vec = unit + .media_types + .as_ref() + .and_then(|mt| mt.banner.as_ref()) + .map(|b| { + b.sizes + .iter() + .filter(|s| s.len() >= 2) + .map(|s| Format { w: s[0], h: s[1] }) + .collect::>() + }) + .unwrap_or_else(|| vec![Format { w: 300, h: 250 }]); + + let mut bidder: HashMap = HashMap::new(); + if let Some(bids) = &unit.bids { + for bid in bids { + bidder.insert(bid.bidder.clone(), bid.params.clone()); + } + } + if bidder.is_empty() { + for b in &prebid.bidders { + bidder.insert(b.clone(), JsonValue::Object(serde_json::Map::new())); + } + } + + Imp { + id: unit.code.clone(), + banner: Some(Banner { format: formats }), + ext: Some(ImpExt { + prebid: PrebidImpExt { bidder }, + }), + } + }) + .collect(); + + OpenRtbRequest { + id: Uuid::new_v4().to_string(), + imp: imps, + site: Some(Site { + domain: Some(settings.publisher.domain.clone()), + page: Some(format!("https://{}", settings.publisher.domain)), + }), + } +} + +fn is_prebid_script_url(url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + let without_query = lower.split('?').next().unwrap_or(""); + let filename = without_query.rsplit('/').next().unwrap_or(""); + matches!( + filename, + "prebid.js" | "prebid.min.js" | "prebidjs.js" | "prebidjs.min.js" + ) +} + +async fn pbs_auction_for_get( + settings: &Settings, + req: Request, + config: &PrebidIntegrationConfig, +) -> Result> { + handle_prebid_auction(settings, req, config).await +} + +fn extract_adm_for_slot(json: &Json, slot: &str) -> Option { + let seatbids = json.get("seatbid")?.as_array()?; + for sb in seatbids { + if let Some(bids) = sb.get("bid").and_then(|b| b.as_array()) { + for bid in bids { + let impid = bid.get("impid").and_then(|v| v.as_str()).unwrap_or(""); + if impid == slot { + if let Some(adm) = bid.get("adm").and_then(|v| v.as_str()) { + return Some(adm.to_string()); + } + } + } + } + } + for sb in seatbids { + if let Some(bids) = sb.get("bid").and_then(|b| b.as_array()) { + for bid in bids { + if let Some(adm) = bid.get("adm").and_then(|v| v.as_str()) { + return Some(adm.to_string()); + } + } + } + } + None +} + +async fn handle_prebid_auction( + settings: &Settings, + mut req: Request, + config: &PrebidIntegrationConfig, +) -> Result> { + log::info!("Handling Prebid auction request"); + let mut openrtb_request: Json = serde_json::from_slice(&req.take_body_bytes()).change_context( + TrustedServerError::Prebid { + message: "Failed to parse OpenRTB request".to_string(), + }, + )?; + + let synthetic_id = get_or_generate_synthetic_id(settings, &req)?; + let fresh_id = generate_synthetic_id(settings, &req)?; + + log::info!( + "Using synthetic ID: {}, fresh ID: {}", + synthetic_id, + fresh_id + ); + + enhance_openrtb_request( + &mut openrtb_request, + &synthetic_id, + &fresh_id, + settings, + &req, + )?; + + let mut pbs_req = Request::new( + Method::POST, + format!("{}/openrtb2/auction", config.server_url), + ); + copy_request_headers(&req, &mut pbs_req); + pbs_req + .set_body_json(&openrtb_request) + .change_context(TrustedServerError::Prebid { + message: "Failed to set request body".to_string(), + })?; + + log::info!("Sending request to Prebid Server"); + let backend_name = ensure_backend_from_url(&config.server_url)?; + let mut pbs_response = + pbs_req + .send(backend_name) + .change_context(TrustedServerError::Prebid { + message: "Failed to send request to Prebid Server".to_string(), + })?; + + if pbs_response.get_status().is_success() { + let response_body = pbs_response.take_body_bytes(); + match serde_json::from_slice::(&response_body) { + Ok(mut response_json) => { + let request_host = get_request_host(&req); + let request_scheme = get_request_scheme(&req); + transform_prebid_response(&mut response_json, &request_host, &request_scheme)?; + + let transformed_body = serde_json::to_vec(&response_json).change_context( + TrustedServerError::Prebid { + message: "Failed to serialize transformed response".to_string(), + }, + )?; + + Ok(Response::from_status(StatusCode::OK) + .with_header(header::CONTENT_TYPE, "application/json") + .with_header("X-Synthetic-ID", &synthetic_id) + .with_header(HEADER_SYNTHETIC_FRESH, &fresh_id) + .with_header(HEADER_SYNTHETIC_TRUSTED_SERVER, &synthetic_id) + .with_body(transformed_body)) + } + Err(_) => Ok(Response::from_status(pbs_response.get_status()) + .with_header(header::CONTENT_TYPE, "application/json") + .with_body(response_body)), + } + } else { + Ok(pbs_response) + } +} + +fn enhance_openrtb_request( + request: &mut Json, + synthetic_id: &str, + fresh_id: &str, + settings: &Settings, + req: &Request, +) -> Result<(), Report> { + if !request["user"].is_object() { + request["user"] = json!({}); + } + request["user"]["id"] = json!(synthetic_id); + + if !request["user"]["ext"].is_object() { + request["user"]["ext"] = json!({}); + } + request["user"]["ext"]["synthetic_fresh"] = json!(fresh_id); + + if req.get_header("Sec-GPC").is_some() { + if !request["regs"].is_object() { + request["regs"] = json!({}); + } + if !request["regs"]["ext"].is_object() { + request["regs"]["ext"] = json!({}); + } + request["regs"]["ext"]["us_privacy"] = json!("1YYN"); + } + + if let Some(geo_info) = GeoInfo::from_request(req) { + let geo_obj = json!({ + "type": 2, + "country": geo_info.country, + "city": geo_info.city, + "region": geo_info.region, + }); + + if !request["device"].is_object() { + request["device"] = json!({}); + } + request["device"]["geo"] = geo_obj; + } + + if !request["site"].is_object() { + request["site"] = json!({ + "domain": settings.publisher.domain, + "page": format!("https://{}", settings.publisher.domain), + }); + } + + if let Some(request_signing_config) = &settings.request_signing { + if request_signing_config.enabled && request["id"].is_string() { + if !request["ext"].is_object() { + request["ext"] = json!({}); + } + + let id = request["id"] + .as_str() + .expect("should have string id when is_string checked"); + let signer = RequestSigner::from_config()?; + let signature = signer.sign(id.as_bytes())?; + request["ext"]["trusted_server"] = json!({ + "signature": signature, + "kid": signer.kid + }); + } + } + + Ok(()) +} + +fn transform_prebid_response( + response: &mut Json, + request_host: &str, + request_scheme: &str, +) -> Result<(), Report> { + if let Some(seatbids) = response["seatbid"].as_array_mut() { + for seatbid in seatbids { + if let Some(bids) = seatbid["bid"].as_array_mut() { + for bid in bids { + if let Some(adm) = bid["adm"].as_str() { + bid["adm"] = json!(rewrite_ad_markup(adm, request_host, request_scheme)); + } + + if let Some(nurl) = bid["nurl"].as_str() { + bid["nurl"] = json!(make_first_party_proxy_url( + nurl, + request_host, + request_scheme, + "track" + )); + } + + if let Some(burl) = bid["burl"].as_str() { + bid["burl"] = json!(make_first_party_proxy_url( + burl, + request_host, + request_scheme, + "track" + )); + } + } + } + } + } + + Ok(()) +} + +fn rewrite_ad_markup(markup: &str, request_host: &str, request_scheme: &str) -> String { + let mut content = markup.to_string(); + let cdn_patterns = vec![ + ("https://cdn.adsrvr.org", "adsrvr"), + ("https://ib.adnxs.com", "adnxs"), + ("https://rtb.openx.net", "openx"), + ("https://as.casalemedia.com", "casale"), + ("https://eus.rubiconproject.com", "rubicon"), + ]; + + for (cdn_url, cdn_name) in cdn_patterns { + if content.contains(cdn_url) { + let proxy_base = format!( + "{}://{}/ad-proxy/{}", + request_scheme, request_host, cdn_name + ); + content = content.replace(cdn_url, &proxy_base); + } + } + + content = content.replace( + "//cdn.adsrvr.org", + &format!("//{}/ad-proxy/adsrvr", request_host), + ); + content = content.replace( + "//ib.adnxs.com", + &format!("//{}/ad-proxy/adnxs", request_host), + ); + content +} + +fn make_first_party_proxy_url( + third_party_url: &str, + request_host: &str, + request_scheme: &str, + proxy_type: &str, +) -> String { + let encoded = BASE64.encode(third_party_url.as_bytes()); + format!( + "{}://{}/ad-proxy/{}/{}", + request_scheme, request_host, proxy_type, encoded + ) +} + +fn copy_request_headers(from: &Request, to: &mut Request) { + let headers_to_copy = [ + header::COOKIE, + header::USER_AGENT, + header::HeaderName::from_static("x-forwarded-for"), + header::REFERER, + header::ACCEPT_LANGUAGE, + ]; + + for header_name in &headers_to_copy { + if let Some(value) = from.get_header(header_name) { + to.set_header(header_name, value); + } + } +} + +fn get_request_host(req: &Request) -> String { + req.get_header(header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string() +} + +fn get_request_scheme(req: &Request) -> String { + if req.get_tls_protocol().is_some() || req.get_tls_cipher_openssl_name().is_some() { + return "https".to_string(); + } + + if let Some(proto) = req.get_header("X-Forwarded-Proto") { + if let Ok(proto_str) = proto.to_str() { + return proto_str.to_lowercase(); + } + } + + "https".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::integrations::AttributeRewriteAction; + use crate::settings::Settings; + use crate::test_support::tests::crate_test_settings_str; + use fastly::http::Method; + use serde_json::json; + + fn make_settings() -> Settings { + Settings::from_toml(&crate_test_settings_str()).expect("should parse settings") + } + + fn base_config() -> PrebidIntegrationConfig { + PrebidIntegrationConfig { + enabled: true, + server_url: "https://prebid.example".to_string(), + timeout_ms: 1000, + bidders: vec!["exampleBidder".to_string()], + auto_configure: true, + debug: false, + } + } + + #[test] + fn attribute_rewriter_removes_prebid_scripts() { + let integration = PrebidIntegration { + config: base_config(), + }; + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + }; + + let rewritten = integration.rewrite("src", "https://cdn.prebid.org/prebid.min.js", &ctx); + assert!(matches!(rewritten, AttributeRewriteAction::RemoveElement)); + + let untouched = integration.rewrite("src", "https://cdn.example.com/app.js", &ctx); + assert!(matches!(untouched, AttributeRewriteAction::Keep)); + } + + #[test] + fn attribute_rewriter_handles_query_strings_and_links() { + let integration = PrebidIntegration { + config: base_config(), + }; + let ctx = IntegrationAttributeContext { + attribute_name: "href", + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + }; + + let rewritten = + integration.rewrite("href", "https://cdn.prebid.org/prebid.js?v=1.2.3", &ctx); + assert!(matches!(rewritten, AttributeRewriteAction::RemoveElement)); + } + + #[test] + fn enhance_openrtb_request_adds_ids_and_regs() { + let settings = make_settings(); + let mut request_json = json!({ + "id": "openrtb-request-id" + }); + + let synthetic_id = "synthetic-123"; + let fresh_id = "fresh-456"; + let mut req = Request::new(Method::POST, "https://edge.example/third-party/ad"); + req.set_header("Sec-GPC", "1"); + + enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req) + .expect("should enhance request"); + + assert_eq!(request_json["user"]["id"], synthetic_id); + assert_eq!(request_json["user"]["ext"]["synthetic_fresh"], fresh_id); + assert_eq!( + request_json["regs"]["ext"]["us_privacy"], "1YYN", + "GPC header should map to US privacy flag" + ); + assert_eq!( + request_json["site"]["domain"], settings.publisher.domain, + "site domain should match publisher domain" + ); + assert!( + request_json["site"]["page"] + .as_str() + .unwrap() + .starts_with("https://"), + "site page should be populated" + ); + } + + #[test] + fn transform_prebid_response_rewrites_creatives_and_tracking() { + let mut response = json!({ + "seatbid": [{ + "bid": [{ + "adm": r#""#, + "nurl": "https://notify.example/win", + "burl": "https://notify.example/bill" + }] + }] + }); + + transform_prebid_response(&mut response, "pub.example", "https") + .expect("should rewrite response"); + + let rewritten_adm = response["seatbid"][0]["bid"][0]["adm"] + .as_str() + .expect("adm should be string"); + assert!( + rewritten_adm.contains("/ad-proxy/adsrvr"), + "creative markup should proxy CDN urls" + ); + + for url_field in ["nurl", "burl"] { + let value = response["seatbid"][0]["bid"][0][url_field] + .as_str() + .unwrap(); + assert!( + value.contains("/ad-proxy/track/"), + "tracking URLs should be proxied" + ); + } + } + + #[test] + fn extract_adm_for_slot_prefers_exact_match() { + let response = json!({ + "seatbid": [{ + "bid": [ + { "impid": "slot-b", "adm": "" }, + { "impid": "slot-a", "adm": "" } + ] + }] + }); + + let adm = extract_adm_for_slot(&response, "slot-a").expect("adm should exist"); + assert_eq!(adm, ""); + + let fallback = extract_adm_for_slot(&response, "missing") + .expect("should fall back to first available adm"); + assert!( + fallback == "" || fallback == "", + "fallback should return some creative" + ); + } + + #[test] + fn make_first_party_proxy_url_base64_encodes_target() { + let url = "https://cdn.example/path?x=1"; + let rewritten = make_first_party_proxy_url(url, "pub.example", "https", "track"); + assert!( + rewritten.starts_with("https://pub.example/ad-proxy/track/"), + "proxy prefix should be applied" + ); + + let encoded = rewritten.split("/ad-proxy/track/").nth(1).unwrap(); + let decoded = BASE64 + .decode(encoded.as_bytes()) + .expect("should decode base64 proxy payload"); + assert_eq!(String::from_utf8(decoded).unwrap(), url); + } + + #[test] + fn is_prebid_script_url_matches_common_variants() { + assert!(is_prebid_script_url("https://cdn.com/prebid.js")); + assert!(is_prebid_script_url( + "https://cdn.com/prebid.min.js?version=1" + )); + assert!(!is_prebid_script_url("https://cdn.com/app.js")); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 3af42b8..fc0b888 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -6,7 +6,6 @@ //! # Modules //! //! - [`auth`]: Basic authentication enforcement helpers -//! - [`advertiser`]: Ad serving and advertiser integration functionality //! - [`constants`]: Application-wide constants and configuration values //! - [`cookies`]: Cookie parsing and generation utilities //! - [`error`]: Error types and error handling utilities @@ -14,7 +13,6 @@ //! - [`geo`]: Geographic location utilities and DMA code extraction //! - [`models`]: Data models for ad serving and callbacks //! - [`prebid`]: Prebid integration and real-time bidding support -//! - [`prebid_proxy`]: Prebid Server proxy for first-party ad serving //! - [`privacy`]: Privacy utilities and helpers //! - [`settings`]: Configuration management and validation //! - [`streaming_replacer`]: Streaming URL replacement for large responses @@ -23,7 +21,6 @@ //! - [`test_support`]: Testing utilities and mocks //! - [`why`]: Debugging and introspection utilities -pub mod ad; pub mod auth; pub mod backend; pub mod constants; @@ -37,7 +34,6 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; -pub mod prebid_proxy; pub mod proxy; pub mod publisher; pub mod request_signing; diff --git a/crates/common/src/prebid_proxy.rs b/crates/common/src/prebid_proxy.rs deleted file mode 100644 index 748e8b8..0000000 --- a/crates/common/src/prebid_proxy.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Prebid Server proxy integration for first-party ad serving. -//! -//! This module handles proxying requests between Prebid.js and Prebid Server, -//! ensuring all ad serving happens through the first-party domain. - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; -use fastly::{Request, Response}; -use serde_json::{json, Value}; - -use crate::backend::ensure_backend_from_url; -use crate::constants::{HEADER_SYNTHETIC_FRESH, HEADER_SYNTHETIC_TRUSTED_SERVER}; -use crate::error::TrustedServerError; -use crate::geo::GeoInfo; -use crate::request_signing::RequestSigner; -use crate::settings::Settings; -use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; - -/// Handles Prebid auction requests, enhancing them with synthetic IDs and privacy signals -/// before forwarding to Prebid Server. -/// -/// This function: -/// 1. Parses the incoming OpenRTB request from Prebid.js -/// 2. Enhances it with synthetic IDs and privacy information -/// 3. Forwards to Prebid Server -/// 4. Transforms the response to ensure all URLs are first-party -/// -/// # Errors -/// -/// Returns a [`TrustedServerError`] if: -/// - Request parsing fails -/// - Synthetic ID generation fails -/// - Communication with Prebid Server fails -pub async fn handle_prebid_auction( - settings: &Settings, - mut req: Request, -) -> Result> { - log::info!("Handling Prebid auction request"); - - // 1. Parse incoming OpenRTB request - let mut openrtb_request: Value = serde_json::from_slice(&req.take_body_bytes()) - .change_context(TrustedServerError::Prebid { - message: "Failed to parse OpenRTB request".to_string(), - })?; - - // 2. Get/generate synthetic IDs - let synthetic_id = get_or_generate_synthetic_id(settings, &req)?; - let fresh_id = generate_synthetic_id(settings, &req)?; - - log::info!( - "Using synthetic ID: {}, fresh ID: {}", - synthetic_id, - fresh_id - ); - - // 3. Enhance the OpenRTB request - enhance_openrtb_request( - &mut openrtb_request, - &synthetic_id, - &fresh_id, - settings, - &req, - )?; - - // 4. Forward to Prebid Server - let mut pbs_req = Request::new( - Method::POST, - format!("{}/openrtb2/auction", settings.prebid.server_url), - ); - - // Copy relevant headers - copy_request_headers(&req, &mut pbs_req); - - pbs_req - .set_body_json(&openrtb_request) - .change_context(TrustedServerError::Prebid { - message: "Failed to set request body".to_string(), - })?; - - log::info!("Sending request to Prebid Server"); - - let backend_name = ensure_backend_from_url(&settings.prebid.server_url)?; - - // 5. Send to PBS and get response - let mut pbs_response = - pbs_req - .send(backend_name) - .change_context(TrustedServerError::Prebid { - message: "Failed to send request to Prebid Server".to_string(), - })?; - - // 6. Transform response for first-party serving - if pbs_response.get_status().is_success() { - let response_body = pbs_response.take_body_bytes(); - - match serde_json::from_slice::(&response_body) { - Ok(mut response_json) => { - // Get request host and scheme for URL rewriting - let request_host = get_request_host(&req); - let request_scheme = get_request_scheme(&req); - - // Transform all third-party URLs to first-party - transform_prebid_response(&mut response_json, &request_host, &request_scheme)?; - - // Create response with transformed JSON - let transformed_body = serde_json::to_vec(&response_json).change_context( - TrustedServerError::Prebid { - message: "Failed to serialize transformed response".to_string(), - }, - )?; - - Ok(Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "application/json") - .with_header("X-Synthetic-ID", &synthetic_id) - .with_header(HEADER_SYNTHETIC_FRESH, &fresh_id) - .with_header(HEADER_SYNTHETIC_TRUSTED_SERVER, &synthetic_id) - .with_body(transformed_body)) - } - Err(_) => { - // If response is not JSON, pass through as-is - Ok(Response::from_status(pbs_response.get_status()) - .with_header(header::CONTENT_TYPE, "application/json") - .with_body(response_body)) - } - } - } else { - // Pass through error responses - Ok(pbs_response) - } -} - -/// Enhances the OpenRTB request with synthetic IDs and privacy information. -fn enhance_openrtb_request( - request: &mut Value, - synthetic_id: &str, - fresh_id: &str, - settings: &Settings, - req: &Request, -) -> Result<(), Report> { - // Ensure user object exists - if !request["user"].is_object() { - request["user"] = json!({}); - } - - // Add synthetic IDs - request["user"]["id"] = json!(synthetic_id); - if !request["user"]["ext"].is_object() { - request["user"]["ext"] = json!({}); - } - request["user"]["ext"]["synthetic_fresh"] = json!(fresh_id); - - // TODO: Add privacy signals - - // Add US Privacy if present - if req.get_header("Sec-GPC").is_some() { - if !request["regs"].is_object() { - request["regs"] = json!({}); - } - if !request["regs"]["ext"].is_object() { - request["regs"]["ext"] = json!({}); - } - request["regs"]["ext"]["us_privacy"] = json!("1YYN"); - } - - // Add geo information if available - if let Some(geo_info) = GeoInfo::from_request(req) { - let geo_obj = json!({ - "type": 2, // 2 = IP address location - "country": geo_info.country, // Note: OpenRTB expects ISO 3166-1 alpha-3, but Fastly provides alpha-2 - "city": geo_info.city, - "region": geo_info.region, - }); - - if !request["device"].is_object() { - request["device"] = json!({}); - } - request["device"]["geo"] = geo_obj.clone(); - } - - // Add site information if missing - if !request["site"].is_object() { - request["site"] = json!({ - "domain": settings.publisher.domain, - "page": format!("https://{}", settings.publisher.domain), - }); - } - - // Add trusted server signature (if enabled) - if let Some(request_signing_config) = &settings.request_signing { - if request_signing_config.enabled && request["id"].is_string() { - log::info!("signing openrtb request..."); - if !request["ext"].is_object() { - request["ext"] = json!({}); - } - - let id = request["id"] - .as_str() - .expect("as_str guaranteed by is_string check"); - - let signer = RequestSigner::from_config()?; - let signature = signer.sign(id.as_bytes())?; - - request["ext"]["trusted_server"] = json!({ - "signature": signature, - "kid": signer.kid - }); - } - } - - Ok(()) -} - -/// Transforms the Prebid Server response to ensure all URLs are first-party. -fn transform_prebid_response( - response: &mut Value, - request_host: &str, - request_scheme: &str, -) -> Result<(), Report> { - // Transform bid responses - if let Some(seatbids) = response["seatbid"].as_array_mut() { - for seatbid in seatbids { - if let Some(bids) = seatbid["bid"].as_array_mut() { - for bid in bids { - // Transform creative markup - if let Some(adm) = bid["adm"].as_str() { - bid["adm"] = json!(rewrite_ad_markup(adm, request_host, request_scheme)); - } - - // Transform notification URL - if let Some(nurl) = bid["nurl"].as_str() { - bid["nurl"] = json!(make_first_party_proxy_url( - nurl, - request_host, - request_scheme, - "track" - )); - } - - // Transform billing URL - if let Some(burl) = bid["burl"].as_str() { - bid["burl"] = json!(make_first_party_proxy_url( - burl, - request_host, - request_scheme, - "track" - )); - } - } - } - } - } - - Ok(()) -} - -/// Rewrites ad markup to use first-party URLs. -fn rewrite_ad_markup(markup: &str, request_host: &str, request_scheme: &str) -> String { - let mut content = markup.to_string(); - - // Common ad CDN patterns to rewrite - let cdn_patterns = vec![ - ("https://cdn.adsrvr.org", "adsrvr"), - ("https://ib.adnxs.com", "adnxs"), - ("https://rtb.openx.net", "openx"), - ("https://as.casalemedia.com", "casale"), - ("https://eus.rubiconproject.com", "rubicon"), - ]; - - for (cdn_url, cdn_name) in cdn_patterns { - if content.contains(cdn_url) { - // Replace with first-party proxy URL - let proxy_base = format!( - "{}://{}/ad-proxy/{}", - request_scheme, request_host, cdn_name - ); - content = content.replace(cdn_url, &proxy_base); - } - } - - // Also handle protocol-relative URLs - content = content.replace( - "//cdn.adsrvr.org", - &format!("//{}/ad-proxy/adsrvr", request_host), - ); - content = content.replace( - "//ib.adnxs.com", - &format!("//{}/ad-proxy/adnxs", request_host), - ); - - content -} - -/// Creates a first-party proxy URL for the given third-party URL. -fn make_first_party_proxy_url( - third_party_url: &str, - request_host: &str, - request_scheme: &str, - proxy_type: &str, -) -> String { - let encoded = BASE64.encode(third_party_url.as_bytes()); - format!( - "{}://{}/ad-proxy/{}/{}", - request_scheme, request_host, proxy_type, encoded - ) -} - -/// Copies relevant headers from the incoming request to the outgoing request. -fn copy_request_headers(from: &Request, to: &mut Request) { - let headers_to_copy = [ - header::COOKIE, - header::USER_AGENT, - header::HeaderName::from_static("x-forwarded-for"), - header::REFERER, - header::ACCEPT_LANGUAGE, - ]; - - for header_name in &headers_to_copy { - if let Some(value) = from.get_header(header_name) { - to.set_header(header_name, value); - } - } -} - -/// Gets the request host from the incoming request. -fn get_request_host(req: &Request) -> String { - req.get_header(header::HOST) - .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string() -} - -/// Gets the request scheme from the incoming request. -fn get_request_scheme(req: &Request) -> String { - // Check various headers to determine scheme - if req.get_tls_protocol().is_some() || req.get_tls_cipher_openssl_name().is_some() { - return "https".to_string(); - } - - if let Some(proto) = req.get_header("X-Forwarded-Proto") { - if let Ok(proto_str) = proto.to_str() { - return proto_str.to_lowercase(); - } - } - - "https".to_string() // Default to HTTPS for security -} - -#[cfg(test)] -mod tests { - // Note: test_rewrite_ad_markup removed as it tested a private function. - // This functionality is tested through the public handle_prebid_auction function. - - // Note: test_enhance_openrtb_request removed as it tested a private function. - // This functionality is tested through the public handle_prebid_auction function. - - // Note: test_transform_prebid_response removed as it tested a private function. - // This functionality is tested through the public handle_prebid_auction function. -} diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index fdf8a88..4808f58 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -62,31 +62,6 @@ impl Publisher { } } -#[derive(Debug, Default, Deserialize, Serialize, Validate)] -pub struct Prebid { - pub server_url: String, - #[serde(default = "default_timeout_ms")] - pub timeout_ms: u32, - #[serde(default = "default_bidders", deserialize_with = "vec_from_seq_or_map")] - pub bidders: Vec, - #[serde(default = "default_auto_configure")] - pub auto_configure: bool, - #[serde(default)] - pub debug: bool, -} - -fn default_timeout_ms() -> u32 { - 1000 -} - -fn default_bidders() -> Vec { - vec!["mocktioneer".to_string()] -} - -fn default_auto_configure() -> bool { - true -} - #[derive(Debug, Deserialize, Serialize, Validate)] pub struct NextJs { #[serde(default)] @@ -285,8 +260,6 @@ fn default_request_signing_enabled() -> bool { pub struct Settings { #[validate(nested)] pub publisher: Publisher, - #[validate(nested)] - pub prebid: Prebid, #[serde(default)] #[validate(nested)] pub synthetic: Synthetic, @@ -386,10 +359,10 @@ fn validate_path(value: &str) -> Result<(), ValidationError> { } // Helper: allow Vec fields to deserialize from either a JSON array or a map of numeric indices. -// This lets env vars like TRUSTED_SERVER__PREBID__BIDDERS__0=smartadserver work, which the config env source +// This lets env vars like TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS__0=smartadserver work, which the config env source // represents as an object {"0": "value"} rather than a sequence. Also supports string inputs that are // JSON arrays or comma-separated values. -fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> +pub(crate) fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, T: DeserializeOwned, @@ -447,6 +420,7 @@ mod tests { use super::*; use regex::Regex; + use crate::integrations::prebid::PrebidIntegrationConfig; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; #[test] @@ -461,7 +435,11 @@ mod tests { assert!(!settings.publisher.cookie_domain.is_empty()); assert!(!settings.publisher.origin_url.is_empty()); - assert!(!settings.prebid.server_url.is_empty()); + let prebid_cfg = settings + .integration_config::("prebid") + .expect("Prebid config query should succeed") + .expect("Prebid config should load from default settings"); + assert!(!prebid_cfg.server_url.is_empty()); assert!( !settings.publisher.nextjs.enabled, "Next.js URL rewriting should default to disabled" @@ -486,8 +464,12 @@ mod tests { assert!(settings.is_ok()); let settings = settings.expect("should parse valid TOML"); + let prebid_cfg = settings + .integration_config::("prebid") + .expect("Prebid config query should succeed") + .expect("Prebid config should load from test settings"); assert_eq!( - settings.prebid.server_url, + prebid_cfg.server_url, "https://test-prebid.com/openrtb2/auction" ); assert!( @@ -558,9 +540,10 @@ mod tests { fn test_prebid_bidders_override_with_json_env() { let toml_str = crate_test_settings_str(); let env_key = format!( - "{}{}PREBID{}BIDDERS", + "{}{}INTEGRATIONS{}PREBID{}BIDDERS", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); @@ -581,8 +564,12 @@ mod tests { eprintln!("JSON override error: {:?}", res.as_ref().err()); } let settings = res.expect("Settings should parse with JSON env override"); + let cfg = settings + .integration_config::("prebid") + .expect("Prebid config query should succeed") + .expect("Prebid config should exist with env override"); assert_eq!( - settings.prebid.bidders, + cfg.bidders, vec!["smartadserver".to_string(), "rubicon".to_string()] ); }); @@ -595,17 +582,19 @@ mod tests { let toml_str = crate_test_settings_str(); let env_key0 = format!( - "{}{}PREBID{}BIDDERS{}0", + "{}{}INTEGRATIONS{}PREBID{}BIDDERS{}0", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); let env_key1 = format!( - "{}{}PREBID{}BIDDERS{}1", + "{}{}INTEGRATIONS{}PREBID{}BIDDERS{}1", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); @@ -628,8 +617,12 @@ mod tests { } let settings = res.expect("Settings should parse with indexed env override"); + let cfg = settings + .integration_config::("prebid") + .expect("Prebid config query should succeed") + .expect("Prebid config should exist with indexed env override"); assert_eq!( - settings.prebid.bidders, + cfg.bidders, vec!["smartadserver".to_string(), "openx".to_string()] ); }); diff --git a/crates/common/src/settings_data.rs b/crates/common/src/settings_data.rs index b16c5c8..7061ca2 100644 --- a/crates/common/src/settings_data.rs +++ b/crates/common/src/settings_data.rs @@ -50,7 +50,6 @@ mod tests { assert!(!settings.publisher.domain.is_empty()); assert!(!settings.publisher.cookie_domain.is_empty()); assert!(!settings.publisher.origin_url.is_empty()); - assert!(!settings.prebid.server_url.is_empty()); assert!(!settings.synthetic.counter_store.is_empty()); assert!(!settings.synthetic.opid_store.is_empty()); assert!(!settings.synthetic.secret_key.is_empty()); diff --git a/crates/common/src/test_support.rs b/crates/common/src/test_support.rs index da820ac..d3ff379 100644 --- a/crates/common/src/test_support.rs +++ b/crates/common/src/test_support.rs @@ -16,7 +16,8 @@ pub mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" - [prebid] + [integrations.prebid] + enabled = true server_url = "https://test-prebid.com/openrtb2/auction" [synthetic] diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 21cbcaa..69c0b2b 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -3,7 +3,6 @@ use fastly::http::Method; use fastly::{Error, Request, Response}; use log_fastly::Logger; -use trusted_server_common::ad::{handle_server_ad, handle_server_ad_get}; use trusted_server_common::auth::enforce_basic_auth; use trusted_server_common::error::TrustedServerError; use trusted_server_common::integrations::IntegrationRegistry; @@ -74,8 +73,6 @@ async fn route_request( (Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(&settings, req), // tsjs endpoints - (Method::GET, "/first-party/ad") => handle_server_ad_get(&settings, req).await, - (Method::POST, "/third-party/ad") => handle_server_ad(&settings, req).await, (Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await, (Method::GET, "/first-party/click") => handle_first_party_click(&settings, req).await, (Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => { diff --git a/docs/integration_guide.md b/docs/integration_guide.md index 0f2fee6..1291806 100644 --- a/docs/integration_guide.md +++ b/docs/integration_guide.md @@ -161,7 +161,7 @@ impl IntegrationAttributeRewriter for MyIntegration { // Drop remote script entirely – unified bundle already contains the logic. AttributeRewriteAction::remove_element() } else if attr_name == "src" { - AttributeRewriteAction::replace(tsjs::integration_script_src("my_integration")) + AttributeRewriteAction::replace(tsjs::unified_script_src()) } else { AttributeRewriteAction::keep() } @@ -194,7 +194,8 @@ rewrite embedded Next.js payloads. Returning `AttributeRewriteAction::remove_element()` (or `ScriptRewriteAction::RemoveNode` for inline content) removes the element entirely, so integrations can drop publisher-provided markup when the -Trusted Server already injects a safe alternative. +Trusted Server already injects a safe alternative. Prebid, for example, simply removes `prebid.js` +because the unified TSJS bundle is injected automatically at the start of ``. ### 6. Register the module @@ -216,10 +217,9 @@ Place any integration-specific JavaScript entrypoint under `crates/js/lib/src/in (for example, `crates/js/lib/src/integrations/testlight.ts`). The shared `npm run build` script automatically discovers every file in that directory and produces a bundle named `tsjs-.js`, which the Rust crate embeds as `/static/tsjs=tsjs-.min.js`. -In your Rust module, call `tsjs::integration_script_src("")` to obtain the cache-busted -URL for rewrites (see the Testlight example for reference). Register each bundle with -`IntegrationRegistration::with_asset("")` to have the HTML processor inject the -corresponding ` - - - - -
-

Prebid Integration Test - Trusted Server

- -
-

Test Configuration

-

Endpoint: http://localhost:8080/openrtb2/auction (First-party!)

-

Sync Endpoint: http://localhost:8080/cookie_sync (First-party!)

-

Bidders: Kargo, Rubicon, AppNexus (via Prebid Server)

-

All ad serving happens through the trusted-server domain to ensure privacy.

-
- -
-
-

Ad slot: 728x90 or 300x250

-

Waiting for bids...

-
-
- -
- - - -
- -
-

Bid Response

-
Waiting for bids...
-

All Bids:

-
Waiting for response...
-
- -
-

Console Output

-

Open browser console (F12) to see detailed Prebid debug information.

-
-
- - - - \ No newline at end of file diff --git a/trusted-server.toml b/trusted-server.toml index 9ec4ab6..6a1097a 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -13,12 +13,6 @@ proxy_secret = "change-me-proxy-secret" enabled = false rewrite_attributes = ["href", "link", "url"] -[prebid] -server_url = "http://68.183.113.79:8000" -timeout_ms = 1000 -bidders = ["kargo", "rubicon", "appnexus", "openx"] -auto_configure = false -debug = false [synthetic] counter_store = "counter_store" @@ -44,6 +38,14 @@ enabled = false # Set to true to enable request signing config_store_id = "" # set config/secret store ids for key rotation secret_store_id = "" +[integrations.prebid] +enabled = true +server_url = "http://68.183.113.79:8000" +timeout_ms = 1000 +bidders = ["kargo", "rubicon", "appnexus", "openx"] +auto_configure = false +debug = false + [integrations.testlight] endpoint = "https://testlight.example/openrtb2/auction" timeout_ms = 1200