Skip to content

Commit 614c9bc

Browse files
authored
Add didomi integration (#131)
1 parent 68eb929 commit 614c9bc

File tree

7 files changed

+409
-9
lines changed

7 files changed

+409
-9
lines changed

crates/common/src/backend.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,18 @@ pub fn ensure_origin_backend(
2222
}));
2323
}
2424

25-
let host_with_port = match port {
26-
Some(p) => format!("{}:{}", host, p),
27-
None => host.to_string(),
25+
let is_https = scheme.eq_ignore_ascii_case("https");
26+
let target_port = match (port, is_https) {
27+
(Some(p), _) => p,
28+
(None, true) => 443,
29+
(None, false) => 80,
2830
};
2931

30-
// Name: iframe_<scheme>_<host[_port]> (sanitize '.' and ':')
31-
let mut name_base = format!("{}_{}", scheme, host_with_port);
32-
name_base = name_base.replace(['.', ':'], "_");
33-
let backend_name = format!("backend_{}", name_base);
32+
let host_with_port = format!("{}:{}", host, target_port);
33+
34+
// Name: iframe_<scheme>_<host>_<port> (sanitize '.' and ':')
35+
let name_base = format!("{}_{}_{}", scheme, host, target_port);
36+
let backend_name = format!("backend_{}", name_base.replace(['.', ':'], "_"));
3437

3538
// Target base is host[:port]; SSL is enabled only for https scheme
3639
let mut builder = Backend::builder(&backend_name, &host_with_port)
@@ -39,7 +42,11 @@ pub fn ensure_origin_backend(
3942
.first_byte_timeout(Duration::from_secs(15))
4043
.between_bytes_timeout(Duration::from_secs(10));
4144
if scheme.eq_ignore_ascii_case("https") {
42-
builder = builder.enable_ssl().sni_hostname(host);
45+
builder = builder
46+
.enable_ssl()
47+
.sni_hostname(host)
48+
.check_certificate(host);
49+
log::info!("enable ssl for backend: {}", backend_name);
4350
}
4451

4552
match builder.finish() {
@@ -67,6 +74,7 @@ pub fn ensure_origin_backend(
6774
}
6875
}
6976
}
77+
7078
pub fn ensure_backend_from_url(origin_url: &str) -> Result<String, Report<TrustedServerError>> {
7179
let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy {
7280
message: format!("Invalid origin_url: {}", origin_url),
@@ -90,7 +98,7 @@ mod tests {
9098
#[test]
9199
fn returns_name_for_https_no_port() {
92100
let name = ensure_origin_backend("https", "origin.example.com", None).unwrap();
93-
assert_eq!(name, "backend_https_origin_example_com");
101+
assert_eq!(name, "backend_https_origin_example_com_443");
94102
}
95103

96104
#[test]
@@ -101,6 +109,12 @@ mod tests {
101109
assert!(name.ends_with("_8080"));
102110
}
103111

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

crates/common/src/integrations/mod.rs

Lines changed: 2 additions & 0 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 lockr;
67
pub mod nextjs;
78
pub mod permutive;
@@ -25,5 +26,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
2526
nextjs::register,
2627
permutive::register,
2728
lockr::register,
29+
didomi::register,
2830
]
2931
}

0 commit comments

Comments
 (0)