Skip to main content

px_native/infrastructure/
sensor_solver.rs

1//! Native `NativeSolver` implementation that builds a sensor payload
2//! locally and POSTs it to `${origin}/${app_id_tag}${sensor_path}`,
3//! then parses the `Set-Cookie` response into a [`PxCookieBundle`].
4
5use 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
23/// HTTP-backed native solver bound to a single tenant profile.
24pub 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    /// Build the wire-format payload for the given context.
45    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}