Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions crates/common/src/didomi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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<Response, error_stack::Report<TrustedServerError>> {
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 {
use super::*;

#[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/"));
}
}
5 changes: 5 additions & 0 deletions crates/common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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 {}
Expand Down Expand Up @@ -85,6 +89,7 @@ impl IntoHttpResponse for TrustedServerError {
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::FastlyError { .. } => StatusCode::BAD_GATEWAY,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
pub mod advertiser;
pub mod constants;
pub mod cookies;
pub mod didomi;
pub mod error;
pub mod gam;
pub mod gdpr;
Expand Down
2 changes: 2 additions & 0 deletions crates/common/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ pub const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
});
});
</script>
<script type="text/javascript">(function(){function i(e){if(!window.frames[e]){if(document.body&&document.body.firstChild){var t=document.body;var n=document.createElement("iframe");n.style.display="none";n.name=e;n.title=e;t.insertBefore(n,t.firstChild)}else{setTimeout(function(){i(e)},5)}}}function e(n,o,r,f,s){function e(e,t,n,i){if(typeof n!=="function"){return}if(!window[o]){window[o]=[]}var a=false;if(s){a=s(e,i,n)}if(!a){window[o].push({command:e,version:t,callback:n,parameter:i})}}e.stub=true;e.stubVersion=2;function t(i){if(!window[n]||window[n].stub!==true){return}if(!i.data){return}var a=typeof i.data==="string";var e;try{e=a?JSON.parse(i.data):i.data}catch(t){return}if(e[r]){var o=e[r];window[n](o.command,o.version,function(e,t){var n={};n[f]={returnValue:e,success:t,callId:o.callId};if(i.source){i.source.postMessage(a?JSON.stringify(n):n,"*")}},o.parameter)}}if(typeof window[n]!=="function"){window[n]=e;if(window.addEventListener){window.addEventListener("message",t,false)}else{window.attachEvent("onmessage",t)}}}e("__tcfapi","__tcfapiBuffer","__tcfapiCall","__tcfapiReturn");i("__tcfapiLocator")})();</script><script type="text/javascript">(function(){window.__gpp_addFrame=function(e){if(!window.frames[e]){if(document.body){var t=document.createElement("iframe");t.style.cssText="display:none";t.name=e;document.body.appendChild(t)}else{window.setTimeout(window.__gpp_addFrame,10,e)}}};window.__gpp_stub=function(){var e=arguments;__gpp.queue=__gpp.queue||[];__gpp.events=__gpp.events||[];if(!e.length||e.length==1&&e[0]=="queue"){return __gpp.queue}if(e.length==1&&e[0]=="events"){return __gpp.events}var t=e[0];var p=e.length>1?e[1]:null;var s=e.length>2?e[2]:null;if(t==="ping"){p({gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:["2:tcfeuv2","5:tcfcav1","6:uspv1","7:usnat","8:usca","9:usva","10:usco","11:usut","12:usct"],cmpId:"7",sectionList:[],applicableSections:[],gppString:"",parsedSections:{}},true)}else if(t==="addEventListener"){if(!("lastId"in __gpp)){__gpp.lastId=0}__gpp.lastId++;var n=__gpp.lastId;__gpp.events.push({id:n,callback:p,parameter:s});p({eventName:"listenerRegistered",listenerId:n,data:true,pingData:{gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:["2:tcfeuv2","5:tcfcav1","6:uspv1","7:usnat","8:usca","9:usva","10:usco","11:usut","12:usct"],cmpId:"7",sectionList:[],applicableSections:[],gppString:"",parsedSections:{}}},true)}else if(t==="removeEventListener"){var a=false;for(var i=0;i<__gpp.events.length;i++){if(__gpp.events[i].id==s){__gpp.events.splice(i,1);a=true;break}}p({eventName:"listenerRemoved",listenerId:s,data:a,pingData:{gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:["2:tcfeuv2","5:tcfcav1","6:uspv1","7:usnat","8:usca","9:usva","10:usco","11:usut","12:usct"],cmpId:"7",sectionList:[],applicableSections:[],gppString:"",parsedSections:{}}},true)}else if(t==="hasSection"){p(false,true)}else if(t==="getSection"||t==="getField"){p(null,true)}else{__gpp.queue.push([].slice.apply(e))}};window.__gpp_msghandler=function(s){var n=typeof s.data==="string";try{var e=n?JSON.parse(s.data):s.data}catch(t){var e=null}if(typeof e==="object"&&e!==null&&"__gppCall"in e){var a=e.__gppCall;window.__gpp(a.command,function(e,t){var p={__gppReturn:{returnValue:e,success:t,callId:a.callId}};s.source.postMessage(n?JSON.stringify(p):p,"*")},"parameter"in a?a.parameter:null,"version"in a?a.version:"1.1")}};if(!("__gpp"in window)||typeof window.__gpp!=="function"){window.__gpp=window.__gpp_stub;window.addEventListener("message",window.__gpp_msghandler,false);window.__gpp_addFrame("__gppLocator")}})();</script><script type="text/javascript">(function(){(function(e,i,o){var n=document.createElement("link");n.rel="preconnect";n.as="script";var t=document.createElement("link");t.rel="dns-prefetch";t.as="script";var r=document.createElement("script");r.id="spcloader";r.type="text/javascript";r["async"]=true;r.charset="utf-8";window.didomiConfig=window.didomiConfig||{};window.didomiConfig.sdkPath=window.didomiConfig.sdkPath||o||"https://sdk.privacy-center.org/";const d=window.didomiConfig.sdkPath;var a=d+e+"/loader.js?target_type=notice&target="+i;if(window.didomiConfig&&window.didomiConfig.user){var c=window.didomiConfig.user;var s=c.country;var f=c.region;if(s){a=a+"&country="+s;if(f){a=a+"&region="+f}}}n.href=d;t.href=d;r.src=a;var m=document.getElementsByTagName("script")[0];m.parentNode.insertBefore(n,m);m.parentNode.insertBefore(t,m);m.parentNode.insertBefore(r,m)})("24cd3901-9da4-4643-96a3-9b1c573b5264","J3nR2TTU","http://127.0.0.1:7676/consent/")})();</script>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Is it possible for Didomi to host the content of this file?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really as this is the snippet code Didomi customers must add to their frontend to actually load our SDK. A setting as recommended below sounds a good idea. The setting could enable further options if necessary / in the future.

</head>
<body>
<!-- GDPR Consent Banner -->
Expand Down Expand Up @@ -1113,6 +1114,7 @@ pub const GAM_TEST_TEMPLATE: &str = r#"
color: #856404;
}
</style>
<script type="text/javascript">(function(){function i(e){if(!window.frames[e]){if(document.body&&document.body.firstChild){var t=document.body;var n=document.createElement("iframe");n.style.display="none";n.name=e;n.title=e;t.insertBefore(n,t.firstChild)}else{setTimeout(function(){i(e)},5)}}}function e(n,o,r,f,s){function e(e,t,n,i){if(typeof n!=="function"){return}if(!window[o]){window[o]=[]}var a=false;if(s){a=s(e,i,n)}if(!a){window[o].push({command:e,version:t,callback:n,parameter:i})}}e.stub=true;e.stubVersion=2;function t(i){if(!window[n]||window[n].stub!==true){return}if(!i.data){return}var a=typeof i.data==="string";var e;try{e=a?JSON.parse(i.data):i.data}catch(t){return}if(e[r]){var o=e[r];window[n](o.command,o.version,function(e,t){var n={};n[f]={returnValue:e,success:t,callId:o.callId};if(i.source){i.source.postMessage(a?JSON.stringify(n):n,"*")}},o.parameter)}}if(typeof window[n]!=="function"){window[n]=e;if(window.addEventListener){window.addEventListener("message",t,false)}else{window.attachEvent("onmessage",t)}}}e("__tcfapi","__tcfapiBuffer","__tcfapiCall","__tcfapiReturn");i("__tcfapiLocator")})();</script><script type="text/javascript">(function(){window.__gpp_addFrame=function(e){if(!window.frames[e]){if(document.body){var t=document.createElement("iframe");t.style.cssText="display:none";t.name=e;document.body.appendChild(t)}else{window.setTimeout(window.__gpp_addFrame,10,e)}}};window.__gpp_stub=function(){var e=arguments;__gpp.queue=__gpp.queue||[];__gpp.events=__gpp.events||[];if(!e.length||e.length==1&&e[0]=="queue"){return __gpp.queue}if(e.length==1&&e[0]=="events"){return __gpp.events}var t=e[0];var p=e.length>1?e[1]:null;var s=e.length>2?e[2]:null;if(t==="ping"){p({gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:["2:tcfeuv2","5:tcfcav1","6:uspv1","7:usnat","8:usca","9:usva","10:usco","11:usut","12:usct"],cmpId:"7",sectionList:[],applicableSections:[],gppString:"",parsedSections:{}},true)}else if(t==="addEventListener"){if(!("lastId"in __gpp)){__gpp.lastId=0}__gpp.lastId++;var n=__gpp.lastId;__gpp.events.push({id:n,callback:p,parameter:s});p({eventName:"listenerRegistered",listenerId:n,data:true,pingData:{gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:["2:tcfeuv2","5:tcfcav1","6:uspv1","7:usnat","8:usca","9:usva","10:usco","11:usut","12:usct"],cmpId:"7",sectionList:[],applicableSections:[],gppString:"",parsedSections:{}}},true)}else if(t==="removeEventListener"){var a=false;for(var i=0;i<__gpp.events.length;i++){if(__gpp.events[i].id==s){__gpp.events.splice(i,1);a=true;break}}p({eventName:"listenerRemoved",listenerId:s,data:a,pingData:{gppVersion:"1.1",cmpStatus:"stub",cmpDisplayStatus:"hidden",signalStatus:"not ready",supportedAPIs:["2:tcfeuv2","5:tcfcav1","6:uspv1","7:usnat","8:usca","9:usva","10:usco","11:usut","12:usct"],cmpId:"7",sectionList:[],applicableSections:[],gppString:"",parsedSections:{}}},true)}else if(t==="hasSection"){p(false,true)}else if(t==="getSection"||t==="getField"){p(null,true)}else{__gpp.queue.push([].slice.apply(e))}};window.__gpp_msghandler=function(s){var n=typeof s.data==="string";try{var e=n?JSON.parse(s.data):s.data}catch(t){var e=null}if(typeof e==="object"&&e!==null&&"__gppCall"in e){var a=e.__gppCall;window.__gpp(a.command,function(e,t){var p={__gppReturn:{returnValue:e,success:t,callId:a.callId}};s.source.postMessage(n?JSON.stringify(p):p,"*")},"parameter"in a?a.parameter:null,"version"in a?a.version:"1.1")}};if(!("__gpp"in window)||typeof window.__gpp!=="function"){window.__gpp=window.__gpp_stub;window.addEventListener("message",window.__gpp_msghandler,false);window.__gpp_addFrame("__gppLocator")}})();</script><script type="text/javascript">(function(){(function(e,i,o){var n=document.createElement("link");n.rel="preconnect";n.as="script";var t=document.createElement("link");t.rel="dns-prefetch";t.as="script";var r=document.createElement("script");r.id="spcloader";r.type="text/javascript";r["async"]=true;r.charset="utf-8";window.didomiConfig=window.didomiConfig||{};window.didomiConfig.sdkPath=window.didomiConfig.sdkPath||o||"https://sdk.privacy-center.org/";const d=window.didomiConfig.sdkPath;var a=d+e+"/loader.js?target_type=notice&target="+i;if(window.didomiConfig&&window.didomiConfig.user){var c=window.didomiConfig.user;var s=c.country;var f=c.region;if(s){a=a+"&country="+s;if(f){a=a+"&region="+f}}}n.href=d;t.href=d;r.src=a;var m=document.getElementsByTagName("script")[0];m.parentNode.insertBefore(n,m);m.parentNode.insertBefore(t,m);m.parentNode.insertBefore(r,m)})("24cd3901-9da4-4643-96a3-9b1c573b5264","J3nR2TTU","http://127.0.0.1:7676/consent/")})();</script>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Should this script be included only if Didomi is used by publisher? I would recommend we have a setting to enable Didomi.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script must be included only if Didomi is used by the publisher, indeed. What would be your recommendation in regard of creating such setting? Note that the script is differs from one publisher to another and must be added by the publisher himself. Any help on how to create such setting would be appreciated as this will define the way those settings are set in the project.

</head>
<body>
<div class="container">
Expand Down
6 changes: 6 additions & 0 deletions crates/fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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, is_gam_asset_path,
Expand Down Expand Up @@ -74,6 +75,11 @@ async fn route_request(settings: Settings, req: Request) -> Result<Response, Err
(&Method::GET, "/privacy-policy") => handle_privacy_policy(&settings, req),
(&Method::GET, "/why-trusted-server") => handle_why_trusted_server(&settings, req),

// Didomi CMP routes
(_, path) if path.starts_with("/consent/") => {
DidomiProxy::handle_consent_request(&settings, req).await
}

// Catch-all 404 handler
_ => return Ok(not_found_response()),
};
Expand Down
Loading