use std::time::Duration; use crate::{service::media::FileMeta, services, utils, Error, Result, Ruma}; use ruma::api::client::{ error::{ErrorKind, RetryAfter}, media::{ create_content, get_content, get_content_as_filename, get_content_thumbnail, get_media_config, get_media_preview }, }; #[cfg(feature = "url_preview")] use { crate::config::UrlPreviewMode, crate::service::media::UrlPreviewData, webpage::HTML, reqwest::Url, std::{io::Cursor, net::IpAddr, sync::Arc}, tokio::sync::Notify, image::io::Reader as ImgReader, }; const MXC_LENGTH: usize = 32; /// # `GET /_matrix/media/r0/config` /// /// Returns max upload size. pub async fn get_media_config_route( _body: Ruma, ) -> Result { Ok(get_media_config::v3::Response { upload_size: services().globals.max_request_size().into(), }) } #[cfg(feature = "url_preview")] async fn download_image( client: &reqwest::Client, url: &str, ) -> Result { let image = client.get(url).send().await?.bytes().await?; let mxc = format!( "mxc://{}/{}", services().globals.server_name(), utils::random_string(MXC_LENGTH) ); services().media .create(mxc.clone(), None, None, &image) .await?; let (width, height) = match ImgReader::new(Cursor::new(&image)).with_guessed_format() { Err(_) => (None, None), Ok(reader) => match reader.into_dimensions() { Err(_) => (None, None), Ok((width, height)) => (Some(width), Some(height)), }, }; Ok(UrlPreviewData { image: Some(mxc), image_size: Some(image.len()), image_width: width, image_height: height, ..Default::default() }) } #[cfg(feature = "url_preview")] async fn download_html( client: &reqwest::Client, url: &str, ) -> Result { let max_download_size = 300_000; let mut response = client.get(url).send().await?; let mut bytes: Vec = Vec::new(); while let Some(chunk) = response.chunk().await? { bytes.extend_from_slice(&chunk); if bytes.len() > max_download_size { break; } } let body = String::from_utf8_lossy(&bytes); let html = match HTML::from_string(body.to_string(), Some(url.to_owned())) { Ok(html) => html, Err(_) => { return Err(Error::BadRequest( ErrorKind::Unknown, "Failed to parse HTML", )) } }; let mut data = match html.opengraph.images.first() { None => UrlPreviewData::default(), Some(obj) => download_image(client, &obj.url).await?, }; let props = html.opengraph.properties; /* use OpenGraph title/description, but fall back to HTML if not available */ data.title = props.get("title").cloned().or(html.title); data.description = props.get("description").cloned().or(html.description); Ok(data) } #[cfg(feature = "url_preview")] fn url_request_allowed(addr: &IpAddr) -> bool { // could be implemented with reqwest when it supports IP filtering: // https://github.com/seanmonstar/reqwest/issues/1515 // TODO: simplify to .is_global() when it has been stabilized match addr { IpAddr::V4(ip4) => { !(ip4.is_private() || ip4.is_loopback() || ip4.is_link_local() || ip4.is_multicast() || ip4.is_broadcast() || ip4.is_documentation() || ip4.is_unspecified()) } IpAddr::V6(ip6) => !(ip6.is_loopback() || ip6.is_multicast() || ip6.is_unspecified()), } } #[cfg(feature = "url_preview")] async fn request_url_preview(url: &str) -> Result { let client = services().globals.default_client(); let response = client.head(url).send().await?; if !response .remote_addr() .map_or(false, |a| url_request_allowed(&a.ip())) { return Err(Error::BadRequest( ErrorKind::Unknown, "Requesting from this address forbidden", )); } let content_type = match response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|x| x.to_str().ok()) { Some(ct) => ct, None => { return Err(Error::BadRequest( ErrorKind::Unknown, "Unknown Content-Type", )) } }; let data = match content_type { html if html.starts_with("text/html") => download_html(&client, url).await?, img if img.starts_with("image/") => download_image(&client, url).await?, _ => { return Err(Error::BadRequest( ErrorKind::Unknown, "Unsupported Content-Type", )) } }; services().media.set_url_preview(url, &data).await?; Ok(data) } #[cfg(feature = "url_preview")] async fn get_url_preview(url: &str) -> Result { if let Some(preview) = services().media.get_url_preview(url).await { return Ok(preview); } let notif_opt = services() .media .url_preview_requests .read() .unwrap() .get(url) .cloned(); match notif_opt { None => { let notifier = Arc::new(Notify::new()); { services().media .url_preview_requests .write() .unwrap() .insert(url.to_string(), notifier.clone()); } let data = request_url_preview(url).await; notifier.notify_waiters(); { services().media.url_preview_requests.write().unwrap().remove(url); } data } Some(notifier) => { // wait until being notified that request is finished let notifier = notifier.clone(); let notifier = notifier.notified(); notifier.await; services().media .get_url_preview(url) .await .ok_or(Error::BadRequest( ErrorKind::Unknown, "No Preview available", )) } } } #[cfg(feature = "url_preview")] fn url_preview_allowed(url_str: &str) -> bool { let url = match Url::parse(url_str) { Ok(u) => u, Err(_) => return false, }; if ["http", "https"].iter().all(|&scheme| scheme != url.scheme().to_lowercase()) { return false; } match services().globals.url_preview_mode() { UrlPreviewMode::All => true, UrlPreviewMode::None => false, UrlPreviewMode::Allowlist => { match url.host_str() { None => false, Some(host) => { services().globals.url_preview_allowlist().contains(&host.to_string()) } } } } } /// # `GET /_matrix/media/r0/preview_url` /// /// Returns URL preview. #[cfg(feature = "url_preview")] pub async fn get_media_preview_route( body: Ruma, ) -> Result { let url = &body.url; if !url_preview_allowed(url) { return Err(Error::BadRequest( ErrorKind::Unknown, "Previewing URL not allowed", )); } if let Ok(preview) = get_url_preview(url).await { let res = serde_json::value::to_raw_value(&preview).expect("Converting to JSON failed"); return Ok(get_media_preview::v3::Response::from_raw_value(res)); } Err(Error::BadRequest( ErrorKind::LimitExceeded { retry_after: Some(RetryAfter::Delay(Duration::from_secs(5))), }, "Retry later", )) } #[cfg(not(feature = "url_preview"))] pub async fn get_media_preview_route( _body: Ruma, ) -> Result { Err(Error::BadRequest( ErrorKind::Forbidden, "URL preview not implemented", )) } /// # `POST /_matrix/media/r0/upload` /// /// Permanently save media in the server. /// /// - Some metadata will be saved in the database /// - Media will be saved in the media/ directory pub async fn create_content_route( body: Ruma, ) -> Result { let mxc = format!( "mxc://{}/{}", services().globals.server_name(), utils::random_string(MXC_LENGTH) ); services() .media .create( mxc.clone(), body.filename .as_ref() .map(|filename| "inline; filename=".to_owned() + filename) .as_deref(), body.content_type.as_deref(), &body.file, ) .await?; Ok(create_content::v3::Response { content_uri: mxc.into(), blurhash: None, }) } pub async fn get_remote_content( mxc: &str, server_name: &ruma::ServerName, media_id: String, ) -> Result { let content_response = services() .sending .send_federation_request( server_name, get_content::v3::Request { allow_remote: false, server_name: server_name.to_owned(), media_id, timeout_ms: Duration::from_secs(20), allow_redirect: false, }, ) .await?; services() .media .create( mxc.to_owned(), content_response.content_disposition.as_deref(), content_response.content_type.as_deref(), &content_response.file, ) .await?; Ok(content_response) } /// # `GET /_matrix/media/r0/download/{serverName}/{mediaId}` /// /// Load media from our server or over federation. /// /// - Only allows federation if `allow_remote` is true pub async fn get_content_route( body: Ruma, ) -> Result { let mxc = format!("mxc://{}/{}", body.server_name, body.media_id); if let Some(FileMeta { content_disposition, content_type, file, }) = services().media.get(mxc.clone()).await? { Ok(get_content::v3::Response { file, content_type, content_disposition, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } else if &*body.server_name != services().globals.server_name() && body.allow_remote { let remote_content_response = get_remote_content(&mxc, &body.server_name, body.media_id.clone()).await?; Ok(get_content::v3::Response { content_disposition: remote_content_response.content_disposition, content_type: remote_content_response.content_type, file: remote_content_response.file, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } else { Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) } } /// # `GET /_matrix/media/r0/download/{serverName}/{mediaId}/{fileName}` /// /// Load media from our server or over federation, permitting desired filename. /// /// - Only allows federation if `allow_remote` is true pub async fn get_content_as_filename_route( body: Ruma, ) -> Result { let mxc = format!("mxc://{}/{}", body.server_name, body.media_id); if let Some(FileMeta { file, content_type, .. }) = services().media.get(mxc.clone()).await? { Ok(get_content_as_filename::v3::Response { file, content_type, content_disposition: Some(format!("inline; filename={}", body.filename)), cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } else if &*body.server_name != services().globals.server_name() && body.allow_remote { let remote_content_response = get_remote_content(&mxc, &body.server_name, body.media_id.clone()).await?; Ok(get_content_as_filename::v3::Response { content_disposition: Some(format!("inline: filename={}", body.filename)), content_type: remote_content_response.content_type, file: remote_content_response.file, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } else { Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) } } /// # `GET /_matrix/media/r0/thumbnail/{serverName}/{mediaId}` /// /// Load media thumbnail from our server or over federation. /// /// - Only allows federation if `allow_remote` is true pub async fn get_content_thumbnail_route( body: Ruma, ) -> Result { let mxc = format!("mxc://{}/{}", body.server_name, body.media_id); if let Some(FileMeta { file, content_type, .. }) = services() .media .get_thumbnail( mxc.clone(), body.width .try_into() .map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Width is invalid."))?, body.height .try_into() .map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Width is invalid."))?, ) .await? { Ok(get_content_thumbnail::v3::Response { file, content_type, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } else if &*body.server_name != services().globals.server_name() && body.allow_remote { let get_thumbnail_response = services() .sending .send_federation_request( &body.server_name, get_content_thumbnail::v3::Request { allow_remote: false, height: body.height, width: body.width, method: body.method.clone(), server_name: body.server_name.clone(), media_id: body.media_id.clone(), timeout_ms: Duration::from_secs(20), allow_redirect: false, }, ) .await?; services() .media .upload_thumbnail( mxc, None, get_thumbnail_response.content_type.as_deref(), body.width.try_into().expect("all UInts are valid u32s"), body.height.try_into().expect("all UInts are valid u32s"), &get_thumbnail_response.file, ) .await?; Ok(get_thumbnail_response) } else { Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) } }