matrix-bridge-teams 0.1.0

A bridge between Matrix and Microsoft Teams written in Rust
use std::path::Path;

use anyhow::{anyhow, Result};
use reqwest::Client;
use tracing::{debug, warn};

const MAX_TEAMS_FILE_SIZE: usize = 100 * 1024 * 1024; // 100MB
const MAX_MATRIX_FILE_SIZE: usize = 50 * 1024 * 1024; // 50MB

#[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()
    }
}