diff --git a/crates/common/src/didomi.rs b/crates/common/src/didomi.rs new file mode 100644 index 0000000..da6f695 --- /dev/null +++ b/crates/common/src/didomi.rs @@ -0,0 +1,234 @@ +use crate::error::TrustedServerError; +use crate::settings::Settings; +use fastly::http::{header, Method}; +use fastly::{Request, Response}; +use log; + +/// Handles Didomi CMP reverse proxy requests +/// +/// This module implements the reverse proxy functionality for Didomi CMP +/// according to their self-hosting documentation: +/// https://developers.didomi.io/api-and-platform/domains/self-hosting +pub struct DidomiProxy; + +impl DidomiProxy { + /// Handle requests to /consent/* paths + /// + /// Routes requests to either SDK or API origins based on path: + /// - /consent/api/* → api.privacy-center.org + /// - /consent/* → sdk.privacy-center.org + pub async fn handle_consent_request( + _settings: &Settings, + req: Request, + ) -> Result> { + let path = req.get_path(); + + log::info!("Didomi proxy handling request: {}", path); + // Force redeploy to fix intermittent issue + + log::info!("DEBUG: Starting path extraction"); + + // Extract the consent path (remove /consent prefix) + let consent_path = path.strip_prefix("/consent").unwrap_or(path); + + log::info!("DEBUG: consent_path = {}", consent_path); + + // Determine which origin to use + let (backend_name, origin_path) = if consent_path.starts_with("/api/") { + // API calls go to api.privacy-center.org with no caching + ("didomi_api", consent_path) + } else { + // SDK files go to sdk.privacy-center.org with geo-based caching + ("didomi_sdk", consent_path) + }; + + log::info!( + "DEBUG: backend_name = {}, origin_path = {}", + backend_name, + origin_path + ); + + log::info!( + "Routing to backend: {} with path: {}", + backend_name, + origin_path + ); + + log::info!("DEBUG: About to create proxy request"); + + // Create the full URL for the request + let backend_host = match backend_name { + "didomi_sdk" => "sdk.privacy-center.org", + "didomi_api" => "api.privacy-center.org", + _ => { + return Ok( + Response::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_header(header::CONTENT_TYPE, "text/plain") + .with_body("Unknown backend"), + ) + } + }; + + let full_url = format!("https://{}{}", backend_host, origin_path); + log::info!("Full URL constructed: {}", full_url); + + // Create the proxy request using Request::new like prebid module + let mut proxy_req = Request::new(req.get_method().clone(), full_url); + + log::info!("Created proxy request with method: {:?}", req.get_method()); + + // Copy query string + if let Some(query) = req.get_query_str() { + proxy_req.set_query_str(query); + } + + // Set required headers according to Didomi documentation + Self::set_proxy_headers(&mut proxy_req, &req, backend_name); + + // Send the request + log::info!( + "Sending request to backend: {} with path: {}", + backend_name, + origin_path + ); + + // Copy request body for POST/PUT requests + if matches!(req.get_method(), &Method::POST | &Method::PUT) { + proxy_req.set_body(req.into_body()); + } + + match proxy_req.send(backend_name) { + Ok(mut response) => { + log::info!( + "Received response from {}: {}", + backend_name, + response.get_status() + ); + + // Process the response according to Didomi requirements + Self::process_response(&mut response, backend_name); + + Ok(response) + } + Err(e) => { + log::error!("Error proxying request to {}: {:?}", backend_name, e); + Err(error_stack::Report::new(TrustedServerError::FastlyError { + message: format!("Proxy error to {}: {}", backend_name, e), + })) + } + } + } + + /// Set proxy headers according to Didomi documentation + fn set_proxy_headers(proxy_req: &mut Request, original_req: &Request, backend_name: &str) { + // Host header is automatically set when using full URLs + + // Forward user IP in X-Forwarded-For header + if let Some(client_ip) = original_req.get_client_ip_addr() { + proxy_req.set_header("X-Forwarded-For", client_ip.to_string()); + } + + // Forward geographic information for SDK requests (for geo-based caching) + if backend_name == "didomi_sdk" { + // Copy geographic headers from Fastly + let geo_headers = [ + ("X-Geo-Country", "FastlyGeo-CountryCode"), + ("X-Geo-Region", "FastlyGeo-Region"), + ("CloudFront-Viewer-Country", "FastlyGeo-CountryCode"), + ]; + + for (header_name, fastly_header) in geo_headers { + if let Some(value) = original_req.get_header(fastly_header) { + proxy_req.set_header(header_name, value); + } + } + } + + // Forward essential headers + let headers_to_forward = [ + header::ACCEPT, + header::ACCEPT_LANGUAGE, + header::ACCEPT_ENCODING, + header::USER_AGENT, + header::REFERER, + header::ORIGIN, + header::AUTHORIZATION, + ]; + + for header_name in headers_to_forward { + if let Some(value) = original_req.get_header(&header_name) { + proxy_req.set_header(&header_name, value); + } + } + + // DO NOT forward cookies (as per Didomi documentation) + // proxy_req.remove_header(header::COOKIE); + + // Set content type for POST/PUT requests + if matches!(original_req.get_method(), &Method::POST | &Method::PUT) { + if let Some(content_type) = original_req.get_header(header::CONTENT_TYPE) { + proxy_req.set_header(header::CONTENT_TYPE, content_type); + } + } + + log::info!("Proxy headers set for {}", backend_name); + } + + /// Process response according to Didomi requirements + fn process_response(response: &mut Response, backend_name: &str) { + // Add CORS headers for SDK requests + if backend_name == "didomi_sdk" { + response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + response.set_header( + header::ACCESS_CONTROL_ALLOW_HEADERS, + "Content-Type, Authorization, X-Requested-With", + ); + response.set_header( + header::ACCESS_CONTROL_ALLOW_METHODS, + "GET, POST, PUT, DELETE, OPTIONS", + ); + } + + // Log cache headers for debugging + if let Some(cache_control) = response.get_header(header::CACHE_CONTROL) { + log::info!("Cache-Control from {}: {:?}", backend_name, cache_control); + } + + // Ensure cache headers are preserved (they will be returned to the client) + // This is important for Didomi's caching requirements + + log::info!("Response processed for {}", backend_name); + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn test_consent_path_extraction() { + let path = "/consent/api/events"; + let consent_path = path.strip_prefix("/consent").unwrap_or(path); + assert_eq!(consent_path, "/api/events"); + + let path = "/consent/24cd3901-9da4-4643-96a3-9b1c573b5264/loader.js"; + let consent_path = path.strip_prefix("/consent").unwrap_or(path); + assert_eq!( + consent_path, + "/24cd3901-9da4-4643-96a3-9b1c573b5264/loader.js" + ); + } + + #[test] + fn test_backend_selection() { + // API requests + let api_path = "/api/events"; + assert!(api_path.starts_with("/api/")); + + // SDK requests + let sdk_path = "/24cd3901-9da4-4643-96a3-9b1c573b5264/loader.js"; + assert!(!sdk_path.starts_with("/api/")); + + let sdk_path2 = "/sdk/version/core.js"; + assert!(!sdk_path2.starts_with("/api/")); + } +} diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index 671f422..b5322db 100644 --- a/crates/common/src/error.rs +++ b/crates/common/src/error.rs @@ -65,6 +65,10 @@ pub enum TrustedServerError { /// Template rendering error. #[display("Template error: {message}")] Template { message: String }, + + /// Fastly platform error. + #[display("Fastly error: {message}")] + FastlyError { message: String }, } impl Error for TrustedServerError {} @@ -94,6 +98,7 @@ impl IntoHttpResponse for TrustedServerError { Self::Proxy { .. } => StatusCode::BAD_GATEWAY, Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::FastlyError { .. } => StatusCode::BAD_GATEWAY, } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 90e5d36..830dba7 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -28,6 +28,7 @@ pub mod backend; pub mod constants; pub mod cookies; pub mod creative; +pub mod didomi; pub mod error; pub mod gam; pub mod gdpr; diff --git a/crates/common/src/templates.rs b/crates/common/src/templates.rs index f416ffc..dfcf9d2 100644 --- a/crates/common/src/templates.rs +++ b/crates/common/src/templates.rs @@ -249,6 +249,7 @@ pub const HTML_TEMPLATE: &str = r#" }); }); + @@ -1113,6 +1114,7 @@ pub const GAM_TEST_TEMPLATE: &str = r#" color: #856404; } +
diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 3d4cb02..c856bb9 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -1,32 +1,27 @@ -use fastly::http::Method; +use fastly::http::{header, Method, StatusCode}; use fastly::{Error, Request, Response}; use log_fastly::Logger; -use trusted_server_common::ad::{handle_server_ad, handle_server_ad_get}; +mod error; +use crate::error::to_error_response; + use trusted_server_common::advertiser::handle_ad_request; +use trusted_server_common::constants::HEADER_X_COMPRESS_HINT; +use trusted_server_common::didomi::DidomiProxy; use trusted_server_common::gam::{ handle_gam_asset, handle_gam_custom_url, handle_gam_golden_url, handle_gam_render, - handle_gam_test, handle_gam_test_page, is_gam_asset_path, + handle_gam_test, is_gam_asset_path, }; use trusted_server_common::gdpr::{handle_consent_request, handle_data_subject_request}; use trusted_server_common::partners::handle_partner_asset; use trusted_server_common::prebid::handle_prebid_test; -use trusted_server_common::prebid_proxy::{handle_prebid_auction, handle_prebid_cookie_sync}; use trusted_server_common::privacy::handle_privacy_policy; -use trusted_server_common::proxy::{ - handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, - handle_first_party_proxy_sign, -}; -use trusted_server_common::publisher::{ - handle_edgepubs_page, handle_main_page, handle_publisher_request, handle_tsjs_dynamic, -}; +use trusted_server_common::publisher::{handle_edgepubs_page, handle_main_page}; use trusted_server_common::settings::Settings; use trusted_server_common::settings_data::get_settings; +use trusted_server_common::templates::GAM_TEST_TEMPLATE; use trusted_server_common::why::handle_why_trusted_server; -mod error; -use crate::error::to_error_response; - #[fastly::main] fn main(req: Request) -> Result { init_logger(); @@ -45,108 +40,63 @@ fn main(req: Request) -> Result { /// Routes incoming requests to appropriate handlers. /// -/// This function implements the application's routing logic. It first checks -/// for known routes, and if none match, it proxies the request to the -/// publisher's origin server as a fallback. -/// Checks if the EdgePubs feature is enabled in experimental settings. -fn is_edgepubs_enabled(settings: &Settings) -> bool { - settings - .experimental - .as_ref() - .is_some_and(|e| e.enable_edge_pub) -} - +/// This function implements the application's routing logic, matching HTTP methods +/// and paths to their corresponding handler functions. async fn route_request(settings: Settings, req: Request) -> Result { log::info!( "FASTLY_SERVICE_VERSION: {}", - ::std::env::var("FASTLY_SERVICE_VERSION").unwrap_or_else(|_| String::new()) + std::env::var("FASTLY_SERVICE_VERSION").unwrap_or_else(|_| String::new()) ); - // Get path and method for routing - let path = req.get_path(); - let method = req.get_method(); - let is_edgepubs_enabled = is_edgepubs_enabled(&settings); - - // Match known routes and handle them - let result = match (method, path, is_edgepubs_enabled) { - // Main application routes - handle '/' dynamically based on experimental flag - (&Method::GET, "/", true) => handle_edgepubs_page(&settings, req), - - (&Method::GET, "/auburndao", _) => handle_main_page(&settings, req), - (&Method::GET, "/ad-creative", true) => handle_ad_request(&settings, req), + let result = match (req.get_method(), req.get_path()) { + // Main application routes + (&Method::GET, "/") => handle_edgepubs_page(&settings, req), + (&Method::GET, "/auburndao") => handle_main_page(&settings, req), + (&Method::GET, "/ad-creative") => handle_ad_request(&settings, req), // Direct asset serving for partner domains (like auburndao.com approach) - (&Method::GET, path, true) if is_partner_asset_path(path) => { + (&Method::GET, path) if is_partner_asset_path(path) => { handle_partner_asset(&settings, req).await } // GAM asset serving (separate from Equativ, checked after Equativ) - (&Method::GET, path, true) if is_gam_asset_path(path) => { - handle_gam_asset(&settings, req).await - } - (&Method::GET, "/prebid-test", _) => handle_prebid_test(&settings, req).await, - - // Prebid Server first-party auction endpoint - (&Method::POST, "/openrtb2/auction", _) => handle_prebid_auction(&settings, req).await, - // Prebid Server first-party cookie sync - (&Method::POST, "/cookie_sync", _) => handle_prebid_cookie_sync(&settings, req).await, - - // GAM (Google Ad Manager) routes - (&Method::GET, "/gam-test", true) => handle_gam_test(&settings, req).await, - (&Method::GET, "/gam-golden-url", true) => handle_gam_golden_url(&settings, req).await, - (&Method::POST, "/gam-test-custom-url", true) => { - handle_gam_custom_url(&settings, req).await - } - (&Method::GET, "/gam-render", true) => handle_gam_render(&settings, req).await, - (&Method::GET, "/gam-test-page", true) => handle_gam_test_page(&settings, req), - // GDPR compliance routes - (&Method::GET | &Method::POST, "/gdpr/consent", _) => { - handle_consent_request(&settings, req) - } - (&Method::GET | &Method::DELETE, "/gdpr/data", _) => { + (&Method::GET, path) if is_gam_asset_path(path) => handle_gam_asset(&settings, req).await, + (&Method::GET, "/prebid-test") => handle_prebid_test(&settings, req).await, + (&Method::GET, "/gam-test") => handle_gam_test(&settings, req).await, + (&Method::GET, "/gam-golden-url") => handle_gam_golden_url(&settings, req).await, + (&Method::POST, "/gam-test-custom-url") => handle_gam_custom_url(&settings, req).await, + (&Method::GET, "/gam-render") => handle_gam_render(&settings, req).await, + (&Method::GET, "/gam-test-page") => Ok(Response::from_status(StatusCode::OK) + .with_body(GAM_TEST_TEMPLATE) + .with_header(header::CONTENT_TYPE, "text/html") + .with_header("x-compress-hint", "on")), + (&Method::GET | &Method::POST, "/gdpr/consent") => handle_consent_request(&settings, req), + (&Method::GET | &Method::DELETE, "/gdpr/data") => { handle_data_subject_request(&settings, req) } + (&Method::GET, "/privacy-policy") => handle_privacy_policy(&settings, req), + (&Method::GET, "/why-trusted-server") => handle_why_trusted_server(&settings, req), - // Static content pages - (&Method::GET, "/privacy-policy", _) => handle_privacy_policy(&settings, req), - (&Method::GET, "/why-trusted-server", _) => handle_why_trusted_server(&settings, req), - - // Serve the tsjs library - (&Method::GET, path, _) if path.starts_with("/static/tsjs=") => { - handle_tsjs_dynamic(&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", _) => { - handle_first_party_proxy_sign(&settings, req).await - } - (&Method::POST, "/first-party/proxy-rebuild", _) => { - handle_first_party_proxy_rebuild(&settings, req).await + // Didomi CMP routes + (&Method::GET | &Method::POST, path) if path.starts_with("/consent/") => { + DidomiProxy::handle_consent_request(&settings, req).await } - // No known route matched, proxy to publisher origin as fallback - _ => { - log::info!( - "No known route matched for path: {}, proxying to publisher origin", - path - ); - - match handle_publisher_request(&settings, req) { - Ok(response) => Ok(response), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - Err(e) - } - } - } + // Catch-all 404 handler + _ => return Ok(not_found_response()), }; // Convert any errors to HTTP error responses result.map_or_else(|e| Ok(to_error_response(e)), Ok) } +/// Creates a standard 404 Not Found response. +fn not_found_response() -> Response { + Response::from_status(StatusCode::NOT_FOUND) + .with_body("Not Found") + .with_header(header::CONTENT_TYPE, "text/plain") + .with_header(HEADER_X_COMPRESS_HINT, "on") +} + +/// Check if the path is for an Equativ asset that should be served directly (like auburndao.com) fn is_partner_asset_path(path: &str) -> bool { // Only handle Equativ/Smart AdServer assets for now path.contains("/diff/") || // Equativ assets diff --git a/fastly.toml b/fastly.toml index d15cffe..f209db1 100644 --- a/fastly.toml +++ b/fastly.toml @@ -75,6 +75,12 @@ build = """ [local_server.backends.ad_cdn_backend] url = "https://cdn.adsrvr.org" # Default, will be overridden dynamically + # Didomi CMP BAckends + [local_server.backends.didomi_sdk] + url = "https://sdk.privacy-center.org" + [local_server.backends.didomi_api] + url = "https://api.privacy-center.org" + [local_server.kv_stores] [[local_server.kv_stores.counter_store]] key = "placeholder"