Skip to content

Commit fed073e

Browse files
committed
Expose edge geo data and modularize injection
Third-party geo lookups add avoidable latency and introduce privacy and compliance risk. This change exposes edge-derived location data via a first-party endpoint, enabling the trusted server to supply targeting information without relying on browser-side APIs. Decouples geo handling from dependencies to improve reliability and modularity. Refactors Prebid injection to utilize the internal JS library, ensuring consistent initialization and reducing client-side complexity.
1 parent a9569b6 commit fed073e

File tree

16 files changed

+700
-184
lines changed

16 files changed

+700
-184
lines changed

crates/common/src/geo.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
//! information from incoming requests, particularly DMA (Designated Market Area) codes.
55
66
use fastly::geo::geo_lookup;
7-
use fastly::Request;
87

98
use crate::constants::{
109
HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY,
@@ -33,6 +32,14 @@ pub struct GeoInfo {
3332
pub region: Option<String>,
3433
}
3534

35+
use core::result::Result;
36+
use error_stack::Report;
37+
use fastly::http::StatusCode;
38+
use fastly::{Request, Response};
39+
40+
use crate::error::TrustedServerError;
41+
use crate::settings::Settings;
42+
3643
impl GeoInfo {
3744
/// Creates a new `GeoInfo` from a request by performing a geo lookup.
3845
///
@@ -150,3 +157,20 @@ pub fn get_dma_code(req: &mut Request) -> Option<String> {
150157

151158
None
152159
}
160+
161+
/// Handles requests to `/first-party/geo` and returns the geo information as JSON.
162+
pub fn handle_first_party_geo(
163+
_settings: &Settings,
164+
req: Request,
165+
) -> Result<Response, Report<TrustedServerError>> {
166+
let geo_info = GeoInfo::from_request(&req);
167+
168+
Response::from_status(StatusCode::OK)
169+
.with_body_json(&geo_info)
170+
.map_err(|_| {
171+
Report::new(TrustedServerError::Integration {
172+
integration: "geo".to_string(),
173+
message: "Failed to serialize response".to_string(),
174+
})
175+
})
176+
}

crates/common/src/html_processor.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::sync::Arc;
88

99
use lol_html::{element, html_content::ContentType, text, Settings as RewriterSettings};
1010

11+
use crate::geo::GeoInfo;
1112
use crate::integrations::{
1213
AttributeRewriteOutcome, IntegrationAttributeContext, IntegrationDocumentState,
1314
IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationRegistry,
@@ -24,6 +25,7 @@ struct HtmlWithPostProcessing {
2425
request_host: String,
2526
request_scheme: String,
2627
document_state: IntegrationDocumentState,
28+
geo: Option<GeoInfo>,
2729
}
2830

2931
impl StreamProcessor for HtmlWithPostProcessing {
@@ -42,6 +44,7 @@ impl StreamProcessor for HtmlWithPostProcessing {
4244
request_scheme: &self.request_scheme,
4345
origin_host: &self.origin_host,
4446
document_state: &self.document_state,
47+
geo: self.geo.as_ref(),
4548
};
4649

4750
// Preflight to avoid allocating a `String` unless at least one post-processor wants to run.
@@ -90,6 +93,7 @@ pub struct HtmlProcessorConfig {
9093
pub request_host: String,
9194
pub request_scheme: String,
9295
pub integrations: IntegrationRegistry,
96+
pub geo: Option<GeoInfo>,
9397
}
9498

9599
impl HtmlProcessorConfig {
@@ -107,6 +111,7 @@ impl HtmlProcessorConfig {
107111
request_host: request_host.to_string(),
108112
request_scheme: request_scheme.to_string(),
109113
integrations: integrations.clone(),
114+
geo: None,
110115
}
111116
}
112117
}
@@ -194,6 +199,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
194199
let integrations = integration_registry.clone();
195200
let patterns = patterns.clone();
196201
let document_state = document_state.clone();
202+
let geo = config.geo.clone();
197203
move |el| {
198204
if !injected_tsjs.get() {
199205
let mut snippet = String::new();
@@ -202,6 +208,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
202208
request_scheme: &patterns.request_scheme,
203209
origin_host: &patterns.origin_host,
204210
document_state: &document_state,
211+
geo: geo.as_ref(),
205212
};
206213
// First inject the TSJS bundle (defines tsjs.setConfig, etc.)
207214
let module_ids = integrations.js_module_ids();
@@ -467,6 +474,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
467474
request_host: config.request_host,
468475
request_scheme: config.request_scheme,
469476
document_state,
477+
geo: config.geo,
470478
}
471479
}
472480

@@ -489,6 +497,7 @@ mod tests {
489497
request_host: "test.example.com".to_string(),
490498
request_scheme: "https".to_string(),
491499
integrations: IntegrationRegistry::default(),
500+
geo: None,
492501
}
493502
}
494503

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
use std::sync::Arc;
2+
3+
use async_trait::async_trait;
4+
use error_stack::Report;
5+
6+
use crate::error::TrustedServerError;
7+
use crate::integrations::{
8+
IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy,
9+
IntegrationRegistration,
10+
};
11+
use crate::settings::{GeoConfig, Settings};
12+
13+
const GEO_INTEGRATION_ID: &str = "geo";
14+
15+
pub struct GeoIntegration {
16+
config: GeoConfig,
17+
}
18+
19+
impl GeoIntegration {
20+
pub fn new(config: GeoConfig) -> Self {
21+
Self { config }
22+
}
23+
}
24+
25+
pub fn build(settings: &Settings) -> Option<Arc<GeoIntegration>> {
26+
if !settings.geo.inject {
27+
return None;
28+
}
29+
Some(Arc::new(GeoIntegration::new(settings.geo.clone())))
30+
}
31+
32+
#[must_use]
33+
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
34+
let integration = build(settings)?;
35+
Some(
36+
IntegrationRegistration::builder(GEO_INTEGRATION_ID)
37+
.with_head_injector(integration)
38+
.build(),
39+
)
40+
}
41+
42+
#[async_trait(?Send)]
43+
impl IntegrationProxy for GeoIntegration {
44+
fn integration_name(&self) -> &'static str {
45+
GEO_INTEGRATION_ID
46+
}
47+
48+
fn routes(&self) -> Vec<IntegrationEndpoint> {
49+
vec![]
50+
}
51+
52+
async fn handle(
53+
&self,
54+
_settings: &Settings,
55+
_req: fastly::Request,
56+
) -> Result<fastly::Response, Report<TrustedServerError>> {
57+
// This integration doesn't handle any routes, but we must implement the trait method.
58+
// Since we return empty routes(), this should arguably never be called.
59+
// However, if it is, returning a 404 or logging an error is appropriate.
60+
// For now, let's return a generic 404.
61+
Ok(fastly::Response::from_status(
62+
fastly::http::StatusCode::NOT_FOUND,
63+
))
64+
}
65+
}
66+
67+
#[async_trait(?Send)]
68+
impl IntegrationHeadInjector for GeoIntegration {
69+
fn integration_id(&self) -> &'static str {
70+
GEO_INTEGRATION_ID
71+
}
72+
73+
fn head_inserts(&self, context: &IntegrationHtmlContext) -> Vec<String> {
74+
let mut inserts = Vec::new();
75+
76+
if let Some(geo) = &context.geo {
77+
let geo_json = serde_json::to_string(geo).unwrap_or_else(|_| "null".to_string());
78+
let cache_key = &self.config.cache_key;
79+
80+
// Script to inject geo info into localStorage for client-side access
81+
let script = format!(
82+
r#"<script>
83+
(function() {{
84+
try {{
85+
var geo = {geo_json};
86+
if (geo) {{
87+
window._tudeGeo = geo;
88+
localStorage.setItem('{cache_key}', JSON.stringify(geo));
89+
}}
90+
}} catch (e) {{
91+
console.error('Error injecting geo info:', e);
92+
}}
93+
}})();
94+
</script>"#
95+
);
96+
inserts.push(script);
97+
}
98+
99+
inserts
100+
}
101+
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use super::*;
106+
use crate::geo::GeoInfo;
107+
use crate::integrations::IntegrationHtmlContext;
108+
109+
#[test]
110+
fn test_geo_injection() {
111+
let config = GeoConfig {
112+
inject: true,
113+
cache_key: "test-cache-key".to_string(),
114+
};
115+
let integration = GeoIntegration::new(config);
116+
117+
// Test with GeoInfo present
118+
let geo_info = GeoInfo {
119+
country: "US".to_string(),
120+
region: Some("NY".to_string()),
121+
city: "New York".to_string(),
122+
continent: "North America".to_string(),
123+
latitude: 40.71,
124+
longitude: -74.00,
125+
metro_code: 501,
126+
};
127+
128+
let context = IntegrationHtmlContext {
129+
request_host: "example.com",
130+
request_scheme: "https",
131+
origin_host: "origin.example.com",
132+
document_state: &Default::default(),
133+
geo: Some(&geo_info),
134+
};
135+
136+
let inserts = integration.head_inserts(&context);
137+
assert_eq!(inserts.len(), 1);
138+
assert!(inserts[0].contains("window._tudeGeo ="));
139+
assert!(
140+
inserts[0].contains(r#"localStorage.setItem('test-cache-key', JSON.stringify(geo))"#)
141+
);
142+
assert!(inserts[0].contains(r#""country":"US""#));
143+
144+
// Test with GeoInfo missing
145+
let context_no_geo = IntegrationHtmlContext {
146+
request_host: "example.com",
147+
request_scheme: "https",
148+
origin_host: "origin.example.com",
149+
document_state: &Default::default(),
150+
geo: None,
151+
};
152+
153+
let inserts_no_geo = integration.head_inserts(&context_no_geo);
154+
assert!(inserts_no_geo.is_empty());
155+
}
156+
}

crates/common/src/integrations/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod adserver_mock;
66
pub mod aps;
77
pub mod datadome;
88
pub mod didomi;
9+
pub mod geo;
910
pub mod lockr;
1011
pub mod nextjs;
1112
pub mod permutive;
@@ -25,6 +26,7 @@ type IntegrationBuilder = fn(&Settings) -> Option<IntegrationRegistration>;
2526

2627
pub(crate) fn builders() -> &'static [IntegrationBuilder] {
2728
&[
29+
geo::register,
2830
prebid::register,
2931
testlight::register,
3032
nextjs::register,

0 commit comments

Comments
 (0)