client/
browser_cookies.rs

1// src/client/browser_cookies.rs
2//! Browser cookie reading functionality for automatic SESSDATA detection
3
4use chrono::{DateTime, TimeZone, Utc};
5use directories::UserDirs;
6use log::{debug, info, warn};
7use sqlite::Connection;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
13pub struct Cookie {
14    pub name: String,
15    pub value: String,
16    pub domain: String,
17    pub path: String,
18    pub expires: Option<DateTime<Utc>>,
19    pub secure: bool,
20    pub http_only: bool,
21}
22
23#[derive(Debug)]
24pub enum Browser {
25    Chrome,
26    Firefox,
27    Edge,
28    Chromium,
29    Opera,
30}
31
32impl Browser {
33    pub fn get_cookie_db_path(&self) -> Option<PathBuf> {
34        let user_dirs = UserDirs::new()?;
35        let home_dir = user_dirs.home_dir();
36
37        match self {
38            Browser::Chrome => {
39                #[cfg(target_os = "linux")]
40                {
41                    Some(home_dir.join(".config/google-chrome/Default/Cookies"))
42                }
43                #[cfg(target_os = "macos")]
44                {
45                    Some(home_dir.join("Library/Application Support/Google/Chrome/Default/Cookies"))
46                }
47                #[cfg(target_os = "windows")]
48                {
49                    Some(
50                        home_dir
51                            .join("AppData/Local/Google/Chrome/User Data/Default/Network/Cookies"),
52                    )
53                }
54            }
55            Browser::Firefox => {
56                #[cfg(target_os = "linux")]
57                {
58                    let firefox_dir = home_dir.join(".mozilla/firefox");
59                    Self::find_firefox_profile_cookies(&firefox_dir)
60                }
61                #[cfg(target_os = "macos")]
62                {
63                    let firefox_dir = home_dir.join("Library/Application Support/Firefox/Profiles");
64                    Self::find_firefox_profile_cookies(&firefox_dir)
65                }
66                #[cfg(target_os = "windows")]
67                {
68                    let firefox_dir = home_dir.join("AppData/Roaming/Mozilla/Firefox/Profiles");
69                    Self::find_firefox_profile_cookies(&firefox_dir)
70                }
71            }
72            Browser::Edge => {
73                #[cfg(target_os = "linux")]
74                {
75                    Some(home_dir.join(".config/microsoft-edge/Default/Cookies"))
76                }
77                #[cfg(target_os = "macos")]
78                {
79                    Some(
80                        home_dir.join("Library/Application Support/Microsoft Edge/Default/Cookies"),
81                    )
82                }
83                #[cfg(target_os = "windows")]
84                {
85                    Some(
86                        home_dir
87                            .join("AppData/Local/Microsoft/Edge/User Data/Default/Network/Cookies"),
88                    )
89                }
90            }
91            Browser::Chromium => {
92                #[cfg(target_os = "linux")]
93                {
94                    Some(home_dir.join(".config/chromium/Default/Cookies"))
95                }
96                #[cfg(target_os = "macos")]
97                {
98                    Some(home_dir.join("Library/Application Support/Chromium/Default/Cookies"))
99                }
100                #[cfg(target_os = "windows")]
101                {
102                    Some(home_dir.join("AppData/Local/Chromium/User Data/Default/Network/Cookies"))
103                }
104            }
105            Browser::Opera => {
106                #[cfg(target_os = "linux")]
107                {
108                    Some(home_dir.join(".config/opera/Default/Cookies"))
109                }
110                #[cfg(target_os = "macos")]
111                {
112                    Some(home_dir.join(
113                        "Library/Application Support/com.operasoftware.Opera/Default/Cookies",
114                    ))
115                }
116                #[cfg(target_os = "windows")]
117                {
118                    Some(
119                        home_dir
120                            .join("AppData/Roaming/Opera Software/Opera Stable/Network/Cookies"),
121                    )
122                }
123            }
124        }
125    }
126
127    fn find_firefox_profile_cookies(firefox_dir: &Path) -> Option<PathBuf> {
128        if !firefox_dir.exists() {
129            return None;
130        }
131
132        // Look for the default profile directory
133        let entries = fs::read_dir(firefox_dir).ok()?;
134        for entry in entries {
135            if let Ok(entry) = entry {
136                let path = entry.path();
137                if path.is_dir() {
138                    let dir_name = path.file_name()?.to_str()?;
139                    if dir_name.contains(".default") || dir_name.contains(".default-release") {
140                        let cookies_path = path.join("cookies.sqlite");
141                        if cookies_path.exists() {
142                            return Some(cookies_path);
143                        }
144                    }
145                }
146            }
147        }
148        None
149    }
150
151    pub fn get_all_supported() -> Vec<Browser> {
152        vec![
153            Browser::Chrome,
154            Browser::Firefox,
155            Browser::Edge,
156            Browser::Chromium,
157            Browser::Opera,
158        ]
159    }
160}
161
162/// Read cookies from a browser's cookie database
163pub fn read_cookies_from_browser(
164    browser: &Browser,
165    domain_filter: Option<&str>,
166) -> Result<Vec<Cookie>, String> {
167    let db_path = browser
168        .get_cookie_db_path()
169        .ok_or_else(|| "Could not determine cookie database path".to_string())?;
170
171    if !db_path.exists() {
172        return Err(format!("Cookie database not found at: {:?}", db_path));
173    }
174
175    debug!("Reading cookies from: {:?}", db_path);
176
177    // Create a temporary copy of the database since browsers might have it locked
178    let temp_path = std::env::temp_dir().join(format!("temp_cookies_{}.db", std::process::id()));
179    if let Err(e) = fs::copy(&db_path, &temp_path) {
180        return Err(format!("Failed to copy cookie database: {}", e));
181    }
182
183    let result = match browser {
184        Browser::Firefox => read_firefox_cookies(&temp_path, domain_filter),
185        _ => read_chromium_cookies(&temp_path, domain_filter),
186    };
187
188    // Clean up temporary file
189    let _ = fs::remove_file(&temp_path);
190
191    result
192}
193
194fn read_chromium_cookies(
195    db_path: &Path,
196    domain_filter: Option<&str>,
197) -> Result<Vec<Cookie>, String> {
198    let connection =
199        Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
200
201    let mut query =
202        "SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly FROM cookies"
203            .to_string();
204
205    if let Some(domain) = domain_filter {
206        query.push_str(&format!(" WHERE host_key LIKE '%{}'", domain));
207    }
208
209    let mut cookies = Vec::new();
210
211    connection
212        .iterate(query, |pairs| {
213            let mut cookie_data = HashMap::new();
214            for &(column, value) in pairs.iter() {
215                cookie_data.insert(column, value.unwrap_or(""));
216            }
217
218            let expires = if let Some(expires_str) = cookie_data.get("expires_utc") {
219                if let Ok(expires_microseconds) = expires_str.parse::<i64>() {
220                    // Chrome stores time as microseconds since Windows epoch (1601-01-01)
221                    // Convert to Unix timestamp (seconds since 1970-01-01)
222                    let windows_epoch_offset = 11644473600_i64; // seconds between 1601 and 1970
223                    let unix_timestamp = (expires_microseconds / 1_000_000) - windows_epoch_offset;
224                    Utc.timestamp_opt(unix_timestamp, 0).single()
225                } else {
226                    None
227                }
228            } else {
229                None
230            };
231
232            let cookie = Cookie {
233                name: cookie_data.get("name").unwrap_or(&"").to_string(),
234                value: cookie_data.get("value").unwrap_or(&"").to_string(),
235                domain: cookie_data.get("host_key").unwrap_or(&"").to_string(),
236                path: cookie_data.get("path").unwrap_or(&"").to_string(),
237                expires,
238                secure: cookie_data.get("is_secure").unwrap_or(&"0") == &"1",
239                http_only: cookie_data.get("is_httponly").unwrap_or(&"0") == &"1",
240            };
241
242            cookies.push(cookie);
243            true
244        })
245        .map_err(|e| format!("Failed to query cookies: {}", e))?;
246
247    Ok(cookies)
248}
249
250fn read_firefox_cookies(
251    db_path: &Path,
252    domain_filter: Option<&str>,
253) -> Result<Vec<Cookie>, String> {
254    let connection =
255        Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
256
257    let mut query =
258        "SELECT name, value, host, path, expiry, isSecure, isHttpOnly FROM moz_cookies".to_string();
259
260    if let Some(domain) = domain_filter {
261        query.push_str(&format!(" WHERE host LIKE '%{}'", domain));
262    }
263
264    let mut cookies = Vec::new();
265
266    connection
267        .iterate(query, |pairs| {
268            let mut cookie_data = HashMap::new();
269            for &(column, value) in pairs.iter() {
270                cookie_data.insert(column, value.unwrap_or(""));
271            }
272
273            let expires = if let Some(expires_str) = cookie_data.get("expiry") {
274                if let Ok(expires_timestamp) = expires_str.parse::<i64>() {
275                    Utc.timestamp_opt(expires_timestamp, 0).single()
276                } else {
277                    None
278                }
279            } else {
280                None
281            };
282
283            let cookie = Cookie {
284                name: cookie_data.get("name").unwrap_or(&"").to_string(),
285                value: cookie_data.get("value").unwrap_or(&"").to_string(),
286                domain: cookie_data.get("host").unwrap_or(&"").to_string(),
287                path: cookie_data.get("path").unwrap_or(&"").to_string(),
288                expires,
289                secure: cookie_data.get("isSecure").unwrap_or(&"0") == &"1",
290                http_only: cookie_data.get("isHttpOnly").unwrap_or(&"0") == &"1",
291            };
292
293            cookies.push(cookie);
294            true
295        })
296        .map_err(|e| format!("Failed to query cookies: {}", e))?;
297
298    Ok(cookies)
299}
300
301/// Find SESSDATA cookie from all supported browsers
302pub fn find_bilibili_cookies_as_string() -> Option<String> {
303    let browsers = Browser::get_all_supported();
304    let mut all_cookies = vec![];
305
306    for browser in browsers {
307        info!("Checking browser: {:?}", browser);
308
309        if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
310            all_cookies.extend(cookies);
311        }
312    }
313
314    let mut valid_cookies = all_cookies
315        .into_iter()
316        .filter(|cookie| {
317            if let Some(expires) = cookie.expires {
318                if Utc::now() > expires {
319                    warn!(
320                        "Found expired {} cookie, expires: {:?}",
321                        cookie.name, expires
322                    );
323                    return false;
324                }
325            }
326            true
327        })
328        .collect::<Vec<_>>();
329
330    // Deduplicate cookies, keeping the one with the latest expiry
331    valid_cookies.sort_by(|a, b| {
332        if a.name != b.name {
333            a.name.cmp(&b.name)
334        } else {
335            b.expires.cmp(&a.expires) // None is smaller
336        }
337    });
338    valid_cookies.dedup_by(|a, b| a.name == b.name);
339
340    if valid_cookies.is_empty() {
341        warn!("No valid bilibili cookies found in any browser");
342        return None;
343    }
344
345    info!("Found {} valid bilibili cookies", valid_cookies.len());
346
347    let cookie_string = valid_cookies
348        .iter()
349        .map(|c| format!("{}={}", c.name, c.value))
350        .collect::<Vec<String>>()
351        .join("; ");
352
353    if cookie_string.contains("SESSDATA") {
354        Some(cookie_string)
355    } else {
356        warn!("No SESSDATA cookie found among the valid cookies");
357        None
358    }
359}
360
361/// Get all bilibili cookies from browsers for debugging
362pub fn get_all_bilibili_cookies() -> HashMap<String, String> {
363    let mut all_cookies = HashMap::new();
364    let browsers = Browser::get_all_supported();
365
366    for browser in browsers {
367        if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
368            for cookie in cookies {
369                // Only include non-expired cookies
370                if let Some(expires) = cookie.expires {
371                    if Utc::now() > expires {
372                        continue;
373                    }
374                }
375
376                // Use the most recent cookie if duplicates exist
377                let key = format!("{}_{}", cookie.name, cookie.domain);
378                all_cookies.insert(key, cookie.value);
379            }
380        }
381    }
382
383    all_cookies
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_browser_path_detection() {
392        for browser in Browser::get_all_supported() {
393            let path = browser.get_cookie_db_path();
394            println!("{:?} cookie path: {:?}", browser, path);
395        }
396    }
397
398    #[test]
399    fn test_find_sessdata() {
400        // This test will only work if you have bilibili cookies in your browser
401        if let Some(sessdata) = find_bilibili_cookies_as_string() {
402            println!("Found SESSDATA: {}", &sessdata[..20.min(sessdata.len())]);
403            assert!(!sessdata.is_empty());
404        } else {
405            println!("No SESSDATA found - this is normal if you're not logged into bilibili");
406        }
407    }
408}