Skip to main content

microclaw_engine/internal/tool_runtime/
media_client.rs

1//! Shared HTTP helpers for multimedia tools (image gen / vision / TTS / STT).
2//!
3//! All four tools hit OpenAI-compatible endpoints and share:
4//! - API-key secrecy (never logged, never echoed back)
5//! - SSRF pre-flight via `crate::internal::tool_runtime::url_safety::check_url_private_ip`
6//! - Configurable timeout + bounded redirect policy
7//! - Minimal request envelope (Bearer auth, JSON or multipart body)
8
9use 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
16/// Runtime handle that media tools share: resolved base URL, API key, and
17/// HTTP client with a consistent timeout.
18pub 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        // Guard the configured base URL itself — prevents an operator from
33        // (accidentally or maliciously) pointing multimedia traffic at
34        // internal metadata / localhost.
35        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            // Re-validate SSRF on every redirect hop — handles both
41            // provider-side redirects and any third-party SDK path.
42            .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        // Kept as a method rather than cached to make leak-by-debug less likely:
78        // `Debug for MediaClient` won't print this value.
79        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        // Never leak the API key via Debug.
90        f.debug_struct("MediaClient")
91            .field("base_url", &self.base_url)
92            .field("api_key", &"<redacted>")
93            .finish()
94    }
95}
96
97/// Resolve a "location" (absolute URL, file path, or `data:` URI) into
98/// bytes + optional mime-type. Applies SSRF check when fetching over HTTP.
99///
100/// File-path resolution is bounded to `working_dir` plus any explicit
101/// directories in `extra_allowed_dirs`. Absolute paths outside both sets
102/// are rejected.
103pub 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    // File path: must be inside working_dir or an explicit allowlist entry.
135    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    // data:[<mediatype>][;base64],<data>
143    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
227/// Save bytes to `<data_dir>/media/<subdir>/<uuid>.<ext>` and return the
228/// absolute path. Creates the parent directory as needed.
229pub 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}