|
| 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 | +} |
0 commit comments