client/
auth.rs

1// src/client/auth.rs
2//! Authentication helpers for Bilibili live danmaku WebSocket client
3
4use md5;
5use reqwest::header::HeaderMap;
6use reqwest::StatusCode;
7use serde::Deserialize;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10// Add browser cookie support
11use crate::browser_cookies;
12
13/// Get Bilibili cookies from browser (preferred, newest), then fallback to provided cookie string
14pub fn get_cookies_or_browser(provided_cookie: Option<&str>) -> Option<String> {
15    // First try browser cookies as they are the newest
16    log::info!("Searching for Bilibili cookies in browser (newest)...");
17    if let Some(browser_cookie) = browser_cookies::find_bilibili_cookies_as_string() {
18        log::info!("Found Bilibili cookies in browser (using newest)");
19        return Some(browser_cookie);
20    }
21
22    log::info!("No Bilibili cookies found in browser, checking provided cookie...");
23    if let Some(cookie) = provided_cookie {
24        if !cookie.is_empty() && cookie != "dummy_sessdata" && cookie.len() > 20 {
25            log::info!("Using provided cookie as fallback");
26            return Some(cookie.to_string());
27        }
28    }
29
30    log::warn!("No valid Bilibili cookies found in browser or provided input");
31    None
32}
33
34pub fn init_uid(headers: HeaderMap) -> (StatusCode, String) {
35    let client = reqwest::blocking::Client::builder()
36        .https_only(true)
37        .build()
38        .unwrap();
39
40    let mut request_headers = headers;
41    request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
42
43    let response = client.get(UID_INIT_URL).headers(request_headers).send();
44    log::debug!("init uid response: {:?}", response);
45    let stat: StatusCode;
46    let body: String;
47    match response {
48        Ok(resp) => {
49            stat = resp.status();
50            body = resp.text().unwrap();
51            log::info!("init uid response: {:?}", body);
52        }
53        Err(_) => {
54            panic!("init uid failed");
55        }
56    }
57    (stat, body)
58}
59
60/// Initializes the buvid by sending a request and extracting the 'buvid3' cookie.
61///
62/// Note: This function is not used for document creation.
63///
64/// # Panics
65///
66/// Panics if the request fails.
67pub fn init_buvid(headers: HeaderMap) -> (StatusCode, String) {
68    // Not used for document creation.
69    let client = reqwest::blocking::Client::builder()
70        .https_only(true)
71        .build()
72        .unwrap();
73
74    let mut request_headers = headers;
75    request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
76
77    let response = client.get(BUVID_INIT_URL).headers(request_headers).send();
78    let stat: StatusCode;
79    let mut buvid: String = "".to_string();
80    match response {
81        Ok(resp) => {
82            stat = resp.status();
83            let cookies = resp.cookies();
84            for i in cookies {
85                log::debug!("init buvid response cookie : {:?}", i);
86                if "buvid3".eq(i.name()) {
87                    buvid = i.value().to_string();
88                    log::info!("init buvid response: {:?}", buvid);
89                }
90            }
91        }
92        Err(_) => {
93            panic!("init buvid failed");
94        }
95    }
96    (stat, buvid)
97}
98
99/// Initializes the room by sending a request with the given room ID.
100///
101/// Note: This function should NOT be used for document creation.
102///
103/// # Panics
104///
105/// Panics if the request fails.
106pub fn init_room(headers: HeaderMap, temp_room_id: &str) -> (StatusCode, String) {
107    let client = reqwest::blocking::Client::builder()
108        .https_only(true)
109        .build()
110        .unwrap();
111
112    let mut request_headers = headers;
113    request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
114
115    let url = format!("{}?room_id={}", ROOM_INIT_URL, temp_room_id);
116    let response = client.get(url).headers(request_headers).send();
117    let stat: StatusCode;
118    let body: String;
119    match response {
120        Ok(resp) => {
121            stat = resp.status();
122            body = resp.text().unwrap();
123            log::info!("init room response: {:?}", body);
124        }
125        Err(_) => {
126            panic!("init buvid failed");
127        }
128    }
129    (stat, body)
130}
131
132pub fn init_host_server(headers: HeaderMap, room_id: u64) -> (StatusCode, String) {
133    let client = reqwest::blocking::Client::builder()
134        .https_only(true)
135        .build()
136        .unwrap();
137
138    let mut request_headers = headers.clone();
139    request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
140
141    // Get WBI keys for signing
142    let wbi_keys = match get_wbi_keys(request_headers.clone()) {
143        Ok(keys) => keys,
144        Err(e) => {
145            log::error!("Failed to get WBI keys: {:?}", e);
146            panic!("Failed to get WBI keys");
147        }
148    };
149
150    // Prepare parameters for signing
151    let params = vec![
152        ("id", room_id.to_string()),
153        ("type", "0".to_string()),
154        ("web_location", "444.8".to_string()),
155    ];
156
157    // Generate signed query string
158    let signed_query = encode_wbi(params, wbi_keys);
159
160    // Construct final URL
161    let url = format!("{}?{}", DANMAKU_SERVER_CONF_URL, signed_query);
162
163    // debug log the total request
164    let response = client.get(url).headers(request_headers).send();
165    log::debug!("init host server response: {:?}", response);
166    let stat: StatusCode;
167    let body: String;
168    match response {
169        Ok(resp) => {
170            stat = resp.status();
171            body = resp.text().unwrap();
172            log::info!("init host server response body: {:?}", body);
173        }
174        Err(_) => {
175            panic!("init host server failed");
176        }
177    }
178    (stat, body)
179}
180
181// WBI signing constants and functions
182const MIXIN_KEY_ENC_TAB: [usize; 64] = [
183    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
184    28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
185    54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
186];
187
188#[derive(Deserialize)]
189struct WbiImg {
190    img_url: String,
191    sub_url: String,
192}
193
194#[derive(Deserialize)]
195struct Data {
196    wbi_img: WbiImg,
197}
198
199#[derive(Deserialize)]
200struct ResWbi {
201    data: Data,
202}
203
204// 对 imgKey 和 subKey 进行字符顺序打乱编码
205fn get_mixin_key(orig: &[u8]) -> String {
206    MIXIN_KEY_ENC_TAB
207        .iter()
208        .take(32)
209        .map(|&i| orig[i] as char)
210        .collect::<String>()
211}
212
213fn get_url_encoded(s: &str) -> String {
214    s.chars()
215        .filter_map(|c| match c.is_ascii_alphanumeric() || "-_.~".contains(c) {
216            true => Some(c.to_string()),
217            false => {
218                // 过滤 value 中的 "!'()*" 字符
219                if "!'()*".contains(c) {
220                    return None;
221                }
222                let encoded = c
223                    .encode_utf8(&mut [0; 4])
224                    .bytes()
225                    .fold("".to_string(), |acc, b| acc + &format!("%{:02X}", b));
226                Some(encoded)
227            }
228        })
229        .collect::<String>()
230}
231
232// 为请求参数进行 wbi 签名
233fn encode_wbi(params: Vec<(&str, String)>, (img_key, sub_key): (String, String)) -> String {
234    let cur_time = match SystemTime::now().duration_since(UNIX_EPOCH) {
235        Ok(t) => t.as_secs(),
236        Err(_) => panic!("SystemTime before UNIX EPOCH!"),
237    };
238    _encode_wbi(params, (img_key, sub_key), cur_time)
239}
240
241fn _encode_wbi(
242    mut params: Vec<(&str, String)>,
243    (img_key, sub_key): (String, String),
244    timestamp: u64,
245) -> String {
246    let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
247    // 添加当前时间戳
248    params.push(("wts", timestamp.to_string()));
249    // 重新排序
250    params.sort_by(|a, b| a.0.cmp(b.0));
251    // 拼接参数
252    let query = params
253        .iter()
254        .map(|(k, v)| format!("{}={}", get_url_encoded(k), get_url_encoded(v)))
255        .collect::<Vec<_>>()
256        .join("&");
257    // 计算签名
258    let web_sign = format!("{:x}", md5::compute(query.clone() + &mixin_key));
259    // 返回最终的 query
260    query + &format!("&w_rid={}", web_sign)
261}
262
263fn get_wbi_keys(headers: HeaderMap) -> Result<(String, String), reqwest::Error> {
264    let client = reqwest::blocking::Client::builder()
265        .https_only(true)
266        .build()
267        .unwrap();
268
269    let mut request_headers = headers;
270    request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
271
272    let response = client
273        .get("https://api.bilibili.com/x/web-interface/nav")
274        .headers(request_headers)
275        .send()?;
276
277    let res_wbi: ResWbi = response.json()?;
278    Ok((
279        take_filename(res_wbi.data.wbi_img.img_url).unwrap(),
280        take_filename(res_wbi.data.wbi_img.sub_url).unwrap(),
281    ))
282}
283
284fn take_filename(url: String) -> Option<String> {
285    url.rsplit_once('/')
286        .and_then(|(_, s)| s.rsplit_once('.'))
287        .map(|(s, _)| s.to_string())
288}
289
290pub const UID_INIT_URL: &str = "https://api.bilibili.com/x/web-interface/nav";
291pub const BUVID_INIT_URL: &str = "https://data.bilibili.com/v/";
292pub const ROOM_INIT_URL: &str =
293    "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom";
294pub const DANMAKU_SERVER_CONF_URL: &str =
295    "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo";
296pub const USER_AGENT: &str =
297    "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0";
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_uid_url_constant() {
305        assert!(UID_INIT_URL.contains("bilibili.com"));
306    }
307
308    #[test]
309    fn test_take_filename() {
310        assert_eq!(
311            take_filename(
312                "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string()
313            ),
314            Some("7cd084941338484aae1ad9425b84077c".to_string())
315        );
316
317        assert_eq!(
318            take_filename(
319                "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string()
320            ),
321            Some("4932caff0ff746eab6f01bf08b70ac45".to_string())
322        );
323
324        // Test edge case with no extension
325        assert_eq!(
326            take_filename("https://example.com/path/file".to_string()),
327            None
328        );
329    }
330
331    #[test]
332    fn test_encode_wbi_with_known_values() {
333        let params = vec![
334            ("foo", String::from("114")),
335            ("bar", String::from("514")),
336            ("zab", String::from("1919810")),
337        ];
338
339        let result = _encode_wbi(
340            params,
341            (
342                "7cd084941338484aae1ad9425b84077c".to_string(),
343                "4932caff0ff746eab6f01bf08b70ac45".to_string(),
344            ),
345            1702204169,
346        );
347
348        assert_eq!(
349            result,
350            "bar=514&foo=114&wts=1702204169&zab=1919810&w_rid=8f6f2b5b3d485fe1886cec6a0be8c5d4"
351        );
352    }
353
354    #[test]
355    fn test_encode_wbi_bilibili_danmu_params() {
356        // Test with the actual Bilibili danmu parameters from the example
357        let params = vec![
358            ("id", String::from("24779526")),
359            ("type", String::from("0")),
360            ("web_location", String::from("444.8")),
361        ];
362
363        // Using the timestamp from the example URL (1748308267)
364        let result = _encode_wbi(
365            params,
366            (
367                "7cd084941338484aae1ad9425b84077c".to_string(),
368                "4932caff0ff746eab6f01bf08b70ac45".to_string(),
369            ),
370            1748308267,
371        );
372
373        // The result should contain the correct parameters and w_rid
374        assert!(result.contains("id=24779526"));
375        assert!(result.contains("type=0"));
376        assert!(result.contains("web_location=444.8"));
377        assert!(result.contains("wts=1748308267"));
378        assert!(result.contains("w_rid="));
379
380        // Check the parameter order (should be alphabetical)
381        let expected_order = "id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=";
382        assert!(result.starts_with(expected_order));
383    }
384
385    #[test]
386    fn test_wbi_signature_consistency() {
387        // Test that the same parameters always generate the same signature
388        let params1 = vec![
389            ("id", String::from("24779526")),
390            ("type", String::from("0")),
391            ("web_location", String::from("444.8")),
392        ];
393
394        let params2 = vec![
395            ("id", String::from("24779526")),
396            ("type", String::from("0")),
397            ("web_location", String::from("444.8")),
398        ];
399
400        let keys = (
401            "7cd084941338484aae1ad9425b84077c".to_string(),
402            "4932caff0ff746eab6f01bf08b70ac45".to_string(),
403        );
404
405        let timestamp = 1748308267;
406
407        let result1 = _encode_wbi(params1, keys.clone(), timestamp);
408        let result2 = _encode_wbi(params2, keys, timestamp);
409
410        assert_eq!(result1, result2);
411    }
412
413    #[test]
414    fn test_wbi_parameter_sorting() {
415        // Test that parameters are properly sorted alphabetically
416        let params = vec![
417            ("z_param", String::from("last")),
418            ("a_param", String::from("first")),
419            ("m_param", String::from("middle")),
420        ];
421
422        let result = _encode_wbi(
423            params,
424            (
425                "7cd084941338484aae1ad9425b84077c".to_string(),
426                "4932caff0ff746eab6f01bf08b70ac45".to_string(),
427            ),
428            1748308267,
429        );
430
431        // Check that parameters appear in alphabetical order
432        let parts: Vec<&str> = result.split('&').collect();
433        assert!(parts[0].starts_with("a_param="));
434        assert!(parts[1].starts_with("m_param="));
435        assert!(parts[2].starts_with("wts="));
436        assert!(parts[3].starts_with("z_param="));
437        assert!(parts[4].starts_with("w_rid="));
438    }
439
440    #[test]
441    fn test_correct_bilibili_url_signature() {
442        // Test the exact URL from the working example:
443        // "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=884cf361b8ad4e239b4a9dbbb7134679"
444        // "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=d1e619744b4977f88ed67524a1f567cc&wts=1751072897"
445
446        let params = vec![
447            ("id", String::from("24779526")),
448            ("type", String::from("0")),
449            ("web_location", String::from("444.8")),
450        ];
451
452        let result = _encode_wbi(
453            params,
454            (
455                "7cd084941338484aae1ad9425b84077c".to_string(),
456                "4932caff0ff746eab6f01bf08b70ac45".to_string(),
457            ),
458            1751072897,
459        );
460
461        // Expected complete query string from working URL
462        let expected = "id=24779526&type=0&web_location=444.8&wts=1751072897&w_rid=d1e619744b4977f88ed67524a1f567cc";
463        assert_eq!(result, expected);
464
465        // Extract and verify the w_rid specifically
466        let w_rid = result.split("w_rid=").nth(1).unwrap();
467        assert_eq!(w_rid, "d1e619744b4977f88ed67524a1f567cc");
468    }
469
470    #[test]
471    fn test_second_bilibili_url_signature() {
472        // Test the second URL example:
473        // "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=fa20533eb27334ba6f2ec7263721319a&wts=1748311635"
474
475        let params = vec![
476            ("id", String::from("24779526")),
477            ("type", String::from("0")),
478            ("web_location", String::from("444.8")),
479        ];
480
481        let result = _encode_wbi(
482            params,
483            (
484                "7cd084941338484aae1ad9425b84077c".to_string(),
485                "4932caff0ff746eab6f01bf08b70ac45".to_string(),
486            ),
487            1748311635,
488        );
489
490        // Expected complete query string from working URL
491        let expected = "id=24779526&type=0&web_location=444.8&wts=1748311635&w_rid=fa20533eb27334ba6f2ec7263721319a";
492        assert_eq!(result, expected);
493
494        // Extract and verify the w_rid specifically
495        let w_rid = result.split("w_rid=").nth(1).unwrap();
496        assert_eq!(w_rid, "fa20533eb27334ba6f2ec7263721319a");
497    }
498
499    #[test]
500    fn test_third_bilibili_url_signature() {
501        // Test the third URL example from README:
502        // "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507"
503
504        let params = vec![
505            ("id", String::from("24779526")),
506            ("type", String::from("0")),
507            ("web_location", String::from("444.8")),
508        ];
509
510        let result = _encode_wbi(
511            params,
512            (
513                "7cd084941338484aae1ad9425b84077c".to_string(),
514                "4932caff0ff746eab6f01bf08b70ac45".to_string(),
515            ),
516            1748312554,
517        );
518
519        // Expected complete query string from working URL
520        let expected = "id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507";
521        assert_eq!(result, expected);
522
523        // Extract and verify the w_rid specifically
524        let w_rid = result.split("w_rid=").nth(1).unwrap();
525        assert_eq!(w_rid, "30f250e8abd9effea1bcb88aab416507");
526    }
527}