1use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
8use std::time::{Duration, Instant};
9use tokio::sync::Mutex;
10
11use crate::device;
12
13const DEFAULT_TOKEN_VALIDITY_SECS: u64 = 3600;
15
16const DEFAULT_TIMEZONE: &str = "Europe/Paris";
18
19const MAC_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
22 .remove(b'-')
23 .remove(b'_')
24 .remove(b'.')
25 .remove(b'~');
26
27#[derive(Debug, Clone)]
31pub struct StalkerSession {
32 pub(crate) token: String,
34
35 pub(crate) portal_url: String,
37
38 pub(crate) mac_address: String,
40
41 pub(crate) token_obtained_at: Instant,
43
44 pub(crate) token_validity: Duration,
46
47 pub(crate) serial: String,
49
50 pub(crate) device_id: String,
52
53 pub(crate) device_id2: String,
55
56 pub(crate) random: String,
58
59 pub(crate) timezone: String,
61}
62
63impl StalkerSession {
64 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 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 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 pub fn auth_header(&self) -> String {
120 format!("Bearer {}", self.token)
121 }
122
123 pub fn is_token_expired(&self) -> bool {
128 self.token_obtained_at.elapsed() > self.token_validity
129 }
130
131 pub fn refresh_token(&mut self, new_token: String) {
133 self.token = new_token;
134 self.token_obtained_at = Instant::now();
135 }
136
137 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 pub fn metrics(&self) -> String {
149 device::generate_metrics(&self.mac_address, &self.serial, &self.random)
150 }
151
152 pub fn hw_version_2(&self) -> String {
154 device::generate_hw_version_2(&self.mac_address)
155 }
156
157 pub fn mac_to_device_id(mac: &str) -> String {
161 mac.replace(':', "").to_uppercase()
162 }
163
164 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
201pub struct TokenRefreshLock {
206 inner: Mutex<()>,
207}
208
209impl TokenRefreshLock {
210 pub fn new() -> Self {
211 Self {
212 inner: Mutex::new(()),
213 }
214 }
215
216 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 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 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}