1use 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#[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 pub fn new() -> Self {
51 Self::with_client(Client::new())
52 }
53
54 pub fn with_client(http: Client) -> Self {
59 Self {
60 http,
61 base_url: CDN_BASE_URL.to_string(),
62 }
63 }
64
65 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 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}