px_native/infrastructure/
sensor_solver.rs1use std::sync::Arc;
6use std::time::{Duration, SystemTime};
7
8use async_trait::async_trait;
9use px_core::{NamedCookie, PxCookieBundle};
10use px_errors::AppError;
11use reqwest::Client;
12use reqwest::header::{
13 ACCEPT, ACCEPT_LANGUAGE, CONTENT_TYPE, HeaderValue, ORIGIN, REFERER, SET_COOKIE, USER_AGENT,
14};
15use url::Url;
16
17use crate::cipher::encrypt_sensor;
18use crate::domain::native_solver::{NativeSolver, SolveContext};
19use crate::events::{SyntheticIdentity, default_batch};
20use crate::infrastructure::cookies::parse_set_cookies;
21use crate::profile::TenantProfile;
22
23pub struct SensorNativeSolver {
25 client: Client,
26 profile: Arc<TenantProfile>,
27 cookie_ttl: Duration,
28}
29
30impl SensorNativeSolver {
31 pub fn new(client: Client, profile: Arc<TenantProfile>) -> Self {
32 Self {
33 client,
34 profile,
35 cookie_ttl: Duration::from_secs(300),
36 }
37 }
38
39 pub fn with_cookie_ttl(mut self, ttl: Duration) -> Self {
40 self.cookie_ttl = ttl;
41 self
42 }
43
44 pub fn build_payload(&self, ctx: &SolveContext) -> Result<Vec<u8>, AppError> {
46 let identity = identity_from_fingerprint(ctx);
47 let now_ms = epoch_ms_now();
48 let batch = default_batch(&identity, now_ms);
49 let json = serde_json::to_vec(&batch)
50 .map_err(|e| AppError::InternalError(format!("serialize sensor batch: {e}")))?;
51 let pf = if ctx.url.is_empty() {
52 self.profile.pf_fallback.as_bytes().to_vec()
53 } else {
54 ctx.url.as_bytes().to_vec()
55 };
56 let cu = uuid::Uuid::now_v1(&[0u8; 6]).to_string();
57 encrypt_sensor(&json, &pf, cu.as_bytes())
58 }
59}
60
61#[async_trait]
62impl NativeSolver for SensorNativeSolver {
63 async fn solve(&self, ctx: &SolveContext) -> Result<PxCookieBundle, AppError> {
64 let url =
65 Url::parse(&ctx.url).map_err(|e| AppError::BadRequest(format!("invalid url: {e}")))?;
66 let origin = format!(
67 "{}://{}",
68 url.scheme(),
69 url.host_str()
70 .ok_or_else(|| AppError::BadRequest("url has no host".into()))?,
71 );
72 let sensor_url = self.profile.sensor_url(&origin);
73 let payload = self.build_payload(ctx)?;
74
75 let resp = self
76 .client
77 .post(&sensor_url)
78 .header(USER_AGENT, header(&ctx.fingerprint.user_agent)?)
79 .header(ACCEPT, HeaderValue::from_static("*/*"))
80 .header(
81 ACCEPT_LANGUAGE,
82 header(&ctx.fingerprint.accept_language.join(","))?,
83 )
84 .header(
85 CONTENT_TYPE,
86 HeaderValue::from_static("application/x-www-form-urlencoded"),
87 )
88 .header(ORIGIN, header(&origin)?)
89 .header(REFERER, header(&ctx.url)?)
90 .body(payload)
91 .send()
92 .await
93 .map_err(|e| AppError::InternalError(format!("sensor POST: {e}")))?;
94
95 if !resp.status().is_success() {
96 return Err(AppError::InternalError(format!(
97 "sensor POST returned status {}",
98 resp.status()
99 )));
100 }
101
102 let set_cookie_values: Vec<&HeaderValue> =
103 resp.headers().get_all(SET_COOKIE).iter().collect();
104 let cookies: Vec<NamedCookie> =
105 parse_set_cookies(&set_cookie_values, url.host_str().unwrap_or(""));
106 if cookies.is_empty() {
107 return Err(AppError::InternalError(
108 "sensor POST returned no Set-Cookie headers".into(),
109 ));
110 }
111 tracing::info!(target: "px_native", url = %sensor_url, count = cookies.len(), "native sensor solved");
112 Ok(PxCookieBundle::new(
113 cookies,
114 ctx.fingerprint.user_agent.clone(),
115 SystemTime::now(),
116 self.cookie_ttl,
117 ))
118 }
119}
120
121fn header(value: &str) -> Result<HeaderValue, AppError> {
122 HeaderValue::from_str(value).map_err(|e| AppError::BadRequest(format!("bad header value: {e}")))
123}
124
125fn identity_from_fingerprint(ctx: &SolveContext) -> SyntheticIdentity {
126 SyntheticIdentity {
127 user_agent: ctx.fingerprint.user_agent.clone(),
128 locale: ctx
129 .fingerprint
130 .accept_language
131 .first()
132 .cloned()
133 .unwrap_or_else(|| "en-US".into()),
134 timezone: ctx.fingerprint.timezone.clone(),
135 viewport: (ctx.fingerprint.screen_width, ctx.fingerprint.screen_height),
136 ga_client_id: format!("GA1.1.{}", ctx.fingerprint.key_hash()),
137 session_count: 1,
138 first_visit_days_ago: 7,
139 }
140}
141
142fn epoch_ms_now() -> u64 {
143 SystemTime::now()
144 .duration_since(SystemTime::UNIX_EPOCH)
145 .map(|d| d.as_millis() as u64)
146 .unwrap_or(0)
147}