Skip to main content

crispy_stalker/
session.rs

1//! Stalker session state — token, cookie, and device identity management.
2//!
3//! Expanded with token refresh logic from:
4//! - Python: `ensure_token`, `token_validity_period`
5//! - TypeScript: `ensureToken`, `STALKER_TOKEN_VALIDITY_SECONDS`, token refresh locking
6
7use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
8use std::time::{Duration, Instant};
9use tokio::sync::Mutex;
10
11use crate::device;
12
13/// Default token validity period in seconds (from TypeScript constants).
14const DEFAULT_TOKEN_VALIDITY_SECS: u64 = 3600;
15
16/// Default timezone for Stalker cookie header.
17const DEFAULT_TIMEZONE: &str = "Europe/Paris";
18
19/// Characters to percent-encode in the MAC cookie value.
20/// Encode everything except unreserved characters per RFC 3986.
21const MAC_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
22    .remove(b'-')
23    .remove(b'_')
24    .remove(b'.')
25    .remove(b'~');
26
27/// Active session state after successful authentication.
28///
29/// Extended with device identity and token expiry tracking.
30#[derive(Debug, Clone)]
31pub struct StalkerSession {
32    /// Bearer token obtained from handshake.
33    pub(crate) token: String,
34
35    /// Discovered portal URL (e.g. `http://host/stalker_portal/server/load.php`).
36    pub(crate) portal_url: String,
37
38    /// MAC address (original format, e.g. `00:1A:79:XX:XX:XX`).
39    pub(crate) mac_address: String,
40
41    /// Timestamp when the token was obtained.
42    pub(crate) token_obtained_at: Instant,
43
44    /// Token validity period.
45    pub(crate) token_validity: Duration,
46
47    /// Generated serial (MD5 of MAC, 13 chars).
48    pub(crate) serial: String,
49
50    /// Generated device ID (SHA-256 of MAC, 64 chars).
51    pub(crate) device_id: String,
52
53    /// Second device ID (same as device_id per both sources).
54    pub(crate) device_id2: String,
55
56    /// Random hex string for metrics.
57    pub(crate) random: String,
58
59    /// Timezone for cookie header (e.g. `Europe/Paris`).
60    pub(crate) timezone: String,
61}
62
63impl StalkerSession {
64    /// Create a new session with device identity derived from MAC.
65    ///
66    /// `timezone` defaults to `"Europe/Paris"` when `None`.
67    pub fn new(
68        token: String,
69        portal_url: String,
70        mac_address: String,
71        token_validity_secs: Option<u64>,
72        timezone: Option<&str>,
73    ) -> Self {
74        let serial = device::generate_serial(&mac_address);
75        let device_id = device::generate_device_id(&mac_address);
76        let random = device::generate_random_hex();
77
78        Self {
79            token,
80            portal_url,
81            mac_address,
82            token_obtained_at: Instant::now(),
83            token_validity: Duration::from_secs(
84                token_validity_secs.unwrap_or(DEFAULT_TOKEN_VALIDITY_SECS),
85            ),
86            device_id2: device_id.clone(),
87            serial,
88            device_id,
89            random,
90            timezone: timezone
91                .filter(|s| !s.is_empty())
92                .unwrap_or(DEFAULT_TIMEZONE)
93                .to_string(),
94        }
95    }
96
97    /// Build the `Cookie` header value for Stalker requests.
98    ///
99    /// Format: `mac={percent_encoded_mac}; stb_lang=en; timezone={encoded_tz}`
100    pub fn cookie_header(&self) -> String {
101        let encoded_mac = utf8_percent_encode(&self.mac_address, MAC_ENCODE_SET).to_string();
102        let encoded_tz = utf8_percent_encode(&self.timezone, MAC_ENCODE_SET).to_string();
103        format!("mac={encoded_mac}; stb_lang=en; timezone={encoded_tz}")
104    }
105
106    /// Build the `Cookie` header with token included.
107    ///
108    /// Used for most requests (except `get_profile` on `stalker_portal` endpoints).
109    pub fn cookie_header_with_token(&self) -> String {
110        let encoded_mac = utf8_percent_encode(&self.mac_address, MAC_ENCODE_SET).to_string();
111        let encoded_tz = utf8_percent_encode(&self.timezone, MAC_ENCODE_SET).to_string();
112        format!(
113            "mac={encoded_mac}; stb_lang=en; timezone={encoded_tz}; token={}",
114            self.token
115        )
116    }
117
118    /// Build the `Authorization` header value.
119    pub fn auth_header(&self) -> String {
120        format!("Bearer {}", self.token)
121    }
122
123    /// Check whether the token has expired.
124    ///
125    /// Python: `(current_time - self.token_timestamp) > self.token_validity_period`
126    /// TypeScript: `(currentTimestamp - this.tokenTimestamp) > STALKER_TOKEN_VALIDITY_SECONDS`
127    pub fn is_token_expired(&self) -> bool {
128        self.token_obtained_at.elapsed() > self.token_validity
129    }
130
131    /// Update the token after a refresh (handshake + profile).
132    pub fn refresh_token(&mut self, new_token: String) {
133        self.token = new_token;
134        self.token_obtained_at = Instant::now();
135    }
136
137    /// Generate the signature for profile requests.
138    pub fn signature(&self) -> String {
139        device::generate_signature(
140            &self.mac_address,
141            &self.serial,
142            &self.device_id,
143            &self.device_id2,
144        )
145    }
146
147    /// Generate metrics JSON for profile requests.
148    pub fn metrics(&self) -> String {
149        device::generate_metrics(&self.mac_address, &self.serial, &self.random)
150    }
151
152    /// Generate `hw_version_2` (SHA-1 of MAC).
153    pub fn hw_version_2(&self) -> String {
154        device::generate_hw_version_2(&self.mac_address)
155    }
156
157    /// Derive the device ID from a MAC address (legacy: colon-stripped uppercase).
158    ///
159    /// `00:1A:79:XX:XX:XX` -> `001A79XXXXXX`
160    pub fn mac_to_device_id(mac: &str) -> String {
161        mac.replace(':', "").to_uppercase()
162    }
163
164    /// Full Stalker header set as used by both Python and TypeScript sources.
165    ///
166    /// Python: `generate_headers()` — includes `X-User-Agent`, `Referer`, etc.
167    /// TypeScript: `getHeaders()` — same header set.
168    pub fn full_headers(&self, include_token_in_cookie: bool) -> Vec<(String, String)> {
169        let mut headers = vec![
170            ("Accept".into(), "*/*".into()),
171            (
172                "User-Agent".into(),
173                "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3".into(),
174            ),
175            (
176                "X-User-Agent".into(),
177                "Model: MAG250; Link: WiFi".into(),
178            ),
179            (
180                "Referer".into(),
181                format!("{}/stalker_portal/c/index.html", self.portal_url.trim_end_matches("/stalker_portal/server/load.php").trim_end_matches("/portal.php").trim_end_matches("/c/")),
182            ),
183            ("Accept-Language".into(), "en-US,en;q=0.5".into()),
184            ("Pragma".into(), "no-cache".into()),
185            ("Connection".into(), "keep-alive".into()),
186            ("Accept-Encoding".into(), "gzip, deflate".into()),
187            ("Authorization".into(), self.auth_header()),
188        ];
189
190        let cookie = if include_token_in_cookie {
191            self.cookie_header_with_token()
192        } else {
193            self.cookie_header()
194        };
195        headers.push(("Cookie".into(), cookie));
196
197        headers
198    }
199}
200
201/// Token refresh lock — prevents concurrent token refreshes.
202///
203/// Translated from TypeScript: `tokenRefreshPromise: Promise<void> | null`
204/// converted to `tokio::Mutex`-based locking.
205pub struct TokenRefreshLock {
206    inner: Mutex<()>,
207}
208
209impl TokenRefreshLock {
210    pub fn new() -> Self {
211        Self {
212            inner: Mutex::new(()),
213        }
214    }
215
216    /// Acquire the lock. Only one refresh can proceed at a time.
217    pub async fn lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
218        self.inner.lock().await
219    }
220}
221
222impl Default for TokenRefreshLock {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn test_session() -> StalkerSession {
233        StalkerSession::new(
234            "abc123".into(),
235            "http://example.com/stalker_portal/server/load.php".into(),
236            "00:1A:79:AB:CD:EF".into(),
237            Some(3600),
238            None,
239        )
240    }
241
242    #[test]
243    fn cookie_header_encodes_mac() {
244        let session = test_session();
245        let cookie = session.cookie_header();
246        assert!(cookie.starts_with("mac=00%3A1A%3A79%3AAB%3ACD%3AEF"));
247        assert!(cookie.contains("stb_lang=en"));
248        assert!(cookie.contains("timezone=Europe%2FParis"));
249    }
250
251    #[test]
252    fn cookie_header_with_token_includes_token() {
253        let session = test_session();
254        let cookie = session.cookie_header_with_token();
255        assert!(cookie.contains("token=abc123"));
256        assert!(cookie.contains("mac="));
257    }
258
259    #[test]
260    fn auth_header_format() {
261        let session = test_session();
262        assert_eq!(session.auth_header(), "Bearer abc123");
263    }
264
265    #[test]
266    fn token_not_expired_initially() {
267        let session = test_session();
268        assert!(!session.is_token_expired());
269    }
270
271    #[test]
272    fn token_expired_after_validity() {
273        let mut session = test_session();
274        session.token_validity = Duration::from_millis(0);
275        // A zero-duration validity means token is immediately expired
276        std::thread::sleep(Duration::from_millis(1));
277        assert!(session.is_token_expired());
278    }
279
280    #[test]
281    fn refresh_token_resets_timestamp() {
282        let mut session = test_session();
283        session.token_validity = Duration::from_millis(0);
284        std::thread::sleep(Duration::from_millis(1));
285        assert!(session.is_token_expired());
286
287        session.refresh_token("new_token".into());
288        // After refresh, token should not be expired (validity reset to 0ms is still tricky)
289        session.token_validity = Duration::from_secs(3600);
290        assert!(!session.is_token_expired());
291        assert_eq!(session.token, "new_token");
292    }
293
294    #[test]
295    fn serial_and_device_id_populated() {
296        let session = test_session();
297        assert_eq!(session.serial.len(), 13);
298        assert_eq!(session.device_id.len(), 64);
299        assert_eq!(session.device_id, session.device_id2);
300    }
301
302    #[test]
303    fn signature_is_valid() {
304        let session = test_session();
305        let sig = session.signature();
306        assert_eq!(sig.len(), 64);
307        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
308    }
309
310    #[test]
311    fn mac_to_device_id_removes_colons() {
312        assert_eq!(
313            StalkerSession::mac_to_device_id("00:1A:79:AB:CD:EF"),
314            "001A79ABCDEF"
315        );
316    }
317
318    #[test]
319    fn mac_to_device_id_uppercases() {
320        assert_eq!(
321            StalkerSession::mac_to_device_id("aa:bb:cc:dd:ee:ff"),
322            "AABBCCDDEEFF"
323        );
324    }
325
326    #[test]
327    fn full_headers_contain_required_fields() {
328        let session = test_session();
329        let headers = session.full_headers(true);
330        let header_map: std::collections::HashMap<_, _> = headers.into_iter().collect();
331
332        assert_eq!(header_map["Authorization"], "Bearer abc123");
333        assert!(header_map["User-Agent"].contains("MAG200"));
334        assert_eq!(header_map["X-User-Agent"], "Model: MAG250; Link: WiFi");
335        assert!(header_map["Cookie"].contains("token=abc123"));
336    }
337
338    #[test]
339    fn full_headers_without_token_in_cookie() {
340        let session = test_session();
341        let headers = session.full_headers(false);
342        let header_map: std::collections::HashMap<_, _> = headers.into_iter().collect();
343
344        assert!(!header_map["Cookie"].contains("token="));
345    }
346
347    #[test]
348    fn custom_timezone_in_cookie_header() {
349        let session = StalkerSession::new(
350            "token".into(),
351            "http://example.com/stalker_portal/server/load.php".into(),
352            "00:1A:79:AB:CD:EF".into(),
353            Some(3600),
354            Some("America/New_York"),
355        );
356        let cookie = session.cookie_header();
357        assert!(cookie.contains("timezone=America%2FNew_York"));
358        assert!(!cookie.contains("Europe%2FParis"));
359    }
360
361    #[test]
362    fn default_timezone_is_europe_paris_when_none() {
363        let session = StalkerSession::new(
364            "token".into(),
365            "http://example.com/stalker_portal/server/load.php".into(),
366            "00:1A:79:AB:CD:EF".into(),
367            Some(3600),
368            None,
369        );
370        let cookie = session.cookie_header();
371        assert!(cookie.contains("timezone=Europe%2FParis"));
372    }
373
374    #[test]
375    fn empty_timezone_defaults_to_europe_paris() {
376        let session = StalkerSession::new(
377            "token".into(),
378            "http://example.com/stalker_portal/server/load.php".into(),
379            "00:1A:79:AB:CD:EF".into(),
380            Some(3600),
381            Some(""),
382        );
383        let cookie = session.cookie_header();
384        assert!(cookie.contains("timezone=Europe%2FParis"));
385    }
386}