diff --git a/src/service/media/preview.rs b/src/service/media/preview.rs index f0270d3b3..21e096019 100644 --- a/src/service/media/preview.rs +++ b/src/service/media/preview.rs @@ -10,7 +10,6 @@ #[cfg(feature = "url_preview")] use conduwuit::utils::response::LimitReadExt; use conduwuit::{Err, Result, debug, err}; -use conduwuit_core::implement; use ipaddress::IPAddress; #[cfg(feature = "url_preview")] use ruma::OwnedMxcUri; @@ -49,407 +48,407 @@ pub struct UrlPreviewData { pub audio_size: Option, } -#[implement(Service)] -pub async fn remove_url_preview(&self, url: &str) -> Result<()> { - // TODO: also remove the downloaded image - self.db.remove_url_preview(url) -} - -#[implement(Service)] -pub async fn clear_url_previews(&self) { self.db.clear_url_previews().await; } - -#[implement(Service)] -pub async fn set_url_preview(&self, url: &str, data: &UrlPreviewData) -> Result<()> { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("valid system time"); - self.db.set_url_preview(url, data, now) -} - -#[implement(Service)] -pub async fn get_url_preview(&self, url: &Url) -> Result { - if let Ok(preview) = self.db.get_url_preview(url.as_str()).await { - return Ok(preview); +impl Service { + pub async fn remove_url_preview(&self, url: &str) -> Result<()> { + // TODO: also remove the downloaded image + self.db.remove_url_preview(url) } - // ensure that only one request is made per URL - let _request_lock = self.url_preview_mutex.lock(url.as_str()).await; + pub async fn clear_url_previews(&self) { self.db.clear_url_previews().await; } - match self.db.get_url_preview(url.as_str()).await { - | Ok(preview) => Ok(preview), - | Err(_) => self.request_url_preview(url).await, + pub async fn set_url_preview(&self, url: &str, data: &UrlPreviewData) -> Result<()> { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("valid system time"); + self.db.set_url_preview(url, data, now) } -} -#[implement(Service)] -async fn request_url_preview(&self, url: &Url) -> Result { - if let Ok(ip) = IPAddress::parse(url.host_str().expect("URL previously validated")) { - if !self.services.client.valid_cidr_range(&ip) { - return Err!(Request(Forbidden("Requesting from this address is forbidden"))); + pub async fn get_url_preview(&self, url: &Url) -> Result { + if let Ok(preview) = self.db.get_url_preview(url.as_str()).await { + return Ok(preview); + } + + // ensure that only one request is made per URL + let _request_lock = self.url_preview_mutex.lock(url.as_str()).await; + + match self.db.get_url_preview(url.as_str()).await { + | Ok(preview) => Ok(preview), + | Err(_) => self.request_url_preview(url).await, } } - let client = &self.services.client.url_preview; - let response = client.head(url.as_str()).send().await?; - - debug!(%url, "URL preview response headers: {:?}", response.headers()); - - if let Some(remote_addr) = response.remote_addr() { - debug!(%url, "URL preview response remote address: {:?}", remote_addr); - - if let Ok(ip) = IPAddress::parse(remote_addr.ip().to_string()) { + async fn request_url_preview(&self, url: &Url) -> Result { + if let Ok(ip) = IPAddress::parse(url.host_str().expect("URL previously validated")) { if !self.services.client.valid_cidr_range(&ip) { return Err!(Request(Forbidden("Requesting from this address is forbidden"))); } } - } - let Some(content_type) = response.headers().get(reqwest::header::CONTENT_TYPE) else { - return Err!(Request(Unknown("Unknown or invalid Content-Type header"))); - }; + let client = &self.services.client.url_preview; + let response = client.head(url.as_str()).send().await?; - let content_type = content_type - .to_str() - .map_err(|e| err!(Request(Unknown("Unknown or invalid Content-Type header: {e}"))))?; + debug!(%url, "URL preview response headers: {:?}", response.headers()); - let data = match content_type { - | html if html.starts_with("text/html") => self.download_html(url.as_str()).await?, - | img if img.starts_with("image/") => self.download_image(url.as_str(), None).await?, - | video if video.starts_with("video/") => self.download_video(url.as_str(), None).await?, - | audio if audio.starts_with("audio/") => self.download_audio(url.as_str(), None).await?, - | _ => return Err!(Request(Unknown("Unsupported Content-Type"))), - }; + if let Some(remote_addr) = response.remote_addr() { + debug!(%url, "URL preview response remote address: {:?}", remote_addr); - self.set_url_preview(url.as_str(), &data).await?; - - Ok(data) -} - -#[cfg(feature = "url_preview")] -#[implement(Service)] -pub async fn download_image( - &self, - url: &str, - preview_data: Option, -) -> Result { - use conduwuit::utils::random_string; - use image::ImageReader; - - use crate::media::mxc::Mxc; - - let mut preview_data = preview_data.unwrap_or_default(); - - let image = self - .services - .client - .url_preview - .get(url) - .send() - .await? - .limit_read( - self.services - .server - .config - .max_request_size - .try_into() - .expect("u64 should fit in usize"), - ) - .await?; - - let mxc = Mxc { - server_name: self.services.globals.server_name(), - media_id: &random_string(super::MXC_LENGTH), - }; - - self.create(&mxc, None, None, None, &image).await?; - - preview_data.image = Some(mxc.to_string()); - if preview_data.image_height.is_none() || preview_data.image_width.is_none() { - let cursor = std::io::Cursor::new(&image); - let (width, height) = match ImageReader::new(cursor).with_guessed_format() { - | Err(_) => (None, None), - | Ok(reader) => match reader.into_dimensions() { - | Err(_) => (None, None), - | Ok((width, height)) => (Some(width), Some(height)), - }, - }; - - preview_data.image_width = width; - preview_data.image_height = height; - } - - Ok(preview_data) -} - -#[cfg(feature = "url_preview")] -#[implement(Service)] -pub async fn download_video( - &self, - url: &str, - preview_data: Option, -) -> Result { - let mut preview_data = preview_data.unwrap_or_default(); - - if self.services.globals.url_preview_allow_audio_video() { - let (url, size) = self.download_media(url).await?; - preview_data.video = Some(url.to_string()); - preview_data.video_size = Some(size); - } - - Ok(preview_data) -} - -#[cfg(feature = "url_preview")] -#[implement(Service)] -pub async fn download_audio( - &self, - url: &str, - preview_data: Option, -) -> Result { - let mut preview_data = preview_data.unwrap_or_default(); - - if self.services.globals.url_preview_allow_audio_video() { - let (url, size) = self.download_media(url).await?; - preview_data.audio = Some(url.to_string()); - preview_data.audio_size = Some(size); - } - - Ok(preview_data) -} - -#[cfg(feature = "url_preview")] -#[implement(Service)] -pub async fn download_media(&self, url: &str) -> Result<(OwnedMxcUri, usize)> { - use conduwuit::utils::random_string; - use http::header::CONTENT_TYPE; - - let response = self.services.client.url_preview.get(url).send().await?; - let content_type = response.headers().get(CONTENT_TYPE).cloned(); - let media = response - .limit_read( - self.services - .server - .config - .max_request_size - .try_into() - .expect("u64 should fit in usize"), - ) - .await?; - - let mxc = Mxc { - server_name: self.services.globals.server_name(), - media_id: &random_string(super::MXC_LENGTH), - }; - - let content_type = content_type.and_then(|v| v.to_str().map(ToOwned::to_owned).ok()); - self.create(&mxc, None, None, content_type.as_deref(), &media) - .await?; - - Ok((OwnedMxcUri::from(mxc.to_string()), media.len())) -} - -#[cfg(not(feature = "url_preview"))] -#[implement(Service)] -pub async fn download_image( - &self, - _url: &str, - _preview_data: Option, -) -> Result { - Err!(FeatureDisabled("url_preview")) -} - -#[cfg(not(feature = "url_preview"))] -#[implement(Service)] -pub async fn download_video( - &self, - _url: &str, - _preview_data: Option, -) -> Result { - Err!(FeatureDisabled("url_preview")) -} - -#[cfg(not(feature = "url_preview"))] -#[implement(Service)] -pub async fn download_audio( - &self, - _url: &str, - _preview_data: Option, -) -> Result { - Err!(FeatureDisabled("url_preview")) -} - -#[cfg(not(feature = "url_preview"))] -#[implement(Service)] -pub async fn download_media(&self, _url: &str) -> Result { - Err!(FeatureDisabled("url_preview")) -} - -#[cfg(feature = "url_preview")] -#[implement(Service)] -async fn download_html(&self, url: &str) -> Result { - use webpage::HTML; - - let client = &self.services.client.url_preview; - let body = client - .get(url) - .send() - .await? - .limit_read_text( - self.services - .server - .config - .max_request_size - .try_into() - .expect("u64 should fit in usize"), - ) - .await?; - let Ok(html) = HTML::from_string(body.clone(), Some(url.to_owned())) else { - return Err!(Request(Unknown("Failed to parse HTML"))); - }; - - let mut preview_data = UrlPreviewData::default(); - - if let Some(obj) = html.opengraph.images.first() { - preview_data = self.download_image(&obj.url, Some(preview_data)).await?; - } - - if let Some(obj) = html.opengraph.videos.first() { - preview_data = self.download_video(&obj.url, Some(preview_data)).await?; - preview_data.video_width = obj.properties.get("width").and_then(|v| v.parse().ok()); - preview_data.video_height = obj.properties.get("height").and_then(|v| v.parse().ok()); - } - - if let Some(obj) = html.opengraph.audios.first() { - preview_data = self.download_audio(&obj.url, Some(preview_data)).await?; - } - - let props = html.opengraph.properties; - - /* use OpenGraph title/description, but fall back to HTML if not available */ - preview_data.title = props.get("title").cloned().or(html.title); - preview_data.description = props.get("description").cloned().or(html.description); - - Ok(preview_data) -} - -#[cfg(not(feature = "url_preview"))] -#[implement(Service)] -async fn download_html(&self, _url: &str) -> Result { - Err!(FeatureDisabled("url_preview")) -} - -#[implement(Service)] -pub fn url_preview_allowed(&self, url: &Url) -> bool { - if ["http", "https"] - .iter() - .all(|&scheme| scheme != url.scheme().to_lowercase()) - { - debug!("Ignoring non-HTTP/HTTPS URL to preview: {}", url); - return false; - } - - let host = match url.host_str() { - | None => { - debug!("Ignoring URL preview for a URL that does not have a host (?): {}", url); - return false; - }, - | Some(h) => h.to_owned(), - }; - - let allowlist_domain_contains = self - .services - .globals - .url_preview_domain_contains_allowlist(); - let allowlist_domain_explicit = self - .services - .globals - .url_preview_domain_explicit_allowlist(); - let denylist_domain_explicit = self.services.globals.url_preview_domain_explicit_denylist(); - let allowlist_url_contains = self.services.globals.url_preview_url_contains_allowlist(); - - if allowlist_domain_contains.contains(&"*".to_owned()) - || allowlist_domain_explicit.contains(&"*".to_owned()) - || allowlist_url_contains.contains(&"*".to_owned()) - { - debug!("Config key contains * which is allowing all URL previews. Allowing URL {}", url); - return true; - } - - if !host.is_empty() { - if denylist_domain_explicit.contains(&host) { - debug!( - "Host {} is not allowed by url_preview_domain_explicit_denylist (check 1/4)", - &host - ); - return false; - } - - if allowlist_domain_explicit.contains(&host) { - debug!( - "Host {} is allowed by url_preview_domain_explicit_allowlist (check 2/4)", - &host - ); - return true; - } - - if allowlist_domain_contains - .iter() - .any(|domain_s| domain_s.contains(&host.clone())) - { - debug!( - "Host {} is allowed by url_preview_domain_contains_allowlist (check 3/4)", - &host - ); - return true; - } - - if allowlist_url_contains - .iter() - .any(|url_s| url.to_string().contains(url_s)) - { - debug!("URL {} is allowed by url_preview_url_contains_allowlist (check 4/4)", &host); - return true; - } - - // check root domain if available and if user has root domain checks - if self.services.globals.url_preview_check_root_domain() { - debug!("Checking root domain"); - match host.split_once('.') { - | None => return false, - | Some((_, root_domain)) => { - if denylist_domain_explicit.contains(&root_domain.to_owned()) { - debug!( - "Root domain {} is not allowed by \ - url_preview_domain_explicit_denylist (check 1/3)", - &root_domain - ); - return false; - } - - if allowlist_domain_explicit.contains(&root_domain.to_owned()) { - debug!( - "Root domain {} is allowed by url_preview_domain_explicit_allowlist \ - (check 2/3)", - &root_domain - ); - return true; - } - - if allowlist_domain_contains - .iter() - .any(|domain_s| domain_s.contains(&root_domain.to_owned())) - { - debug!( - "Root domain {} is allowed by url_preview_domain_contains_allowlist \ - (check 3/3)", - &root_domain - ); - return true; - } - }, + if let Ok(ip) = IPAddress::parse(remote_addr.ip().to_string()) { + if !self.services.client.valid_cidr_range(&ip) { + return Err!(Request(Forbidden("Requesting from this address is forbidden"))); + } } } + + let Some(content_type) = response.headers().get(reqwest::header::CONTENT_TYPE) else { + return Err!(Request(Unknown("Unknown or invalid Content-Type header"))); + }; + + let content_type = content_type + .to_str() + .map_err(|e| err!(Request(Unknown("Unknown or invalid Content-Type header: {e}"))))?; + + let data = match content_type { + | html if html.starts_with("text/html") => self.download_html(url.as_str()).await?, + | img if img.starts_with("image/") => self.download_image(url.as_str(), None).await?, + | video if video.starts_with("video/") => + self.download_video(url.as_str(), None).await?, + | audio if audio.starts_with("audio/") => + self.download_audio(url.as_str(), None).await?, + | _ => return Err!(Request(Unknown("Unsupported Content-Type"))), + }; + + self.set_url_preview(url.as_str(), &data).await?; + + Ok(data) } - false + #[cfg(feature = "url_preview")] + + pub async fn download_image( + &self, + url: &str, + preview_data: Option, + ) -> Result { + use conduwuit::utils::random_string; + use image::ImageReader; + + use crate::media::mxc::Mxc; + + let mut preview_data = preview_data.unwrap_or_default(); + + let image = self + .services + .client + .url_preview + .get(url) + .send() + .await? + .limit_read( + self.services + .server + .config + .max_request_size + .try_into() + .expect("u64 should fit in usize"), + ) + .await?; + + let mxc = Mxc { + server_name: self.services.globals.server_name(), + media_id: &random_string(super::MXC_LENGTH), + }; + + self.create(&mxc, None, None, None, &image).await?; + + preview_data.image = Some(mxc.to_string()); + if preview_data.image_height.is_none() || preview_data.image_width.is_none() { + let cursor = std::io::Cursor::new(&image); + let (width, height) = match ImageReader::new(cursor).with_guessed_format() { + | Err(_) => (None, None), + | Ok(reader) => match reader.into_dimensions() { + | Err(_) => (None, None), + | Ok((width, height)) => (Some(width), Some(height)), + }, + }; + + preview_data.image_width = width; + preview_data.image_height = height; + } + + Ok(preview_data) + } + + #[cfg(feature = "url_preview")] + + pub async fn download_video( + &self, + url: &str, + preview_data: Option, + ) -> Result { + let mut preview_data = preview_data.unwrap_or_default(); + + if self.services.globals.url_preview_allow_audio_video() { + let (url, size) = self.download_media(url).await?; + preview_data.video = Some(url.to_string()); + preview_data.video_size = Some(size); + } + + Ok(preview_data) + } + + #[cfg(feature = "url_preview")] + + pub async fn download_audio( + &self, + url: &str, + preview_data: Option, + ) -> Result { + let mut preview_data = preview_data.unwrap_or_default(); + + if self.services.globals.url_preview_allow_audio_video() { + let (url, size) = self.download_media(url).await?; + preview_data.audio = Some(url.to_string()); + preview_data.audio_size = Some(size); + } + + Ok(preview_data) + } + + #[cfg(feature = "url_preview")] + + pub async fn download_media(&self, url: &str) -> Result<(OwnedMxcUri, usize)> { + use conduwuit::utils::random_string; + use http::header::CONTENT_TYPE; + + let response = self.services.client.url_preview.get(url).send().await?; + let content_type = response.headers().get(CONTENT_TYPE).cloned(); + let media = response + .limit_read( + self.services + .server + .config + .max_request_size + .try_into() + .expect("u64 should fit in usize"), + ) + .await?; + + let mxc = Mxc { + server_name: self.services.globals.server_name(), + media_id: &random_string(super::MXC_LENGTH), + }; + + let content_type = content_type.and_then(|v| v.to_str().map(ToOwned::to_owned).ok()); + self.create(&mxc, None, None, content_type.as_deref(), &media) + .await?; + + Ok((OwnedMxcUri::from(mxc.to_string()), media.len())) + } + + #[cfg(not(feature = "url_preview"))] + pub async fn download_image( + &self, + _url: &str, + _preview_data: Option, + ) -> Result { + Err!(FeatureDisabled("url_preview")) + } + + #[cfg(not(feature = "url_preview"))] + pub async fn download_video( + &self, + _url: &str, + _preview_data: Option, + ) -> Result { + Err!(FeatureDisabled("url_preview")) + } + + #[cfg(not(feature = "url_preview"))] + pub async fn download_audio( + &self, + _url: &str, + _preview_data: Option, + ) -> Result { + Err!(FeatureDisabled("url_preview")) + } + + #[cfg(not(feature = "url_preview"))] + pub async fn download_media(&self, _url: &str) -> Result { + Err!(FeatureDisabled("url_preview")) + } + + #[cfg(feature = "url_preview")] + + async fn download_html(&self, url: &str) -> Result { + use webpage::HTML; + + let client = &self.services.client.url_preview; + let body = client + .get(url) + .send() + .await? + .limit_read_text( + self.services + .server + .config + .max_request_size + .try_into() + .expect("u64 should fit in usize"), + ) + .await?; + let Ok(html) = HTML::from_string(body.clone(), Some(url.to_owned())) else { + return Err!(Request(Unknown("Failed to parse HTML"))); + }; + + let mut preview_data = UrlPreviewData::default(); + + if let Some(obj) = html.opengraph.images.first() { + preview_data = self.download_image(&obj.url, Some(preview_data)).await?; + } + + if let Some(obj) = html.opengraph.videos.first() { + preview_data = self.download_video(&obj.url, Some(preview_data)).await?; + preview_data.video_width = obj.properties.get("width").and_then(|v| v.parse().ok()); + preview_data.video_height = obj.properties.get("height").and_then(|v| v.parse().ok()); + } + + if let Some(obj) = html.opengraph.audios.first() { + preview_data = self.download_audio(&obj.url, Some(preview_data)).await?; + } + + let props = html.opengraph.properties; + + /* use OpenGraph title/description, but fall back to HTML if not available */ + preview_data.title = props.get("title").cloned().or(html.title); + preview_data.description = props.get("description").cloned().or(html.description); + + Ok(preview_data) + } + + #[cfg(not(feature = "url_preview"))] + async fn download_html(&self, _url: &str) -> Result { + Err!(FeatureDisabled("url_preview")) + } + + pub fn url_preview_allowed(&self, url: &Url) -> bool { + if ["http", "https"] + .iter() + .all(|&scheme| scheme != url.scheme().to_lowercase()) + { + debug!("Ignoring non-HTTP/HTTPS URL to preview: {}", url); + return false; + } + + let host = match url.host_str() { + | None => { + debug!("Ignoring URL preview for a URL that does not have a host (?): {}", url); + return false; + }, + | Some(h) => h.to_owned(), + }; + + let allowlist_domain_contains = self + .services + .globals + .url_preview_domain_contains_allowlist(); + let allowlist_domain_explicit = self + .services + .globals + .url_preview_domain_explicit_allowlist(); + let denylist_domain_explicit = + self.services.globals.url_preview_domain_explicit_denylist(); + let allowlist_url_contains = self.services.globals.url_preview_url_contains_allowlist(); + + if allowlist_domain_contains.contains(&"*".to_owned()) + || allowlist_domain_explicit.contains(&"*".to_owned()) + || allowlist_url_contains.contains(&"*".to_owned()) + { + debug!( + "Config key contains * which is allowing all URL previews. Allowing URL {}", + url + ); + return true; + } + + if !host.is_empty() { + if denylist_domain_explicit.contains(&host) { + debug!( + "Host {} is not allowed by url_preview_domain_explicit_denylist (check 1/4)", + &host + ); + return false; + } + + if allowlist_domain_explicit.contains(&host) { + debug!( + "Host {} is allowed by url_preview_domain_explicit_allowlist (check 2/4)", + &host + ); + return true; + } + + if allowlist_domain_contains + .iter() + .any(|domain_s| domain_s.contains(&host.clone())) + { + debug!( + "Host {} is allowed by url_preview_domain_contains_allowlist (check 3/4)", + &host + ); + return true; + } + + if allowlist_url_contains + .iter() + .any(|url_s| url.to_string().contains(url_s)) + { + debug!( + "URL {} is allowed by url_preview_url_contains_allowlist (check 4/4)", + &host + ); + return true; + } + + // check root domain if available and if user has root domain checks + if self.services.globals.url_preview_check_root_domain() { + debug!("Checking root domain"); + match host.split_once('.') { + | None => return false, + | Some((_, root_domain)) => { + if denylist_domain_explicit.contains(&root_domain.to_owned()) { + debug!( + "Root domain {} is not allowed by \ + url_preview_domain_explicit_denylist (check 1/3)", + &root_domain + ); + return false; + } + + if allowlist_domain_explicit.contains(&root_domain.to_owned()) { + debug!( + "Root domain {} is allowed by \ + url_preview_domain_explicit_allowlist (check 2/3)", + &root_domain + ); + return true; + } + + if allowlist_domain_contains + .iter() + .any(|domain_s| domain_s.contains(&root_domain.to_owned())) + { + debug!( + "Root domain {} is allowed by \ + url_preview_domain_contains_allowlist (check 3/3)", + &root_domain + ); + return true; + } + }, + } + } + } + + false + } } diff --git a/src/service/media/remote.rs b/src/service/media/remote.rs index c5862a576..4a1ca828b 100644 --- a/src/service/media/remote.rs +++ b/src/service/media/remote.rs @@ -1,7 +1,7 @@ use std::{fmt::Debug, time::Duration}; use conduwuit::{ - Err, Error, Result, debug_warn, err, implement, + Err, Error, Result, debug_warn, err, utils::{content_disposition::make_content_disposition, response::LimitReadExt}, }; use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue}; @@ -24,436 +24,424 @@ use super::{Dim, FileMeta}; use crate::{federation::FederationPathBuilderInput, media::mxc::Mxc}; -#[implement(super::Service)] -pub async fn fetch_remote_thumbnail( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - server: Option<&ServerName>, - timeout_ms: Duration, - dim: &Dim, -) -> Result { - self.check_fetch_authorized(mxc)?; +impl super::Service { + pub async fn fetch_remote_thumbnail( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + server: Option<&ServerName>, + timeout_ms: Duration, + dim: &Dim, + ) -> Result { + self.check_fetch_authorized(mxc)?; - let result = self - .fetch_thumbnail_authenticated(mxc, user, server, timeout_ms, dim) - .await; - - if let Err(Error::Request(NotFound, ..)) = &result { - return self - .fetch_thumbnail_unauthenticated(mxc, user, server, timeout_ms, dim) + let result = self + .fetch_thumbnail_authenticated(mxc, user, server, timeout_ms, dim) .await; + + if let Err(Error::Request(NotFound, ..)) = &result { + return self + .fetch_thumbnail_unauthenticated(mxc, user, server, timeout_ms, dim) + .await; + } + + result } - result -} + pub async fn fetch_remote_content( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + server: Option<&ServerName>, + timeout_ms: Duration, + ) -> Result { + self.check_fetch_authorized(mxc)?; -#[implement(super::Service)] -pub async fn fetch_remote_content( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - server: Option<&ServerName>, - timeout_ms: Duration, -) -> Result { - self.check_fetch_authorized(mxc)?; + let result = self + .fetch_content_authenticated(mxc, user, server, timeout_ms) + .await + .inspect_err(|error| { + debug_warn!( + %mxc, + ?user, + ?server, + ?error, + "Authenticated fetch of remote content failed" + ); + }); - let result = self - .fetch_content_authenticated(mxc, user, server, timeout_ms) - .await - .inspect_err(|error| { - debug_warn!( - %mxc, - ?user, - ?server, - ?error, - "Authenticated fetch of remote content failed" - ); - }); + if let Err(Error::Request(Unrecognized, ..)) = &result { + return self + .fetch_content_unauthenticated(mxc, user, server, timeout_ms) + .await; + } - if let Err(Error::Request(Unrecognized, ..)) = &result { - return self - .fetch_content_unauthenticated(mxc, user, server, timeout_ms) - .await; + result } - result -} + async fn fetch_thumbnail_authenticated( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + server: Option<&ServerName>, + timeout_ms: Duration, + dim: &Dim, + ) -> Result { + use federation::authenticated_media::get_content_thumbnail::v1::{Request, Response}; -#[implement(super::Service)] -async fn fetch_thumbnail_authenticated( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - server: Option<&ServerName>, - timeout_ms: Duration, - dim: &Dim, -) -> Result { - use federation::authenticated_media::get_content_thumbnail::v1::{Request, Response}; + let mut request = Request::new(mxc.media_id.into(), dim.width.into(), dim.height.into()); + request.method = Some(dim.method.clone()); + request.animated = Some(true); + request.timeout_ms = timeout_ms; - let mut request = Request::new(mxc.media_id.into(), dim.width.into(), dim.height.into()); - request.method = Some(dim.method.clone()); - request.animated = Some(true); - request.timeout_ms = timeout_ms; + let Response { content, .. } = self.federation_request(mxc, server, request).await?; - let Response { content, .. } = self.federation_request(mxc, server, request).await?; - - match content { - | FileOrLocation::File(content) => - self.handle_thumbnail_file(mxc, user, dim, content).await, - | FileOrLocation::Location(location) => self.handle_location(mxc, user, &location).await, - | _ => Err!("Unknown content in response"), + match content { + | FileOrLocation::File(content) => + self.handle_thumbnail_file(mxc, user, dim, content).await, + | FileOrLocation::Location(location) => + self.handle_location(mxc, user, &location).await, + | _ => Err!("Unknown content in response"), + } } -} -#[implement(super::Service)] -async fn fetch_content_authenticated( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - server: Option<&ServerName>, - timeout_ms: Duration, -) -> Result { - use federation::authenticated_media::get_content::v1::{Request, Response}; + async fn fetch_content_authenticated( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + server: Option<&ServerName>, + timeout_ms: Duration, + ) -> Result { + use federation::authenticated_media::get_content::v1::{Request, Response}; - let mut request = Request::new(mxc.media_id.into()); - request.timeout_ms = timeout_ms; + let mut request = Request::new(mxc.media_id.into()); + request.timeout_ms = timeout_ms; - let Response { content, .. } = self.federation_request(mxc, server, request).await?; + let Response { content, .. } = self.federation_request(mxc, server, request).await?; - match content { - | FileOrLocation::File(content) => self.handle_content_file(mxc, user, content).await, - | FileOrLocation::Location(location) => self.handle_location(mxc, user, &location).await, - | _ => Err!("Unknown content in response"), + match content { + | FileOrLocation::File(content) => self.handle_content_file(mxc, user, content).await, + | FileOrLocation::Location(location) => + self.handle_location(mxc, user, &location).await, + | _ => Err!("Unknown content in response"), + } } -} -#[allow(deprecated)] -#[implement(super::Service)] -async fn fetch_thumbnail_unauthenticated( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - server: Option<&ServerName>, - timeout_ms: Duration, - dim: &Dim, -) -> Result { - use media::get_content_thumbnail::v3::{Request, Response}; + #[allow(deprecated)] + async fn fetch_thumbnail_unauthenticated( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + server: Option<&ServerName>, + timeout_ms: Duration, + dim: &Dim, + ) -> Result { + use media::get_content_thumbnail::v3::{Request, Response}; - let mut request = Request::new( - mxc.media_id.into(), - mxc.server_name.into(), - dim.width.into(), - dim.height.into(), - ); - request.allow_redirect = true; - request.allow_remote = true; - request.animated = Some(true); - request.method = Some(dim.method.clone()); - request.timeout_ms = timeout_ms; + let mut request = Request::new( + mxc.media_id.into(), + mxc.server_name.into(), + dim.width.into(), + dim.height.into(), + ); + request.allow_redirect = true; + request.allow_remote = true; + request.animated = Some(true); + request.method = Some(dim.method.clone()); + request.timeout_ms = timeout_ms; - let Response { - file, content_type, content_disposition, .. - } = self - .federation_request_legacy_media(mxc, server, request) - .await?; + let Response { + file, content_type, content_disposition, .. + } = self + .federation_request_legacy_media(mxc, server, request) + .await?; - let content = Content::new(file, content_type.unwrap(), content_disposition.unwrap()); + let content = Content::new(file, content_type.unwrap(), content_disposition.unwrap()); - self.handle_thumbnail_file(mxc, user, dim, content).await -} + self.handle_thumbnail_file(mxc, user, dim, content).await + } -#[allow(deprecated)] -#[implement(super::Service)] -async fn fetch_content_unauthenticated( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - server: Option<&ServerName>, - timeout_ms: Duration, -) -> Result { - use media::get_content::v3::{Request, Response}; + #[allow(deprecated)] + async fn fetch_content_unauthenticated( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + server: Option<&ServerName>, + timeout_ms: Duration, + ) -> Result { + use media::get_content::v3::{Request, Response}; - let mut request = Request::new(mxc.media_id.into(), mxc.server_name.into()); - request.allow_remote = true; - request.allow_redirect = true; - request.timeout_ms = timeout_ms; + let mut request = Request::new(mxc.media_id.into(), mxc.server_name.into()); + request.allow_remote = true; + request.allow_redirect = true; + request.timeout_ms = timeout_ms; - let Response { - file, content_type, content_disposition, .. - } = self - .federation_request_legacy_media(mxc, server, request) - .await?; + let Response { + file, content_type, content_disposition, .. + } = self + .federation_request_legacy_media(mxc, server, request) + .await?; - let content = Content::new(file, content_type.unwrap(), content_disposition.unwrap()); + let content = Content::new(file, content_type.unwrap(), content_disposition.unwrap()); - self.handle_content_file(mxc, user, content).await -} + self.handle_content_file(mxc, user, content).await + } -#[implement(super::Service)] -async fn handle_thumbnail_file( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - dim: &Dim, - content: Content, -) -> Result { - let content_disposition = make_content_disposition( - content.content_disposition.as_ref(), - content.content_type.as_deref(), - None, - ); + async fn handle_thumbnail_file( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + dim: &Dim, + content: Content, + ) -> Result { + let content_disposition = make_content_disposition( + content.content_disposition.as_ref(), + content.content_type.as_deref(), + None, + ); - self.upload_thumbnail( - mxc, - user, - Some(&content_disposition), - content.content_type.as_deref(), - dim, - &content.file, - ) - .await - .map(|()| FileMeta { - content: Some(content.file), - content_type: content.content_type, - content_disposition: Some(content_disposition), - }) -} - -#[implement(super::Service)] -async fn handle_content_file( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - content: Content, -) -> Result { - let content_disposition = make_content_disposition( - content.content_disposition.as_ref(), - content.content_type.as_deref(), - None, - ); - - self.create( - mxc, - user, - Some(&content_disposition), - content.content_type.as_deref(), - &content.file, - ) - .await - .map(|()| FileMeta { - content: Some(content.file), - content_type: content.content_type, - content_disposition: Some(content_disposition), - }) -} - -#[implement(super::Service)] -async fn handle_location( - &self, - mxc: &Mxc<'_>, - user: Option<&UserId>, - location: &str, -) -> Result { - self.location_request(location).await.map_err(|error| { - err!(Request(NotFound( - debug_warn!(%mxc, user = user.map(tracing::field::display), ?location, ?error, "Fetching media from location failed") - ))) - }) -} - -#[implement(super::Service)] -async fn location_request(&self, location: &str) -> Result { - let response = self - .services - .client - .extern_media - .get(location) - .send() - .await?; - - let content_type = response - .headers() - .get(CONTENT_TYPE) - .map(HeaderValue::to_str) - .and_then(Result::ok) - .map(str::to_owned); - - let content_disposition = response - .headers() - .get(CONTENT_DISPOSITION) - .map(HeaderValue::as_bytes) - .map(TryFrom::try_from) - .and_then(Result::ok); - - response - .limit_read( - self.services - .server - .config - .max_request_size - .try_into() - .expect("u64 should fit in usize"), + self.upload_thumbnail( + mxc, + user, + Some(&content_disposition), + content.content_type.as_deref(), + dim, + &content.file, ) .await - .map(|content| FileMeta { - content: Some(content), - content_type: content_type.clone(), - content_disposition: Some(make_content_disposition( - content_disposition.as_ref(), - content_type.as_deref(), - None, - )), + .map(|()| FileMeta { + content: Some(content.file), + content_type: content.content_type, + content_disposition: Some(content_disposition), }) -} - -#[implement(super::Service)] -async fn federation_request<'i, Request>( - &self, - mxc: &Mxc<'_>, - server: Option<&ServerName>, - request: Request, -) -> Result -where - Request: OutgoingRequest< - Authentication = ServerSignatures, - PathBuilder: PathBuilder: FederationPathBuilderInput>, - > + Debug - + Send, -{ - self.services - .sending - .send_federation_request(server.unwrap_or(mxc.server_name), request) - .await -} - -#[implement(super::Service)] -async fn federation_request_legacy_media<'i, Request>( - &self, - mxc: &Mxc<'_>, - server: Option<&ServerName>, - request: Request, -) -> Result -where - Request: OutgoingRequest< - Authentication = NoAccessToken, - PathBuilder: PathBuilder: FederationPathBuilderInput>, - > + Debug - + Send, -{ - self.services - .sending - .send_legacy_media_request(server.unwrap_or(mxc.server_name), request) - .await -} - -#[implement(super::Service)] -#[allow(deprecated)] -pub async fn fetch_remote_thumbnail_legacy( - &self, - body: &media::get_content_thumbnail::v3::Request, -) -> Result { - let mxc = Mxc { - server_name: &body.server_name, - media_id: &body.media_id, - }; - - let mut request = media::get_content_thumbnail::v3::Request::new( - body.media_id.clone(), - body.server_name.clone(), - body.width, - body.height, - ); - request.method.clone_from(&body.method); - request.allow_remote = body.allow_remote; - request.allow_redirect = body.allow_redirect; - request.animated = body.animated; - request.timeout_ms = body.timeout_ms; - - self.check_legacy_freeze()?; - self.check_fetch_authorized(&mxc)?; - let response = self - .services - .sending - .send_legacy_media_request(mxc.server_name, request) - .await?; - - let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?; - self.upload_thumbnail( - &mxc, - None, - None, - response.content_type.as_deref(), - &dim, - &response.file, - ) - .await?; - - Ok(response) -} - -#[implement(super::Service)] -#[allow(deprecated)] -pub async fn fetch_remote_content_legacy( - &self, - mxc: &Mxc<'_>, - allow_redirect: bool, - timeout_ms: Duration, -) -> Result { - let mut request = - media::get_content::v3::Request::new(mxc.media_id.into(), mxc.server_name.into()); - request.allow_remote = true; - request.allow_redirect = allow_redirect; - request.timeout_ms = timeout_ms; - - self.check_legacy_freeze()?; - self.check_fetch_authorized(mxc)?; - let response = self - .services - .sending - .send_legacy_media_request(mxc.server_name, request) - .await?; - - let content_disposition = make_content_disposition( - response.content_disposition.as_ref(), - response.content_type.as_deref(), - None, - ); - - self.create( - mxc, - None, - Some(&content_disposition), - response.content_type.as_deref(), - &response.file, - ) - .await?; - - Ok(response) -} - -#[implement(super::Service)] -fn check_fetch_authorized(&self, mxc: &Mxc<'_>) -> Result<()> { - if self - .services - .moderation - .is_remote_server_media_downloads_forbidden(mxc.server_name) - { - // we'll lie to the client and say the blocked server's media was not found and - // log. the client has no way of telling anyways so this is a security bonus. - debug_warn!(%mxc, "Received request for media on blocklisted server"); - return Err!(Request(NotFound("Media not found."))); } - Ok(()) -} + async fn handle_content_file( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + content: Content, + ) -> Result { + let content_disposition = make_content_disposition( + content.content_disposition.as_ref(), + content.content_type.as_deref(), + None, + ); -#[implement(super::Service)] -fn check_legacy_freeze(&self) -> Result<()> { - self.services - .server - .config - .freeze_legacy_media - .then_some(()) - .ok_or(err!(Request(NotFound("Remote media is frozen.")))) + self.create( + mxc, + user, + Some(&content_disposition), + content.content_type.as_deref(), + &content.file, + ) + .await + .map(|()| FileMeta { + content: Some(content.file), + content_type: content.content_type, + content_disposition: Some(content_disposition), + }) + } + + async fn handle_location( + &self, + mxc: &Mxc<'_>, + user: Option<&UserId>, + location: &str, + ) -> Result { + self.location_request(location).await.map_err(|error| { + err!(Request(NotFound( + debug_warn!(%mxc, user = user.map(tracing::field::display), ?location, ?error, "Fetching media from location failed") + ))) + }) + } + + async fn location_request(&self, location: &str) -> Result { + let response = self + .services + .client + .extern_media + .get(location) + .send() + .await?; + + let content_type = response + .headers() + .get(CONTENT_TYPE) + .map(HeaderValue::to_str) + .and_then(Result::ok) + .map(str::to_owned); + + let content_disposition = response + .headers() + .get(CONTENT_DISPOSITION) + .map(HeaderValue::as_bytes) + .map(TryFrom::try_from) + .and_then(Result::ok); + + response + .limit_read( + self.services + .server + .config + .max_request_size + .try_into() + .expect("u64 should fit in usize"), + ) + .await + .map(|content| FileMeta { + content: Some(content), + content_type: content_type.clone(), + content_disposition: Some(make_content_disposition( + content_disposition.as_ref(), + content_type.as_deref(), + None, + )), + }) + } + + async fn federation_request<'i, Request>( + &self, + mxc: &Mxc<'_>, + server: Option<&ServerName>, + request: Request, + ) -> Result + where + Request: OutgoingRequest< + Authentication = ServerSignatures, + PathBuilder: PathBuilder: FederationPathBuilderInput>, + > + Debug + + Send, + { + self.services + .sending + .send_federation_request(server.unwrap_or(mxc.server_name), request) + .await + } + + async fn federation_request_legacy_media<'i, Request>( + &self, + mxc: &Mxc<'_>, + server: Option<&ServerName>, + request: Request, + ) -> Result + where + Request: OutgoingRequest< + Authentication = NoAccessToken, + PathBuilder: PathBuilder: FederationPathBuilderInput>, + > + Debug + + Send, + { + self.services + .sending + .send_legacy_media_request(server.unwrap_or(mxc.server_name), request) + .await + } + + #[allow(deprecated)] + pub async fn fetch_remote_thumbnail_legacy( + &self, + body: &media::get_content_thumbnail::v3::Request, + ) -> Result { + let mxc = Mxc { + server_name: &body.server_name, + media_id: &body.media_id, + }; + + let mut request = media::get_content_thumbnail::v3::Request::new( + body.media_id.clone(), + body.server_name.clone(), + body.width, + body.height, + ); + request.method.clone_from(&body.method); + request.allow_remote = body.allow_remote; + request.allow_redirect = body.allow_redirect; + request.animated = body.animated; + request.timeout_ms = body.timeout_ms; + + self.check_legacy_freeze()?; + self.check_fetch_authorized(&mxc)?; + let response = self + .services + .sending + .send_legacy_media_request(mxc.server_name, request) + .await?; + + let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?; + self.upload_thumbnail( + &mxc, + None, + None, + response.content_type.as_deref(), + &dim, + &response.file, + ) + .await?; + + Ok(response) + } + + #[allow(deprecated)] + pub async fn fetch_remote_content_legacy( + &self, + mxc: &Mxc<'_>, + allow_redirect: bool, + timeout_ms: Duration, + ) -> Result { + let mut request = + media::get_content::v3::Request::new(mxc.media_id.into(), mxc.server_name.into()); + request.allow_remote = true; + request.allow_redirect = allow_redirect; + request.timeout_ms = timeout_ms; + + self.check_legacy_freeze()?; + self.check_fetch_authorized(mxc)?; + let response = self + .services + .sending + .send_legacy_media_request(mxc.server_name, request) + .await?; + + let content_disposition = make_content_disposition( + response.content_disposition.as_ref(), + response.content_type.as_deref(), + None, + ); + + self.create( + mxc, + None, + Some(&content_disposition), + response.content_type.as_deref(), + &response.file, + ) + .await?; + + Ok(response) + } + + fn check_fetch_authorized(&self, mxc: &Mxc<'_>) -> Result<()> { + if self + .services + .moderation + .is_remote_server_media_downloads_forbidden(mxc.server_name) + { + // we'll lie to the client and say the blocked server's media was not found and + // log. the client has no way of telling anyways so this is a security bonus. + debug_warn!(%mxc, "Received request for media on blocklisted server"); + return Err!(Request(NotFound("Media not found."))); + } + + Ok(()) + } + + fn check_legacy_freeze(&self) -> Result<()> { + self.services + .server + .config + .freeze_legacy_media + .then_some(()) + .ok_or(err!(Request(NotFound("Remote media is frozen.")))) + } } diff --git a/src/service/media/thumbnail.rs b/src/service/media/thumbnail.rs index 162ac0ba0..8a23bb086 100644 --- a/src/service/media/thumbnail.rs +++ b/src/service/media/thumbnail.rs @@ -7,7 +7,7 @@ use std::{cmp, num::Saturating as Sat}; -use conduwuit::{Result, checked, err, implement}; +use conduwuit::{Result, checked, err}; use ruma::{UInt, UserId, http_headers::ContentDisposition, media::Method}; use tokio::{ fs, @@ -74,80 +74,77 @@ pub async fn get_thumbnail(&self, mxc: &Mxc<'_>, dim: &Dim) -> Result Result> { - let mut content = Vec::new(); - let path = self.get_media_file(&data.key); - fs::File::open(path) - .await? - .read_to_end(&mut content) - .await?; + /// Using saved thumbnail + #[tracing::instrument(name = "saved", level = "debug", skip(self, data))] + async fn get_thumbnail_saved(&self, data: Metadata) -> Result> { + let mut content = Vec::new(); + let path = self.get_media_file(&data.key); + fs::File::open(path) + .await? + .read_to_end(&mut content) + .await?; - Ok(Some(into_filemeta(data, content))) -} - -/// Generate a thumbnail -#[cfg(feature = "media_thumbnail")] -#[implement(super::Service)] -#[tracing::instrument(name = "generate", level = "debug", skip(self, data))] -async fn get_thumbnail_generate( - &self, - mxc: &Mxc<'_>, - dim: &Dim, - data: Metadata, -) -> Result> { - let mut content = Vec::new(); - let path = self.get_media_file(&data.key); - fs::File::open(path) - .await? - .read_to_end(&mut content) - .await?; - - let Ok(image) = image::load_from_memory(&content) else { - // Couldn't parse file to generate thumbnail, send original - return Ok(Some(into_filemeta(data, content))); - }; - - if dim.width > image.width() || dim.height > image.height() { - return Ok(Some(into_filemeta(data, content))); + Ok(Some(into_filemeta(data, content))) } - let mut thumbnail_bytes = Vec::new(); - let thumbnail = thumbnail_generate(&image, dim)?; - let mut cursor = std::io::Cursor::new(&mut thumbnail_bytes); - thumbnail - .write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|error| err!(error!(%error, "Error writing PNG thumbnail.")))?; + /// Generate a thumbnail + #[cfg(feature = "media_thumbnail")] + #[tracing::instrument(name = "generate", level = "debug", skip(self, data))] + async fn get_thumbnail_generate( + &self, + mxc: &Mxc<'_>, + dim: &Dim, + data: Metadata, + ) -> Result> { + let mut content = Vec::new(); + let path = self.get_media_file(&data.key); + fs::File::open(path) + .await? + .read_to_end(&mut content) + .await?; - // Save thumbnail in database so we don't have to generate it again next time - let thumbnail_key = self.db.create_file_metadata( - mxc, - None, - dim, - data.content_disposition.as_ref(), - data.content_type.as_deref(), - )?; + let Ok(image) = image::load_from_memory(&content) else { + // Couldn't parse file to generate thumbnail, send original + return Ok(Some(into_filemeta(data, content))); + }; - let mut f = self.create_media_file(&thumbnail_key).await?; - f.write_all(&thumbnail_bytes).await?; + if dim.width > image.width() || dim.height > image.height() { + return Ok(Some(into_filemeta(data, content))); + } - Ok(Some(into_filemeta(data, thumbnail_bytes))) -} + let mut thumbnail_bytes = Vec::new(); + let thumbnail = thumbnail_generate(&image, dim)?; + let mut cursor = std::io::Cursor::new(&mut thumbnail_bytes); + thumbnail + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| err!(error!(%error, "Error writing PNG thumbnail.")))?; -#[cfg(not(feature = "media_thumbnail"))] -#[implement(super::Service)] -#[tracing::instrument(name = "fallback", level = "debug", skip_all)] -async fn get_thumbnail_generate( - &self, - _mxc: &Mxc<'_>, - _dim: &Dim, - data: Metadata, -) -> Result> { - self.get_thumbnail_saved(data).await + // Save thumbnail in database so we don't have to generate it again next time + let thumbnail_key = self.db.create_file_metadata( + mxc, + None, + dim, + data.content_disposition.as_ref(), + data.content_type.as_deref(), + )?; + + let mut f = self.create_media_file(&thumbnail_key).await?; + f.write_all(&thumbnail_bytes).await?; + + Ok(Some(into_filemeta(data, thumbnail_bytes))) + } + + #[cfg(not(feature = "media_thumbnail"))] + #[tracing::instrument(name = "fallback", level = "debug", skip_all)] + async fn get_thumbnail_generate( + &self, + _mxc: &Mxc<'_>, + _dim: &Dim, + data: Metadata, + ) -> Result> { + self.get_thumbnail_saved(data).await + } } #[cfg(feature = "media_thumbnail")]