Skip to main content

wechat_ilink/
cdn.rs

1//! Low-level CDN client for direct media download.
2//!
3//! [`CdnClient`] is a primitive layer that can be used independently of
4//! [`WechatIlinkClient`](crate::WechatIlinkClient), e.g. when you drive `get_updates` yourself
5//! via [`ILinkClient`](crate::protocol::ILinkClient) and only need decryption
6//! for a specific attachment.
7//!
8//! Modeled after [`teloxide_core::Bot`]: wraps a [`reqwest::Client`] so
9//! connection pool / TLS session / DNS cache are reused across calls, and is
10//! cheap to [`Clone`].
11
12use reqwest::Client;
13use std::time::Duration;
14
15use crate::crypto;
16use crate::error::{Result, WechatIlinkError};
17use crate::protocol::CDN_BASE_URL;
18use crate::types::CDNMedia;
19
20/// HTTP client for WeChat CDN media endpoints.
21///
22/// Cheap to [`Clone`] — shares the underlying [`reqwest::Client`], which uses
23/// an `Arc` internally.
24///
25/// # Example
26///
27/// ```no_run
28/// use wechat_ilink::{CdnClient, CDNMedia};
29///
30/// # async fn demo(media: CDNMedia) -> Result<(), Box<dyn std::error::Error>> {
31/// let cdn = CdnClient::new();
32/// let bytes = cdn.download(&media, None).await?;
33/// # Ok(())
34/// # }
35/// ```
36#[derive(Debug, Clone)]
37pub struct CdnClient {
38    http: Client,
39    base_url: String,
40}
41
42impl Default for CdnClient {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl CdnClient {
49    /// Create a [`CdnClient`] with a fresh internal [`reqwest::Client`].
50    pub fn new() -> Self {
51        Self::with_client(Client::new())
52    }
53
54    /// Create a [`CdnClient`] that reuses an existing [`reqwest::Client`].
55    ///
56    /// Useful when the caller already maintains a shared HTTP client with
57    /// custom proxy / TLS / timeout configuration.
58    pub fn with_client(http: Client) -> Self {
59        Self {
60            http,
61            base_url: CDN_BASE_URL.to_string(),
62        }
63    }
64
65    /// Override the CDN base URL (defaults to [`CDN_BASE_URL`]).
66    ///
67    /// Primarily intended for tests and regional endpoints.
68    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
69        self.base_url = base_url.into();
70        self
71    }
72
73    /// Download and AES-decrypt a CDN media object.
74    ///
75    /// `aes_key_override` is used when the decryption key is attached to the
76    /// message metadata (e.g. [`ImageContent::aes_key`](crate::ImageContent::aes_key))
77    /// rather than embedded in the media's own `aes_key` field.
78    pub async fn download(
79        &self,
80        media: &CDNMedia,
81        aes_key_override: Option<&str>,
82    ) -> Result<Vec<u8>> {
83        let download_url = if let Some(full_url) = &media.full_url {
84            full_url.clone()
85        } else {
86            let encrypted_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
87                WechatIlinkError::Media("CDN media missing encrypted query param".into())
88            })?;
89            format!(
90                "{}/download?encrypted_query_param={}",
91                self.base_url,
92                urlencoding::encode(encrypted_query_param)
93            )
94        };
95
96        let resp = self
97            .http
98            .get(&download_url)
99            .timeout(Duration::from_secs(60))
100            .send()
101            .await?;
102
103        if !resp.status().is_success() {
104            return Err(WechatIlinkError::Media(format!(
105                "CDN download failed: HTTP {}",
106                resp.status()
107            )));
108        }
109
110        let ciphertext = resp.bytes().await?.to_vec();
111
112        let key_source = aes_key_override.or(media.aes_key.as_deref());
113        let Some(key_source) = key_source.filter(|key| !key.is_empty()) else {
114            return Ok(ciphertext);
115        };
116
117        let aes_key = crypto::decode_aes_key(key_source)?;
118        crypto::decrypt_aes_ecb(&ciphertext, &aes_key)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn default_and_new_equivalent() {
128        let a = CdnClient::default();
129        let b = CdnClient::new();
130        assert_eq!(a.base_url, b.base_url);
131    }
132
133    #[test]
134    fn with_base_url_overrides() {
135        let c = CdnClient::new().with_base_url("https://example.test/cdn");
136        assert_eq!(c.base_url, "https://example.test/cdn");
137    }
138
139    #[test]
140    fn clone_is_cheap_and_preserves_config() {
141        let c = CdnClient::new().with_base_url("https://x.y/z");
142        let cloned = c.clone();
143        assert_eq!(c.base_url, cloned.base_url);
144    }
145}