style: Refactor media service

This commit is contained in:
timedout
2026-06-27 18:36:44 +01:00
parent 0c9230f35e
commit b1c5b3d000
3 changed files with 832 additions and 848 deletions
+383 -384
View File
@@ -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<usize>,
}
#[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<UrlPreviewData> {
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<UrlPreviewData> {
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<UrlPreviewData> {
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<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_video(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_audio(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_media(&self, _url: &str) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(feature = "url_preview")]
#[implement(Service)]
async fn download_html(&self, url: &str) -> Result<UrlPreviewData> {
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<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
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<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
pub async fn download_video(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
pub async fn download_audio(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
pub async fn download_media(&self, _url: &str) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(feature = "url_preview")]
async fn download_html(&self, url: &str) -> Result<UrlPreviewData> {
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<UrlPreviewData> {
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
}
}
+385 -397
View File
@@ -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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<Request::IncomingResponse>
where
Request: OutgoingRequest<
Authentication = ServerSignatures,
PathBuilder: PathBuilder<Input<'i>: 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<Request::IncomingResponse>
where
Request: OutgoingRequest<
Authentication = NoAccessToken,
PathBuilder: PathBuilder<Input<'i>: 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<media::get_content_thumbnail::v3::Response> {
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<media::get_content::v3::Response, Error> {
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<FileMeta> {
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<FileMeta> {
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<FileMeta> {
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<Request::IncomingResponse>
where
Request: OutgoingRequest<
Authentication = ServerSignatures,
PathBuilder: PathBuilder<Input<'i>: 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<Request::IncomingResponse>
where
Request: OutgoingRequest<
Authentication = NoAccessToken,
PathBuilder: PathBuilder<Input<'i>: 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<media::get_content_thumbnail::v3::Response> {
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<media::get_content::v3::Response, Error> {
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."))))
}
}
+64 -67
View File
@@ -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<Option<Fil
},
}
}
}
/// Using saved thumbnail
#[implement(super::Service)]
#[tracing::instrument(name = "saved", level = "debug", skip(self, data))]
async fn get_thumbnail_saved(&self, data: Metadata) -> Result<Option<FileMeta>> {
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<Option<FileMeta>> {
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<Option<FileMeta>> {
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<Option<FileMeta>> {
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<Option<FileMeta>> {
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<Option<FileMeta>> {
self.get_thumbnail_saved(data).await
}
}
#[cfg(feature = "media_thumbnail")]