use std::path::Path;
use anyhow::{anyhow, Result};
use reqwest::Client;
use tracing::{debug, warn};
const MAX_TEAMS_FILE_SIZE: usize = 100 * 1024 * 1024; const MAX_MATRIX_FILE_SIZE: usize = 50 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct MediaInfo {
pub data: Vec<u8>,
pub content_type: String,
pub filename: String,
pub size: usize,
}
pub struct MediaHandler {
client: Client,
homeserver_url: String,
}
impl MediaHandler {
pub fn new(homeserver_url: &str) -> Self {
Self {
client: Client::new(),
homeserver_url: homeserver_url.to_string(),
}
}
pub async fn download_from_url(&self, url: &str) -> Result<MediaInfo> {
debug!("downloading media from {}", url);
let response = self
.client
.get(url)
.send()
.await
.map_err(|e| anyhow!("failed to download from {}: {}", url, e))?;
if !response.status().is_success() {
return Err(anyhow!(
"failed to download from {}: status {}",
url,
response.status()
));
}
let headers = response.headers().clone();
let raw_content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(ToOwned::to_owned);
let content_disposition = headers
.get("content-disposition")
.and_then(|v| v.to_str().ok())
.map(ToOwned::to_owned);
let data = response
.bytes()
.await
.map_err(|e| anyhow!("failed to read response body: {}", e))?
.to_vec();
let size = data.len();
let mut filename = content_disposition
.as_deref()
.and_then(filename_from_content_disposition)
.or_else(|| filename_from_url(url))
.unwrap_or_else(|| "attachment".to_string());
let content_type = normalize_content_type(raw_content_type.as_deref(), &filename, &data);
filename = ensure_filename_extension(&filename, &content_type);
debug!("downloaded {} bytes from {}", size, url);
Ok(MediaInfo {
data,
content_type,
filename,
size,
})
}
pub async fn download_matrix_media(&self, mxc_url: &str) -> Result<MediaInfo> {
if !mxc_url.starts_with("mxc://") {
return Err(anyhow!("invalid mxc URL: {}", mxc_url));
}
let mxc_path = mxc_url.trim_start_matches("mxc://");
let download_url = format!(
"{}/_matrix/media/v3/download/{}",
self.homeserver_url.trim_end_matches('/'),
mxc_path
);
self.download_from_url(&download_url).await
}
pub async fn upload_to_matrix(&self, media: &MediaInfo, access_token: &str) -> Result<String> {
if media.size > MAX_MATRIX_FILE_SIZE {
return Err(anyhow!(
"file too large for Matrix: {} bytes (max {})",
media.size,
MAX_MATRIX_FILE_SIZE
));
}
let upload_url = format!(
"{}/_matrix/media/v3/upload?filename={}",
self.homeserver_url.trim_end_matches('/'),
urlencoding::encode(&media.filename)
);
let response = self
.client
.post(&upload_url)
.bearer_auth(access_token)
.header("Content-Type", &media.content_type)
.body(media.data.clone())
.send()
.await
.map_err(|e| anyhow!("failed to upload to Matrix: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow!(
"failed to upload to Matrix: status {} - {}",
status,
error_text
));
}
#[derive(serde::Deserialize)]
struct UploadResponse {
content_uri: String,
}
let upload_response: UploadResponse = response
.json()
.await
.map_err(|e| anyhow!("failed to parse upload response: {}", e))?;
debug!("uploaded media to {}", upload_response.content_uri);
Ok(upload_response.content_uri)
}
pub async fn download_teams_attachment(
&self,
url: &str,
access_token: &str,
) -> Result<MediaInfo> {
debug!("downloading Teams attachment from {}", url);
let response = self
.client
.get(url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| anyhow!("failed to download Teams attachment: {}", e))?;
if !response.status().is_success() {
return Err(anyhow!(
"failed to download Teams attachment: status {}",
response.status()
));
}
let headers = response.headers().clone();
let raw_content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(ToOwned::to_owned);
let content_disposition = headers
.get("content-disposition")
.and_then(|v| v.to_str().ok())
.map(ToOwned::to_owned);
let data = response
.bytes()
.await
.map_err(|e| anyhow!("failed to read attachment body: {}", e))?
.to_vec();
let size = data.len();
let mut filename = content_disposition
.as_deref()
.and_then(filename_from_content_disposition)
.or_else(|| filename_from_url(url))
.unwrap_or_else(|| "attachment".to_string());
let content_type = normalize_content_type(raw_content_type.as_deref(), &filename, &data);
filename = ensure_filename_extension(&filename, &content_type);
debug!("downloaded Teams attachment: {} bytes", size);
Ok(MediaInfo {
data,
content_type,
filename,
size,
})
}
pub fn guess_content_type(filename: &str, data: &[u8]) -> String {
if let Some(mime) = mime_guess::from_path(filename).first() {
return mime.to_string();
}
if let Some(kind) = infer::get(data) {
return kind.mime_type().to_string();
}
"application/octet-stream".to_string()
}
}
fn filename_from_content_disposition(content_disposition: &str) -> Option<String> {
for part in content_disposition.split(';') {
let part = part.trim();
if part.starts_with("filename=") {
let filename = part.trim_start_matches("filename=");
let filename = filename.trim_matches('"');
return Some(filename.to_string());
}
if part.starts_with("filename*=") {
let encoded = part.trim_start_matches("filename*=");
if let Some(filename) = decode_rfc5987(encoded) {
return Some(filename);
}
}
}
None
}
fn filename_from_url(url: &str) -> Option<String> {
let url = url.split('?').next()?;
let path = Path::new(url);
path.file_name()?.to_str().map(|s| s.to_string())
}
fn decode_rfc5987(encoded: &str) -> Option<String> {
let parts: Vec<&str> = encoded.splitn(3, '\'').collect();
if parts.len() != 3 {
return None;
}
let charset = parts[0];
let _language = parts[1];
let value = parts[2];
if charset.eq_ignore_ascii_case("utf-8") {
urlencoding::decode(value).ok().map(|s| s.to_string())
} else {
None
}
}
fn normalize_content_type(
raw_content_type: Option<&str>,
filename: &str,
data: &[u8],
) -> String {
if let Some(content_type) = raw_content_type {
let content_type = content_type.split(';').next().unwrap_or("").trim();
if !content_type.is_empty() && content_type != "application/octet-stream" {
return content_type.to_string();
}
}
MediaHandler::guess_content_type(filename, data)
}
fn ensure_filename_extension(filename: &str, content_type: &str) -> String {
let path = Path::new(filename);
if path.extension().is_some() {
return filename.to_string();
}
let extension = mime_guess::get_mime_extensions_str(content_type)
.and_then(|exts| exts.first())
.map(|s| s.to_string());
if let Some(ext) = extension {
format!("{}.{}", filename, ext)
} else {
filename.to_string()
}
}