Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2481099
Proposal to standardize integrations
aram356 Nov 17, 2025
8da8a3c
Fixed formatting
aram356 Nov 17, 2025
ab9ca6a
Improviing integration so that we can move prebid and nextjs as integ…
aram356 Nov 18, 2025
f38a7a3
Use integration standards for Prebid
aram356 Nov 19, 2025
063f1ca
Fixed clippy
aram356 Nov 19, 2025
44fa1ba
Merge branch 'feature/standardize-integrations' into feature/refactor…
aram356 Nov 19, 2025
42b48a8
Improve routing
aram356 Nov 19, 2025
3c69966
Merge branch 'feature/standardize-integrations' into feature/refactor…
aram356 Nov 19, 2025
e19be32
Removed unused file
aram356 Nov 19, 2025
f1247dd
Unified JS bundling
ChristianPavilonis Nov 20, 2025
787c642
Fixed formatting
aram356 Nov 20, 2025
4b9aaab
Added a way to remove scripts
aram356 Nov 20, 2025
d1deffb
Merge branch 'main' into feature/refactor-prebid-integration
aram356 Nov 20, 2025
97d0d1c
Merge branch 'feature/standardize-integrations' into feature/refactor…
aram356 Nov 20, 2025
20ddc20
Refactor Next.js integration
aram356 Nov 20, 2025
0f3663d
Merge branch 'main' into feature/refactor-prebid-integration
aram356 Nov 21, 2025
c9f9ab6
Merge branch 'feature/refactor-prebid-integration' into feature/refac…
aram356 Nov 22, 2025
2d40b79
Merge branch 'feature/refactor-nextjs-integration' into feature/integ…
aram356 Nov 24, 2025
ef78d75
Allow integration to proxy requests
aram356 Nov 24, 2025
012fb4c
Merge branch 'main' into feature/integration-proxy
aram356 Nov 30, 2025
5f78374
Add Didomi integration
aram356 Dec 1, 2025
4efe60b
Merge branch 'main' into feature/didomi-integration
aram356 Dec 3, 2025
4c2a864
Override config for Didomi integration
aram356 Dec 3, 2025
1f80c4e
Reuse backends correctly
aram356 Dec 3, 2025
1147156
Set additional parameters for SSL backends
aram356 Dec 3, 2025
74547e2
Merge branch 'main' into feature/didomi-integration
aram356 Dec 17, 2025
d711e13
Fixed typescript integration routes for Didomi integration.
aram356 Dec 17, 2025
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
32 changes: 23 additions & 9 deletions crates/common/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ pub fn ensure_origin_backend(
}));
}

let host_with_port = match port {
Some(p) => format!("{}:{}", host, p),
None => host.to_string(),
let is_https = scheme.eq_ignore_ascii_case("https");
let target_port = match (port, is_https) {
(Some(p), _) => p,
(None, true) => 443,
(None, false) => 80,
};

// Name: iframe_<scheme>_<host[_port]> (sanitize '.' and ':')
let mut name_base = format!("{}_{}", scheme, host_with_port);
name_base = name_base.replace(['.', ':'], "_");
let backend_name = format!("backend_{}", name_base);
let host_with_port = format!("{}:{}", host, target_port);

// Name: iframe_<scheme>_<host>_<port> (sanitize '.' and ':')
let name_base = format!("{}_{}_{}", scheme, host, target_port);
let backend_name = format!("backend_{}", name_base.replace(['.', ':'], "_"));

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

match builder.finish() {
Expand Down Expand Up @@ -67,6 +74,7 @@ pub fn ensure_origin_backend(
}
}
}

pub fn ensure_backend_from_url(origin_url: &str) -> Result<String, Report<TrustedServerError>> {
let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy {
message: format!("Invalid origin_url: {}", origin_url),
Expand All @@ -90,7 +98,7 @@ mod tests {
#[test]
fn returns_name_for_https_no_port() {
let name = ensure_origin_backend("https", "origin.example.com", None).unwrap();
assert_eq!(name, "backend_https_origin_example_com");
assert_eq!(name, "backend_https_origin_example_com_443");
}

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

#[test]
fn returns_name_for_http_without_port_defaults_to_80() {
let name = ensure_origin_backend("http", "example.org", None).unwrap();
assert_eq!(name, "backend_http_example_org_80");
}

#[test]
fn error_on_missing_host() {
let err = ensure_origin_backend("https", "", None).err().unwrap();
Expand Down
280 changes: 280 additions & 0 deletions crates/common/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
use std::sync::Arc;

use async_trait::async_trait;
use error_stack::{Report, ResultExt};
use fastly::http::{header, Method};
use fastly::{Request, Response};
use serde::{Deserialize, Serialize};
use url::Url;
use validator::Validate;

use crate::backend::ensure_backend_from_url;
use crate::error::TrustedServerError;
use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration};
use crate::settings::{IntegrationConfig, Settings};

const DIDOMI_INTEGRATION_ID: &str = "didomi";
const DIDOMI_PREFIX: &str = "/integrations/didomi/consent";

/// Configuration for the Didomi consent notice reverse proxy.
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
pub struct DidomiIntegrationConfig {
/// Whether the integration is enabled.
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Base URL for the Didomi SDK origin.
#[serde(default = "default_sdk_origin")]
#[validate(url)]
pub sdk_origin: String,
/// Base URL for the Didomi API origin.
#[serde(default = "default_api_origin")]
#[validate(url)]
pub api_origin: String,
}

impl IntegrationConfig for DidomiIntegrationConfig {
fn is_enabled(&self) -> bool {
self.enabled
}
}

fn default_enabled() -> bool {
true
}

fn default_sdk_origin() -> String {
"https://sdk.privacy-center.org".to_string()
}

fn default_api_origin() -> String {
"https://api.privacy-center.org".to_string()
}

enum DidomiBackend {
Sdk,
Api,
}

struct DidomiIntegration {
config: Arc<DidomiIntegrationConfig>,
}

impl DidomiIntegration {
fn new(config: Arc<DidomiIntegrationConfig>) -> Arc<Self> {
Arc::new(Self { config })
}

fn error(message: impl Into<String>) -> TrustedServerError {
TrustedServerError::Integration {
integration: DIDOMI_INTEGRATION_ID.to_string(),
message: message.into(),
}
}

fn backend_for_path(&self, consent_path: &str) -> DidomiBackend {
if consent_path.starts_with("/api/") {
DidomiBackend::Api
} else {
DidomiBackend::Sdk
}
}

fn build_target_url(
&self,
base: &str,
consent_path: &str,
query: Option<&str>,
) -> Result<String, Report<TrustedServerError>> {
let mut target =
Url::parse(base).change_context(Self::error("Invalid Didomi origin URL"))?;
let path = if consent_path.is_empty() {
"/"
} else {
consent_path
};
target.set_path(path);
target.set_query(query);
Ok(target.to_string())
}

fn copy_headers(
&self,
backend: &DidomiBackend,
original_req: &Request,
proxy_req: &mut Request,
) {
if let Some(client_ip) = original_req.get_client_ip_addr() {
proxy_req.set_header("X-Forwarded-For", client_ip.to_string());
}

for header_name in [
header::ACCEPT,
header::ACCEPT_LANGUAGE,
header::ACCEPT_ENCODING,
header::USER_AGENT,
header::REFERER,
header::ORIGIN,
header::AUTHORIZATION,
] {
if let Some(value) = original_req.get_header(&header_name) {
proxy_req.set_header(&header_name, value);
}
}

if matches!(backend, DidomiBackend::Sdk) {
Self::copy_geo_headers(original_req, proxy_req);
}
}

fn copy_geo_headers(original_req: &Request, proxy_req: &mut Request) {
let geo_headers = [
("X-Geo-Country", "FastlyGeo-CountryCode"),
("X-Geo-Region", "FastlyGeo-Region"),
("CloudFront-Viewer-Country", "FastlyGeo-CountryCode"),
];

for (target, source) in geo_headers {
if let Some(value) = original_req.get_header(source) {
proxy_req.set_header(target, value);
}
}
}

fn add_cors_headers(response: &mut Response) {
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",
);
}
}

fn build(settings: &Settings) -> Option<Arc<DidomiIntegration>> {
let config = match settings.integration_config::<DidomiIntegrationConfig>(DIDOMI_INTEGRATION_ID)
{
Ok(Some(config)) => Arc::new(config),
Ok(None) => return None,
Err(err) => {
log::error!("Failed to load Didomi integration config: {err:?}");
return None;
}
};
Some(DidomiIntegration::new(config))
}

/// Register the Didomi consent notice integration when enabled.
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
let integration = build(settings)?;
Some(
IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID)
.with_proxy(integration)
.build(),
)
}

#[async_trait(?Send)]
impl IntegrationProxy for DidomiIntegration {
fn integration_name(&self) -> &'static str {
DIDOMI_INTEGRATION_ID
}

fn routes(&self) -> Vec<IntegrationEndpoint> {
vec![self.get("/consent/*"), self.post("/consent/*")]
}

async fn handle(
&self,
_settings: &Settings,
req: Request,
) -> Result<Response, Report<TrustedServerError>> {
let path = req.get_path();
let consent_path = path.strip_prefix(DIDOMI_PREFIX).unwrap_or(path);
let backend = self.backend_for_path(consent_path);
let base_origin = match backend {
DidomiBackend::Sdk => self.config.sdk_origin.as_str(),
DidomiBackend::Api => self.config.api_origin.as_str(),
};

let target_url = self
.build_target_url(base_origin, consent_path, req.get_query_str())
.change_context(Self::error("Failed to build Didomi target URL"))?;
let backend_name = ensure_backend_from_url(base_origin)
.change_context(Self::error("Failed to configure Didomi backend"))?;

let mut proxy_req = Request::new(req.get_method().clone(), &target_url);
self.copy_headers(&backend, &req, &mut proxy_req);

if matches!(req.get_method(), &Method::POST | &Method::PUT) {
if let Some(content_type) = req.get_header(header::CONTENT_TYPE) {
proxy_req.set_header(header::CONTENT_TYPE, content_type);
}
proxy_req.set_body(req.into_body());
}

let mut response = proxy_req
.send(&backend_name)
.change_context(Self::error("Didomi upstream request failed"))?;

if matches!(backend, DidomiBackend::Sdk) {
Self::add_cors_headers(&mut response);
}

Ok(response)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::integrations::IntegrationRegistry;
use crate::test_support::tests::create_test_settings;
use fastly::http::Method;

fn config(enabled: bool) -> DidomiIntegrationConfig {
DidomiIntegrationConfig {
enabled,
sdk_origin: default_sdk_origin(),
api_origin: default_api_origin(),
}
}

#[test]
fn selects_api_backend_for_api_paths() {
let integration = DidomiIntegration::new(Arc::new(config(true)));
assert!(matches!(
integration.backend_for_path("/api/events"),
DidomiBackend::Api
));
assert!(matches!(
integration.backend_for_path("/24cd/loader.js"),
DidomiBackend::Sdk
));
}

#[test]
fn builds_target_url_with_query() {
let integration = DidomiIntegration::new(Arc::new(config(true)));
let url = integration
.build_target_url("https://sdk.privacy-center.org", "/loader.js", Some("v=1"))
.unwrap();
assert_eq!(url, "https://sdk.privacy-center.org/loader.js?v=1");
}

#[test]
fn registers_prefix_routes() {
let mut settings = create_test_settings();
settings
.integrations
.insert_config(DIDOMI_INTEGRATION_ID, &config(true))
.expect("should insert config");

let registry = IntegrationRegistry::new(&settings);
assert!(registry.has_route(&Method::GET, "/integrations/didomi/consent/loader.js"));
assert!(registry.has_route(&Method::POST, "/integrations/didomi/consent/api/events"));
assert!(!registry.has_route(&Method::GET, "/other"));
}
}
2 changes: 2 additions & 0 deletions crates/common/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use crate::settings::Settings;

pub mod didomi;
pub mod lockr;
pub mod nextjs;
pub mod permutive;
Expand All @@ -25,5 +26,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] {
nextjs::register,
permutive::register,
lockr::register,
didomi::register,
]
}
Loading