Skip to content

Commit 5f78374

Browse files
committed
Add Didomi integration
1 parent 012fb4c commit 5f78374

File tree

4 files changed

+503
-24
lines changed

4 files changed

+503
-24
lines changed
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
use std::sync::Arc;
2+
3+
use async_trait::async_trait;
4+
use error_stack::{Report, ResultExt};
5+
use fastly::http::{header, Method};
6+
use fastly::{Request, Response};
7+
use serde::{Deserialize, Serialize};
8+
use url::Url;
9+
use validator::Validate;
10+
11+
use crate::backend::ensure_backend_from_url;
12+
use crate::error::TrustedServerError;
13+
use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration};
14+
use crate::settings::{IntegrationConfig, Settings};
15+
16+
const DIDOMI_INTEGRATION_ID: &str = "didomi";
17+
18+
/// Configuration for the Didomi consent notice reverse proxy.
19+
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
20+
pub struct DidomiIntegrationConfig {
21+
/// Whether the integration is enabled.
22+
#[serde(default = "default_enabled")]
23+
pub enabled: bool,
24+
/// Base URL for the Didomi SDK origin.
25+
#[serde(default = "default_sdk_origin")]
26+
#[validate(url)]
27+
pub sdk_origin: String,
28+
/// Base URL for the Didomi API origin.
29+
#[serde(default = "default_api_origin")]
30+
#[validate(url)]
31+
pub api_origin: String,
32+
}
33+
34+
impl IntegrationConfig for DidomiIntegrationConfig {
35+
fn is_enabled(&self) -> bool {
36+
self.enabled
37+
}
38+
}
39+
40+
fn default_enabled() -> bool {
41+
true
42+
}
43+
44+
fn default_sdk_origin() -> String {
45+
"https://sdk.privacy-center.org".to_string()
46+
}
47+
48+
fn default_api_origin() -> String {
49+
"https://api.privacy-center.org".to_string()
50+
}
51+
52+
enum DidomiBackend {
53+
Sdk,
54+
Api,
55+
}
56+
57+
struct DidomiIntegration {
58+
config: Arc<DidomiIntegrationConfig>,
59+
}
60+
61+
impl DidomiIntegration {
62+
fn new(config: Arc<DidomiIntegrationConfig>) -> Arc<Self> {
63+
Arc::new(Self { config })
64+
}
65+
66+
fn error(message: impl Into<String>) -> TrustedServerError {
67+
TrustedServerError::Integration {
68+
integration: DIDOMI_INTEGRATION_ID.to_string(),
69+
message: message.into(),
70+
}
71+
}
72+
73+
fn backend_for_path(&self, consent_path: &str) -> DidomiBackend {
74+
if consent_path.starts_with("/api/") {
75+
DidomiBackend::Api
76+
} else {
77+
DidomiBackend::Sdk
78+
}
79+
}
80+
81+
fn build_target_url(
82+
&self,
83+
base: &str,
84+
consent_path: &str,
85+
query: Option<&str>,
86+
) -> Result<String, Report<TrustedServerError>> {
87+
let mut target =
88+
Url::parse(base).change_context(Self::error("Invalid Didomi origin URL"))?;
89+
let path = if consent_path.is_empty() {
90+
"/"
91+
} else {
92+
consent_path
93+
};
94+
target.set_path(path);
95+
target.set_query(query);
96+
Ok(target.to_string())
97+
}
98+
99+
fn copy_headers(
100+
&self,
101+
backend: &DidomiBackend,
102+
original_req: &Request,
103+
proxy_req: &mut Request,
104+
) {
105+
if let Some(client_ip) = original_req.get_client_ip_addr() {
106+
proxy_req.set_header("X-Forwarded-For", client_ip.to_string());
107+
}
108+
109+
for header_name in [
110+
header::ACCEPT,
111+
header::ACCEPT_LANGUAGE,
112+
header::ACCEPT_ENCODING,
113+
header::USER_AGENT,
114+
header::REFERER,
115+
header::ORIGIN,
116+
header::AUTHORIZATION,
117+
] {
118+
if let Some(value) = original_req.get_header(&header_name) {
119+
proxy_req.set_header(&header_name, value);
120+
}
121+
}
122+
123+
if matches!(backend, DidomiBackend::Sdk) {
124+
Self::copy_geo_headers(original_req, proxy_req);
125+
}
126+
}
127+
128+
fn copy_geo_headers(original_req: &Request, proxy_req: &mut Request) {
129+
let geo_headers = [
130+
("X-Geo-Country", "FastlyGeo-CountryCode"),
131+
("X-Geo-Region", "FastlyGeo-Region"),
132+
("CloudFront-Viewer-Country", "FastlyGeo-CountryCode"),
133+
];
134+
135+
for (target, source) in geo_headers {
136+
if let Some(value) = original_req.get_header(source) {
137+
proxy_req.set_header(target, value);
138+
}
139+
}
140+
}
141+
142+
fn add_cors_headers(response: &mut Response) {
143+
response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*");
144+
response.set_header(
145+
header::ACCESS_CONTROL_ALLOW_HEADERS,
146+
"Content-Type, Authorization, X-Requested-With",
147+
);
148+
response.set_header(
149+
header::ACCESS_CONTROL_ALLOW_METHODS,
150+
"GET, POST, PUT, DELETE, OPTIONS",
151+
);
152+
}
153+
}
154+
155+
fn build(settings: &Settings) -> Option<Arc<DidomiIntegration>> {
156+
let config = match settings.integration_config::<DidomiIntegrationConfig>(DIDOMI_INTEGRATION_ID)
157+
{
158+
Ok(Some(config)) => Arc::new(config),
159+
Ok(None) => return None,
160+
Err(err) => {
161+
log::error!("Failed to load Didomi integration config: {err:?}");
162+
return None;
163+
}
164+
};
165+
Some(DidomiIntegration::new(config))
166+
}
167+
168+
/// Register the Didomi consent notice integration when enabled.
169+
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
170+
let integration = build(settings)?;
171+
Some(
172+
IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID)
173+
.with_proxy(integration)
174+
.build(),
175+
)
176+
}
177+
178+
#[async_trait(?Send)]
179+
impl IntegrationProxy for DidomiIntegration {
180+
fn routes(&self) -> Vec<IntegrationEndpoint> {
181+
vec![
182+
IntegrationEndpoint::get_prefix("/consent"),
183+
IntegrationEndpoint::post_prefix("/consent"),
184+
]
185+
}
186+
187+
async fn handle(
188+
&self,
189+
_settings: &Settings,
190+
req: Request,
191+
) -> Result<Response, Report<TrustedServerError>> {
192+
let path = req.get_path();
193+
let consent_path = path.strip_prefix("/consent").unwrap_or(path);
194+
let backend = self.backend_for_path(consent_path);
195+
let base_origin = match backend {
196+
DidomiBackend::Sdk => self.config.sdk_origin.as_str(),
197+
DidomiBackend::Api => self.config.api_origin.as_str(),
198+
};
199+
200+
let target_url = self
201+
.build_target_url(base_origin, consent_path, req.get_query_str())
202+
.change_context(Self::error("Failed to build Didomi target URL"))?;
203+
let backend_name = ensure_backend_from_url(base_origin)
204+
.change_context(Self::error("Failed to configure Didomi backend"))?;
205+
206+
let mut proxy_req = Request::new(req.get_method().clone(), &target_url);
207+
self.copy_headers(&backend, &req, &mut proxy_req);
208+
209+
if matches!(req.get_method(), &Method::POST | &Method::PUT) {
210+
if let Some(content_type) = req.get_header(header::CONTENT_TYPE) {
211+
proxy_req.set_header(header::CONTENT_TYPE, content_type);
212+
}
213+
proxy_req.set_body(req.into_body());
214+
}
215+
216+
let mut response = proxy_req
217+
.send(&backend_name)
218+
.change_context(Self::error("Didomi upstream request failed"))?;
219+
220+
if matches!(backend, DidomiBackend::Sdk) {
221+
Self::add_cors_headers(&mut response);
222+
}
223+
224+
Ok(response)
225+
}
226+
}
227+
228+
#[cfg(test)]
229+
mod tests {
230+
use super::*;
231+
use crate::integrations::IntegrationRegistry;
232+
use crate::test_support::tests::create_test_settings;
233+
use fastly::http::Method;
234+
235+
fn config(enabled: bool) -> DidomiIntegrationConfig {
236+
DidomiIntegrationConfig {
237+
enabled,
238+
sdk_origin: default_sdk_origin(),
239+
api_origin: default_api_origin(),
240+
}
241+
}
242+
243+
#[test]
244+
fn selects_api_backend_for_api_paths() {
245+
let integration = DidomiIntegration::new(Arc::new(config(true)));
246+
assert!(matches!(
247+
integration.backend_for_path("/api/events"),
248+
DidomiBackend::Api
249+
));
250+
assert!(matches!(
251+
integration.backend_for_path("/24cd/loader.js"),
252+
DidomiBackend::Sdk
253+
));
254+
}
255+
256+
#[test]
257+
fn builds_target_url_with_query() {
258+
let integration = DidomiIntegration::new(Arc::new(config(true)));
259+
let url = integration
260+
.build_target_url("https://sdk.privacy-center.org", "/loader.js", Some("v=1"))
261+
.unwrap();
262+
assert_eq!(url, "https://sdk.privacy-center.org/loader.js?v=1");
263+
}
264+
265+
#[test]
266+
fn registers_prefix_routes() {
267+
let mut settings = create_test_settings();
268+
settings
269+
.integrations
270+
.insert_config(DIDOMI_INTEGRATION_ID, &config(true))
271+
.expect("should insert config");
272+
273+
let registry = IntegrationRegistry::new(&settings);
274+
assert!(registry.has_route(&Method::GET, "/consent/loader.js"));
275+
assert!(registry.has_route(&Method::POST, "/consent/api/events"));
276+
assert!(!registry.has_route(&Method::GET, "/other"));
277+
}
278+
}

crates/common/src/integrations/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use crate::settings::Settings;
44

5+
pub mod didomi;
56
pub mod nextjs;
67
pub mod prebid;
78
mod registry;
@@ -11,11 +12,16 @@ pub use registry::{
1112
AttributeRewriteAction, AttributeRewriteOutcome, IntegrationAttributeContext,
1213
IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationMetadata, IntegrationProxy,
1314
IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry,
14-
IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction,
15+
IntegrationScriptContext, IntegrationScriptRewriter, RouteMatch, ScriptRewriteAction,
1516
};
1617

1718
type IntegrationBuilder = fn(&Settings) -> Option<IntegrationRegistration>;
1819

1920
pub(crate) fn builders() -> &'static [IntegrationBuilder] {
20-
&[prebid::register, testlight::register, nextjs::register]
21+
&[
22+
prebid::register,
23+
testlight::register,
24+
nextjs::register,
25+
didomi::register,
26+
]
2127
}

0 commit comments

Comments
 (0)