mirror of
https://forgejo.ellis.link/continuwuation/continuwuity/
synced 2026-08-28 05:54:12 +00:00
style: Refactor media service
This commit is contained in:
+383
-384
@@ -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
@@ -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."))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user