From cc1889d135763edc44b9eb28c991bbeee8badbc0 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Wed, 18 Dec 2024 21:29:30 +0000 Subject: [PATCH] Add default-enabled feature-gates for url_preview and media_thumbnail Signed-off-by: Jason Volk --- Cargo.lock | 1 - src/core/Cargo.toml | 1 - src/core/error/mod.rs | 2 - src/main/Cargo.toml | 8 ++ src/service/Cargo.toml | 13 ++- src/service/media/data.rs | 37 +++----- src/service/media/preview.rs | 93 ++++++++++++-------- src/service/media/thumbnail.rs | 150 ++++++++++++++++++++------------- 8 files changed, 182 insertions(+), 123 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f900a11..d25197e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,7 +723,6 @@ dependencies = [ "hardened_malloc-rs", "http", "http-body-util", - "image", "ipaddress", "itertools 0.13.0", "libc", diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml index dd8f634a..4a9cc462 100644 --- a/src/core/Cargo.toml +++ b/src/core/Cargo.toml @@ -71,7 +71,6 @@ figment.workspace = true futures.workspace = true http-body-util.workspace = true http.workspace = true -image.workspace = true ipaddress.workspace = true itertools.workspace = true libc.workspace = true diff --git a/src/core/error/mod.rs b/src/core/error/mod.rs index f1e3b924..ffa829d9 100644 --- a/src/core/error/mod.rs +++ b/src/core/error/mod.rs @@ -48,8 +48,6 @@ pub enum Error { Http(#[from] http::Error), #[error(transparent)] HttpHeader(#[from] http::header::InvalidHeaderValue), - #[error("Image error: {0}")] - Image(#[from] image::error::ImageError), #[error("Join error: {0}")] JoinError(#[from] tokio::task::JoinError), #[error(transparent)] diff --git a/src/main/Cargo.toml b/src/main/Cargo.toml index a6421b34..38eb7188 100644 --- a/src/main/Cargo.toml +++ b/src/main/Cargo.toml @@ -41,8 +41,10 @@ default = [ "gzip_compression", "io_uring", "jemalloc", + "media_thumbnail", "release_max_log_level", "systemd", + "url_preview", "zstd_compression", ] @@ -83,6 +85,9 @@ jemalloc_prof = [ jemalloc_stats = [ "conduwuit-core/jemalloc_stats", ] +media_thumbnail = [ + "conduwuit-service/media_thumbnail", +] perf_measurements = [ "dep:opentelemetry", "dep:tracing-flame", @@ -121,6 +126,9 @@ tokio_console = [ "dep:console-subscriber", "tokio/tracing", ] +url_preview = [ + "conduwuit-service/url_preview", +] zstd_compression = [ "conduwuit-api/zstd_compression", "conduwuit-core/zstd_compression", diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index 26f737ee..4708ff4e 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -28,8 +28,8 @@ element_hacks = [] gzip_compression = [ "reqwest/gzip", ] -zstd_compression = [ - "reqwest/zstd", +media_thumbnail = [ + "dep:image", ] release_max_log_level = [ "tracing/max_level_trace", @@ -37,6 +37,13 @@ release_max_log_level = [ "log/max_level_trace", "log/release_max_level_info", ] +url_preview = [ + "dep:image", + "dep:webpage", +] +zstd_compression = [ + "reqwest/zstd", +] [dependencies] arrayvec.workspace = true @@ -51,6 +58,7 @@ futures.workspace = true hickory-resolver.workspace = true http.workspace = true image.workspace = true +image.optional = true ipaddress.workspace = true itertools.workspace = true jsonwebtoken.workspace = true @@ -73,6 +81,7 @@ tokio.workspace = true tracing.workspace = true url.workspace = true webpage.workspace = true +webpage.optional = true [lints] workspace = true diff --git a/src/service/media/data.rs b/src/service/media/data.rs index 43310515..f48482ea 100644 --- a/src/service/media/data.rs +++ b/src/service/media/data.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use conduwuit::{ debug, debug_info, err, utils::{str_from_bytes, stream::TryIgnore, string_from_bytes, ReadyExt}, - Err, Error, Result, + Err, Result, }; use database::{Database, Interfix, Map}; use futures::StreamExt; @@ -123,30 +123,21 @@ impl Data { let content_type = parts .next() - .map(|bytes| { - string_from_bytes(bytes).map_err(|_| { - Error::bad_database("Content type in mediaid_file is invalid unicode.") - }) - }) - .transpose()?; + .map(string_from_bytes) + .transpose() + .map_err(|e| err!(Database(error!(?mxc, "Content-type is invalid: {e}"))))?; - let content_disposition_bytes = parts + let content_disposition = parts .next() - .ok_or_else(|| Error::bad_database("Media ID in db is invalid."))?; - - let content_disposition = if content_disposition_bytes.is_empty() { - None - } else { - Some( - string_from_bytes(content_disposition_bytes) - .map_err(|_| { - Error::bad_database( - "Content Disposition in mediaid_file is invalid unicode.", - ) - })? - .parse()?, - ) - }; + .map(Some) + .ok_or_else(|| err!(Database(error!(?mxc, "Media ID in db is invalid."))))? + .filter(|bytes| !bytes.is_empty()) + .map(string_from_bytes) + .transpose() + .map_err(|e| err!(Database(error!(?mxc, "Content-type is invalid: {e}"))))? + .as_deref() + .map(str::parse) + .transpose()?; Ok(Metadata { content_disposition, content_type, key }) } diff --git a/src/service/media/preview.rs b/src/service/media/preview.rs index b1c53305..e7f76bab 100644 --- a/src/service/media/preview.rs +++ b/src/service/media/preview.rs @@ -1,15 +1,19 @@ -use std::{io::Cursor, time::SystemTime}; +//! URL Previews +//! +//! This functionality is gated by 'url_preview', but not at the unit level for +//! historical and simplicity reasons. Instead the feature gates the inclusion +//! of dependencies and nulls out results through the existing interface when +//! not featured. -use conduwuit::{debug, utils, Err, Result}; +use std::time::SystemTime; + +use conduwuit::{debug, Err, Result}; use conduwuit_core::implement; -use image::ImageReader as ImgReader; use ipaddress::IPAddress; -use ruma::Mxc; use serde::Serialize; use url::Url; -use webpage::HTML; -use super::{Service, MXC_LENGTH}; +use super::Service; #[derive(Serialize, Default)] pub struct UrlPreviewData { @@ -41,34 +45,6 @@ pub async fn set_url_preview(&self, url: &str, data: &UrlPreviewData) -> Result< self.db.set_url_preview(url, data, now) } -#[implement(Service)] -pub async fn download_image(&self, url: &str) -> Result { - let client = &self.services.client.url_preview; - let image = client.get(url).send().await?.bytes().await?; - let mxc = Mxc { - server_name: self.services.globals.server_name(), - media_id: &utils::random_string(MXC_LENGTH), - }; - - self.create(&mxc, None, None, None, &image).await?; - - let (width, height) = match ImgReader::new(Cursor::new(&image)).with_guessed_format() { - | Err(_) => (None, None), - | Ok(reader) => match reader.into_dimensions() { - | Err(_) => (None, None), - | Ok((width, height)) => (Some(width), Some(height)), - }, - }; - - Ok(UrlPreviewData { - image: Some(mxc.to_string()), - image_size: Some(image.len()), - image_width: width, - image_height: height, - ..Default::default() - }) -} - #[implement(Service)] pub async fn get_url_preview(&self, url: &Url) -> Result { if let Ok(preview) = self.db.get_url_preview(url.as_str()).await { @@ -121,8 +97,51 @@ async fn request_url_preview(&self, url: &Url) -> Result { Ok(data) } +#[cfg(feature = "url_preview")] +#[implement(Service)] +pub async fn download_image(&self, url: &str) -> Result { + use conduwuit::utils::random_string; + use image::ImageReader; + use ruma::Mxc; + + let image = self.services.client.url_preview.get(url).send().await?; + let image = image.bytes().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?; + + 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)), + }, + }; + + Ok(UrlPreviewData { + image: Some(mxc.to_string()), + image_size: Some(image.len()), + image_width: width, + image_height: height, + ..Default::default() + }) +} + +#[cfg(not(feature = "url_preview"))] +#[implement(Service)] +pub async fn download_image(&self, _url: &str) -> Result { + Err!(FeatureDisabled("url_preview")) +} + +#[cfg(feature = "url_preview")] #[implement(Service)] async fn download_html(&self, url: &str) -> Result { + use webpage::HTML; + let client = &self.services.client.url_preview; let mut response = client.get(url).send().await?; @@ -159,6 +178,12 @@ async fn download_html(&self, url: &str) -> Result { Ok(data) } +#[cfg(not(feature = "url_preview"))] +#[implement(Service)] +async fn download_html(&self, _url: &str) -> Result { + Err!(FeatureDisabled("url_preview")) +} + #[implement(Service)] pub fn url_preview_allowed(&self, url: &Url) -> bool { if ["http", "https"] diff --git a/src/service/media/thumbnail.rs b/src/service/media/thumbnail.rs index 5c8063cb..7350b3a1 100644 --- a/src/service/media/thumbnail.rs +++ b/src/service/media/thumbnail.rs @@ -1,7 +1,13 @@ -use std::{cmp, io::Cursor, num::Saturating as Sat}; +//! Media Thumbnails +//! +//! This functionality is gated by 'media_thumbnail', but not at the unit level +//! for historical and simplicity reasons. Instead the feature gates the +//! inclusion of dependencies and nulls out results using the existing interface +//! when not featured. -use conduwuit::{checked, err, Result}; -use image::{imageops::FilterType, DynamicImage}; +use std::{cmp, num::Saturating as Sat}; + +use conduwuit::{checked, err, implement, Result}; use ruma::{http_headers::ContentDisposition, media::Method, Mxc, UInt, UserId}; use tokio::{ fs, @@ -67,65 +73,89 @@ impl super::Service { Ok(None) } } - - /// Using saved thumbnail - #[tracing::instrument(skip(self), name = "saved", level = "debug")] - async fn get_thumbnail_saved(&self, data: Metadata) -> Result> { - let mut content = Vec::new(); - let path = self.get_media_file(&data.key); - fs::File::open(path) - .await? - .read_to_end(&mut content) - .await?; - - Ok(Some(into_filemeta(data, content))) - } - - /// Generate a thumbnail - #[tracing::instrument(skip(self), name = "generate", level = "debug")] - async fn get_thumbnail_generate( - &self, - mxc: &Mxc<'_>, - dim: &Dim, - data: Metadata, - ) -> Result> { - let mut content = Vec::new(); - let path = self.get_media_file(&data.key); - fs::File::open(path) - .await? - .read_to_end(&mut content) - .await?; - - let Ok(image) = image::load_from_memory(&content) else { - // Couldn't parse file to generate thumbnail, send original - return Ok(Some(into_filemeta(data, content))); - }; - - if dim.width > image.width() || dim.height > image.height() { - return Ok(Some(into_filemeta(data, content))); - } - - let mut thumbnail_bytes = Vec::new(); - let thumbnail = thumbnail_generate(&image, dim)?; - thumbnail.write_to(&mut Cursor::new(&mut thumbnail_bytes), image::ImageFormat::Png)?; - - // 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))) - } } -fn thumbnail_generate(image: &DynamicImage, requested: &Dim) -> Result { +/// Using saved thumbnail +#[implement(super::Service)] +#[tracing::instrument(name = "saved", level = "debug", skip(self, data))] +async fn get_thumbnail_saved(&self, data: Metadata) -> Result> { + let mut content = Vec::new(); + let path = self.get_media_file(&data.key); + fs::File::open(path) + .await? + .read_to_end(&mut content) + .await?; + + Ok(Some(into_filemeta(data, content))) +} + +/// Generate a thumbnail +#[cfg(feature = "media_thumbnail")] +#[implement(super::Service)] +#[tracing::instrument(name = "generate", level = "debug", skip(self, data))] +async fn get_thumbnail_generate( + &self, + mxc: &Mxc<'_>, + dim: &Dim, + data: Metadata, +) -> Result> { + let mut content = Vec::new(); + let path = self.get_media_file(&data.key); + fs::File::open(path) + .await? + .read_to_end(&mut content) + .await?; + + let Ok(image) = image::load_from_memory(&content) else { + // Couldn't parse file to generate thumbnail, send original + return Ok(Some(into_filemeta(data, content))); + }; + + if dim.width > image.width() || dim.height > image.height() { + return Ok(Some(into_filemeta(data, content))); + } + + 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.")))?; + + // 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"))] +#[implement(super::Service)] +#[tracing::instrument(name = "fallback", level = "debug", skip_all)] +async fn get_thumbnail_generate( + &self, + _mxc: &Mxc<'_>, + _dim: &Dim, + data: Metadata, +) -> Result> { + self.get_thumbnail_saved(data).await +} + +#[cfg(feature = "media_thumbnail")] +fn thumbnail_generate( + image: &image::DynamicImage, + requested: &Dim, +) -> Result { + use image::imageops::FilterType; + let thumbnail = if !requested.crop() { let Dim { width, height, .. } = requested.scaled(&Dim { width: image.width(),