microclaw_engine/internal/tool_runtime/
media_client.rs1use std::path::PathBuf;
10use std::time::Duration;
11
12use reqwest::{Client, Url};
13
14use crate::internal::tool_runtime::url_safety::check_url_private_ip;
15
16pub struct MediaClient {
19 base_url: String,
20 api_key: String,
21 client: Client,
22}
23
24impl MediaClient {
25 pub fn new(base_url: String, api_key: String, timeout_secs: u64) -> Result<Self, String> {
26 let base_url = base_url.trim_end_matches('/').to_string();
27 if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
28 return Err(format!(
29 "invalid media base_url '{base_url}' (must be http:// or https://)"
30 ));
31 }
32 let parsed = Url::parse(&base_url).map_err(|e| format!("invalid base_url: {e}"))?;
36 check_url_private_ip(&parsed)?;
37
38 let client = Client::builder()
39 .timeout(Duration::from_secs(timeout_secs.max(5)))
40 .redirect(crate::internal::tool_runtime::url_safety::ssrf_redirect_policy(3))
43 .user_agent("MicroClaw-media/1.0")
44 .build()
45 .map_err(|e| format!("failed to build HTTP client: {e}"))?;
46 Ok(Self {
47 base_url,
48 api_key,
49 client,
50 })
51 }
52
53 pub fn endpoint(&self, path: &str) -> String {
54 let trimmed = path.trim_start_matches('/');
55 format!("{}/{}", self.base_url, trimmed)
56 }
57
58 pub fn post_json(&self, path: &str) -> reqwest::RequestBuilder {
59 self.client
60 .post(self.endpoint(path))
61 .header(reqwest::header::AUTHORIZATION, self.bearer())
62 .header(reqwest::header::ACCEPT, "application/json")
63 }
64
65 pub fn post_multipart(
66 &self,
67 path: &str,
68 form: reqwest::multipart::Form,
69 ) -> reqwest::RequestBuilder {
70 self.client
71 .post(self.endpoint(path))
72 .header(reqwest::header::AUTHORIZATION, self.bearer())
73 .multipart(form)
74 }
75
76 fn bearer(&self) -> String {
77 format!("Bearer {}", self.api_key)
80 }
81
82 pub fn client(&self) -> &Client {
83 &self.client
84 }
85}
86
87impl std::fmt::Debug for MediaClient {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("MediaClient")
91 .field("base_url", &self.base_url)
92 .field("api_key", &"<redacted>")
93 .finish()
94 }
95}
96
97pub async fn load_bytes_from_location(
104 client: &Client,
105 location: &str,
106 working_dir: &std::path::Path,
107 extra_allowed_dirs: &[PathBuf],
108) -> Result<(Vec<u8>, Option<String>), String> {
109 if let Some(stripped) = location.strip_prefix("data:") {
110 return parse_data_uri(stripped);
111 }
112 if location.starts_with("http://") || location.starts_with("https://") {
113 let parsed = Url::parse(location).map_err(|e| format!("invalid URL: {e}"))?;
114 check_url_private_ip(&parsed)?;
115 let resp = client
116 .get(parsed)
117 .send()
118 .await
119 .map_err(|e| format!("download failed: {e}"))?;
120 if !resp.status().is_success() {
121 return Err(format!("download HTTP {}", resp.status()));
122 }
123 let mime = resp
124 .headers()
125 .get(reqwest::header::CONTENT_TYPE)
126 .and_then(|v| v.to_str().ok())
127 .map(|s| s.split(';').next().unwrap_or(s).trim().to_string());
128 let bytes = resp
129 .bytes()
130 .await
131 .map_err(|e| format!("body read failed: {e}"))?;
132 return Ok((bytes.to_vec(), mime));
133 }
134 let canonical = resolve_and_guard_path(location, working_dir, extra_allowed_dirs)?;
136 let bytes = std::fs::read(&canonical).map_err(|e| format!("read failed: {e}"))?;
137 let mime = guess_mime_from_extension(&canonical);
138 Ok((bytes, mime))
139}
140
141fn parse_data_uri(rest: &str) -> Result<(Vec<u8>, Option<String>), String> {
142 let (meta, data) = rest
144 .split_once(',')
145 .ok_or_else(|| "malformed data: URI".to_string())?;
146 let is_b64 = meta.contains(";base64");
147 let mime = {
148 let first = meta.split(';').next().unwrap_or("").trim();
149 if first.is_empty() {
150 None
151 } else {
152 Some(first.to_string())
153 }
154 };
155 let bytes = if is_b64 {
156 use base64::Engine;
157 base64::engine::general_purpose::STANDARD
158 .decode(data.trim())
159 .map_err(|e| format!("invalid base64 in data URI: {e}"))?
160 } else {
161 data.as_bytes().to_vec()
162 };
163 Ok((bytes, mime))
164}
165
166fn resolve_and_guard_path(
167 path: &str,
168 working_dir: &std::path::Path,
169 extra_allowed_dirs: &[PathBuf],
170) -> Result<PathBuf, String> {
171 let p = std::path::Path::new(path);
172 let joined = if p.is_absolute() {
173 p.to_path_buf()
174 } else {
175 working_dir.join(p)
176 };
177 let canonical = std::fs::canonicalize(&joined)
178 .map_err(|e| format!("cannot resolve {}: {e}", joined.display()))?;
179 let wd_canonical = std::fs::canonicalize(working_dir)
180 .map_err(|e| format!("cannot resolve working_dir: {e}"))?;
181 let mut roots: Vec<PathBuf> = vec![wd_canonical];
182 for d in extra_allowed_dirs {
183 if let Ok(c) = std::fs::canonicalize(d) {
184 roots.push(c);
185 }
186 }
187 if !roots.iter().any(|root| canonical.starts_with(root)) {
188 return Err(format!(
189 "path {} is outside working_dir and media.allowed_read_dirs",
190 canonical.display()
191 ));
192 }
193 if crate::internal::tool_runtime::path_guard::is_blocked(&canonical) {
194 return Err(format!(
195 "access denied: {} is a sensitive path",
196 canonical.display()
197 ));
198 }
199 Ok(canonical)
200}
201
202fn guess_mime_from_extension(path: &std::path::Path) -> Option<String> {
203 let ext = path
204 .extension()
205 .and_then(|e| e.to_str())
206 .map(|s| s.to_ascii_lowercase());
207 Some(
208 match ext.as_deref()? {
209 "png" => "image/png",
210 "jpg" | "jpeg" => "image/jpeg",
211 "webp" => "image/webp",
212 "gif" => "image/gif",
213 "mp3" => "audio/mpeg",
214 "m4a" => "audio/mp4",
215 "mp4" => "audio/mp4",
216 "ogg" | "oga" => "audio/ogg",
217 "opus" => "audio/opus",
218 "wav" => "audio/wav",
219 "flac" => "audio/flac",
220 "webm" => "audio/webm",
221 _ => return None,
222 }
223 .to_string(),
224 )
225}
226
227pub fn persist_output(
230 data_dir: &std::path::Path,
231 subdir: &str,
232 extension: &str,
233 bytes: &[u8],
234) -> Result<PathBuf, String> {
235 let dir = data_dir.join("media").join(subdir);
236 std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir failed: {e}"))?;
237 let file_name = format!("{}.{}", uuid::Uuid::new_v4(), extension);
238 let path = dir.join(file_name);
239 std::fs::write(&path, bytes).map_err(|e| format!("write failed: {e}"))?;
240 Ok(path)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn debug_redacts_api_key() {
249 let mc =
250 MediaClient::new("https://api.openai.com/v1".into(), "sk-secret".into(), 30).unwrap();
251 let dbg = format!("{mc:?}");
252 assert!(dbg.contains("<redacted>"));
253 assert!(!dbg.contains("sk-secret"));
254 }
255
256 #[test]
257 fn rejects_loopback_base_url() {
258 let err = MediaClient::new("http://127.0.0.1/v1".into(), "k".into(), 30).unwrap_err();
259 assert!(err.contains("blocked"));
260 }
261
262 #[test]
263 fn parses_base64_data_uri() {
264 let out = parse_data_uri("image/png;base64,aGVsbG8=").unwrap();
265 assert_eq!(out.0, b"hello");
266 assert_eq!(out.1.as_deref(), Some("image/png"));
267 }
268
269 #[test]
270 fn parses_plain_data_uri() {
271 let out = parse_data_uri(",hello").unwrap();
272 assert_eq!(out.0, b"hello");
273 }
274
275 #[test]
276 fn guesses_mime_from_extension() {
277 assert_eq!(
278 guess_mime_from_extension(std::path::Path::new("foo.png")).as_deref(),
279 Some("image/png")
280 );
281 assert_eq!(
282 guess_mime_from_extension(std::path::Path::new("foo.mp3")).as_deref(),
283 Some("audio/mpeg")
284 );
285 assert_eq!(
286 guess_mime_from_extension(std::path::Path::new("foo.txt")),
287 None
288 );
289 }
290}