Skip to content

Commit 060cc8c

Browse files
committed
feat: implement Didomi CMP reverse proxy
- Add complete reverse proxy implementation in crates/common/src/didomi.rs - Add FastlyError variant for proxy error handling - Add /consent/* path routing in main handler - Update fastly.toml with didomi_sdk and didomi_api backends - Add CMP scripts to HTML templates - Route API calls to api.privacy-center.org and SDK to sdk.privacy-center.org - Include proper header forwarding and CORS handling Note: Didomi SDK domain (127.0.0.1:7676) needs update before deployment
1 parent 9a6ba74 commit 060cc8c

File tree

6 files changed

+235
-0
lines changed

6 files changed

+235
-0
lines changed

crates/common/src/didomi.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
use crate::error::TrustedServerError;
2+
use crate::settings::Settings;
3+
use fastly::http::{header, Method};
4+
use fastly::{Request, Response};
5+
use log;
6+
7+
/// Handles Didomi CMP reverse proxy requests
8+
///
9+
/// This module implements the reverse proxy functionality for Didomi CMP
10+
/// according to their self-hosting documentation:
11+
/// https://developers.didomi.io/api-and-platform/domains/self-hosting
12+
pub struct DidomiProxy;
13+
14+
impl DidomiProxy {
15+
/// Handle requests to /consent/* paths
16+
///
17+
/// Routes requests to either SDK or API origins based on path:
18+
/// - /consent/api/* → api.privacy-center.org
19+
/// - /consent/* → sdk.privacy-center.org
20+
pub async fn handle_consent_request(
21+
_settings: &Settings,
22+
req: Request,
23+
) -> Result<Response, error_stack::Report<TrustedServerError>> {
24+
let path = req.get_path();
25+
26+
log::info!("Didomi proxy handling request: {}", path);
27+
// Force redeploy to fix intermittent issue
28+
29+
log::info!("DEBUG: Starting path extraction");
30+
31+
// Extract the consent path (remove /consent prefix)
32+
let consent_path = path.strip_prefix("/consent").unwrap_or(path);
33+
34+
log::info!("DEBUG: consent_path = {}", consent_path);
35+
36+
// Determine which origin to use
37+
let (backend_name, origin_path) = if consent_path.starts_with("/api/") {
38+
// API calls go to api.privacy-center.org with no caching
39+
("didomi_api", consent_path)
40+
} else {
41+
// SDK files go to sdk.privacy-center.org with geo-based caching
42+
("didomi_sdk", consent_path)
43+
};
44+
45+
log::info!("DEBUG: backend_name = {}, origin_path = {}", backend_name, origin_path);
46+
47+
log::info!("Routing to backend: {} with path: {}", backend_name, origin_path);
48+
49+
log::info!("DEBUG: About to create proxy request");
50+
51+
// Create the full URL for the request
52+
let backend_host = match backend_name {
53+
"didomi_sdk" => "sdk.privacy-center.org",
54+
"didomi_api" => "api.privacy-center.org",
55+
_ => return Ok(Response::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR)
56+
.with_header(header::CONTENT_TYPE, "text/plain")
57+
.with_body("Unknown backend")),
58+
};
59+
60+
let full_url = format!("https://{}{}", backend_host, origin_path);
61+
log::info!("Full URL constructed: {}", full_url);
62+
63+
// Create the proxy request using Request::new like prebid module
64+
let mut proxy_req = Request::new(req.get_method().clone(), full_url);
65+
66+
log::info!("Created proxy request with method: {:?}", req.get_method());
67+
68+
// Copy query string
69+
if let Some(query) = req.get_query_str() {
70+
proxy_req.set_query_str(query);
71+
}
72+
73+
// Set required headers according to Didomi documentation
74+
Self::set_proxy_headers(&mut proxy_req, &req, backend_name);
75+
76+
// Send the request
77+
log::info!("Sending request to backend: {} with path: {}", backend_name, origin_path);
78+
79+
// Copy request body for POST/PUT requests
80+
if matches!(req.get_method(), &Method::POST | &Method::PUT) {
81+
proxy_req.set_body(req.into_body());
82+
}
83+
84+
match proxy_req.send(backend_name) {
85+
Ok(mut response) => {
86+
log::info!("Received response from {}: {}", backend_name, response.get_status());
87+
88+
// Process the response according to Didomi requirements
89+
Self::process_response(&mut response, backend_name);
90+
91+
Ok(response)
92+
}
93+
Err(e) => {
94+
log::error!("Error proxying request to {}: {:?}", backend_name, e);
95+
Err(error_stack::Report::new(TrustedServerError::FastlyError {
96+
message: format!("Proxy error to {}: {}", backend_name, e)
97+
}))
98+
}
99+
}
100+
}
101+
102+
/// Set proxy headers according to Didomi documentation
103+
fn set_proxy_headers(
104+
proxy_req: &mut Request,
105+
original_req: &Request,
106+
backend_name: &str,
107+
) {
108+
// Host header is automatically set when using full URLs
109+
110+
// Forward user IP in X-Forwarded-For header
111+
if let Some(client_ip) = original_req.get_client_ip_addr() {
112+
proxy_req.set_header("X-Forwarded-For", client_ip.to_string());
113+
}
114+
115+
// Forward geographic information for SDK requests (for geo-based caching)
116+
if backend_name == "didomi_sdk" {
117+
// Copy geographic headers from Fastly
118+
let geo_headers = [
119+
("X-Geo-Country", "FastlyGeo-CountryCode"),
120+
("X-Geo-Region", "FastlyGeo-Region"),
121+
("CloudFront-Viewer-Country", "FastlyGeo-CountryCode"),
122+
];
123+
124+
for (header_name, fastly_header) in geo_headers {
125+
if let Some(value) = original_req.get_header(fastly_header) {
126+
proxy_req.set_header(header_name, value);
127+
}
128+
}
129+
}
130+
131+
// Forward essential headers
132+
let headers_to_forward = [
133+
header::ACCEPT,
134+
header::ACCEPT_LANGUAGE,
135+
header::ACCEPT_ENCODING,
136+
header::USER_AGENT,
137+
header::REFERER,
138+
header::ORIGIN,
139+
header::AUTHORIZATION,
140+
];
141+
142+
for header_name in headers_to_forward {
143+
if let Some(value) = original_req.get_header(&header_name) {
144+
proxy_req.set_header(&header_name, value);
145+
}
146+
}
147+
148+
// DO NOT forward cookies (as per Didomi documentation)
149+
// proxy_req.remove_header(header::COOKIE);
150+
151+
// Set content type for POST/PUT requests
152+
if matches!(original_req.get_method(), &Method::POST | &Method::PUT) {
153+
if let Some(content_type) = original_req.get_header(header::CONTENT_TYPE) {
154+
proxy_req.set_header(header::CONTENT_TYPE, content_type);
155+
}
156+
}
157+
158+
log::info!("Proxy headers set for {}", backend_name);
159+
}
160+
161+
/// Process response according to Didomi requirements
162+
fn process_response(response: &mut Response, backend_name: &str) {
163+
// Add CORS headers for SDK requests
164+
if backend_name == "didomi_sdk" {
165+
response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*");
166+
response.set_header(
167+
header::ACCESS_CONTROL_ALLOW_HEADERS,
168+
"Content-Type, Authorization, X-Requested-With",
169+
);
170+
response.set_header(
171+
header::ACCESS_CONTROL_ALLOW_METHODS,
172+
"GET, POST, PUT, DELETE, OPTIONS",
173+
);
174+
}
175+
176+
// Log cache headers for debugging
177+
if let Some(cache_control) = response.get_header(header::CACHE_CONTROL) {
178+
log::info!("Cache-Control from {}: {:?}", backend_name, cache_control);
179+
}
180+
181+
// Ensure cache headers are preserved (they will be returned to the client)
182+
// This is important for Didomi's caching requirements
183+
184+
log::info!("Response processed for {}", backend_name);
185+
}
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
192+
#[test]
193+
fn test_consent_path_extraction() {
194+
let path = "/consent/api/events";
195+
let consent_path = path.strip_prefix("/consent").unwrap_or(path);
196+
assert_eq!(consent_path, "/api/events");
197+
198+
let path = "/consent/24cd3901-9da4-4643-96a3-9b1c573b5264/loader.js";
199+
let consent_path = path.strip_prefix("/consent").unwrap_or(path);
200+
assert_eq!(consent_path, "/24cd3901-9da4-4643-96a3-9b1c573b5264/loader.js");
201+
}
202+
203+
#[test]
204+
fn test_backend_selection() {
205+
// API requests
206+
let api_path = "/api/events";
207+
assert!(api_path.starts_with("/api/"));
208+
209+
// SDK requests
210+
let sdk_path = "/24cd3901-9da4-4643-96a3-9b1c573b5264/loader.js";
211+
assert!(!sdk_path.starts_with("/api/"));
212+
213+
let sdk_path2 = "/sdk/version/core.js";
214+
assert!(!sdk_path2.starts_with("/api/"));
215+
}
216+
}

crates/common/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ pub enum TrustedServerError {
5757
/// Template rendering error.
5858
#[display("Template error: {message}")]
5959
Template { message: String },
60+
61+
/// Fastly platform error.
62+
#[display("Fastly error: {message}")]
63+
FastlyError { message: String },
6064
}
6165

6266
impl Error for TrustedServerError {}
@@ -84,6 +88,7 @@ impl IntoHttpResponse for TrustedServerError {
8488
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
8589
Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE,
8690
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,
91+
Self::FastlyError { .. } => StatusCode::BAD_GATEWAY,
8792
}
8893
}
8994

crates/common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ pub mod synthetic;
3737
pub mod templates;
3838
pub mod test_support;
3939
pub mod why;
40+
pub mod didomi;

crates/common/src/templates.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ pub const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
247247
});
248248
});
249249
</script>
250+
<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>
250251
</head>
251252
<body>
252253
<!-- GDPR Consent Banner -->
@@ -431,6 +432,7 @@ pub const GAM_TEST_TEMPLATE: &str = r#"
431432
color: #856404;
432433
}
433434
</style>
435+
<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>
434436
</head>
435437
<body>
436438
<div class="container">

crates/fastly/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ use trusted_server_common::settings::Settings;
1818
use trusted_server_common::settings_data::get_settings;
1919
use trusted_server_common::templates::GAM_TEST_TEMPLATE;
2020
use trusted_server_common::why::handle_why_trusted_server;
21+
use trusted_server_common::didomi::DidomiProxy;
22+
2123

2224
#[fastly::main]
2325
fn main(req: Request) -> Result<Response, Error> {
@@ -69,6 +71,11 @@ async fn route_request(settings: Settings, req: Request) -> Result<Response, Err
6971
(&Method::GET, "/privacy-policy") => handle_privacy_policy(&settings, req),
7072
(&Method::GET, "/why-trusted-server") => handle_why_trusted_server(&settings, req),
7173

74+
// Didomi CMP routes
75+
(_, path) if path.starts_with("/consent/") => {
76+
DidomiProxy::handle_consent_request(&settings, req).await
77+
}
78+
7279
// Catch-all 404 handler
7380
_ => return Ok(not_found_response()),
7481
};

0 commit comments

Comments
 (0)