use anyhow::{Context, Result};
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::{header, multipart, Client as HttpClient};
use std::fs;
use std::path::{Path, PathBuf};
lazy_static! {
static ref MD_IMG_RE: Regex = Regex::new(r"!\[.*?\]\((.*?)\)").unwrap();
static ref HTML_IMG_RE: Regex = Regex::new(r#"<img[^>]+src=["']([^"']+)["']"#).unwrap();
}
pub fn find_media_references(content: &str) -> Vec<String> {
let mut refs = Vec::new();
for cap in MD_IMG_RE.captures_iter(content) {
if let Some(path) = cap.get(1) {
let path_str = path.as_str();
if is_local_path(path_str) {
refs.push(path_str.to_string());
}
}
}
for cap in HTML_IMG_RE.captures_iter(content) {
if let Some(path) = cap.get(1) {
let path_str = path.as_str();
if is_local_path(path_str) {
refs.push(path_str.to_string());
}
}
}
refs
}
fn is_local_path(path: &str) -> bool {
!path.starts_with("http://") && !path.starts_with("https://")
}
pub fn resolve_path(path: &str, base_dir: Option<&Path>) -> Result<PathBuf> {
if path.contains("..") {
anyhow::bail!("Path traversal not allowed in file paths: {}", path);
}
let expanded = if let Some(stripped) = path.strip_prefix("~/") {
let home = dirs::home_dir().context("Could not determine home directory")?;
home.join(stripped)
} else if path.starts_with('/') {
PathBuf::from(path)
} else if let Some(base) = base_dir {
base.join(path)
} else {
PathBuf::from(path)
};
if !expanded.exists() {
anyhow::bail!("File not found or inaccessible: {}", expanded.display());
}
Ok(expanded)
}
pub async fn upload_file(endpoint: &str, token: &str, file_path: &Path) -> Result<String> {
if !file_path.exists() {
anyhow::bail!("File not found: {}", file_path.display());
}
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.context("Invalid filename")?;
let mime_type = mime_guess::from_path(file_path).first_or_octet_stream();
let file_bytes = fs::read(file_path).context("Failed to read file")?;
let part = multipart::Part::bytes(file_bytes)
.file_name(filename.to_string())
.mime_str(mime_type.as_ref())?;
let form = multipart::Form::new().part("file", part);
let client = HttpClient::new();
let response = client
.post(endpoint)
.header(header::AUTHORIZATION, format!("Bearer {}", token))
.multipart(form)
.send()
.await
.context("Failed to upload file")?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| String::from("<unable to read response body>"));
anyhow::bail!("Upload failed with status {}: {}", status, body);
}
let url = response
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.context("No Location header in response")?
.to_string();
Ok(url)
}
pub fn replace_paths(content: &str, replacements: &[(String, String)]) -> String {
let mut result = content.to_string();
for (local_path, url) in replacements {
result = result.replace(local_path, url);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_local_path() {
assert!(is_local_path("~/photo.jpg"));
assert!(is_local_path("/abs/path.jpg"));
assert!(is_local_path("relative/path.jpg"));
assert!(!is_local_path("https://example.com/image.jpg"));
}
#[test]
fn test_replace_paths() {
let content = "Image:  here";
let replacements = vec![(
"~/photo.jpg".to_string(),
"https://cdn.com/abc.jpg".to_string(),
)];
let result = replace_paths(content, &replacements);
assert!(result.contains("https://cdn.com/abc.jpg"));
assert!(!result.contains("~/photo.jpg"));
}
}