Add default-enabled feature-gates for url_preview and media_thumbnail

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk 2024-12-18 21:29:30 +00:00
parent 0238f27605
commit cc1889d135
8 changed files with 182 additions and 123 deletions

1
Cargo.lock generated
View file

@ -723,7 +723,6 @@ dependencies = [
"hardened_malloc-rs",
"http",
"http-body-util",
"image",
"ipaddress",
"itertools 0.13.0",
"libc",

View file

@ -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

View file

@ -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)]

View file

@ -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",

View file

@ -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

View file

@ -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 })
}

View file

@ -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<UrlPreviewData> {
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<UrlPreviewData> {
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<UrlPreviewData> {
Ok(data)
}
#[cfg(feature = "url_preview")]
#[implement(Service)]
pub async fn download_image(&self, url: &str) -> Result<UrlPreviewData> {
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<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 mut response = client.get(url).send().await?;
@ -159,6 +178,12 @@ async fn download_html(&self, url: &str) -> Result<UrlPreviewData> {
Ok(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"]

View file

@ -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<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
#[tracing::instrument(skip(self), name = "generate", level = "debug")]
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)));
}
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<DynamicImage> {
/// 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?;
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)));
}
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<Option<FileMeta>> {
self.get_thumbnail_saved(data).await
}
#[cfg(feature = "media_thumbnail")]
fn thumbnail_generate(
image: &image::DynamicImage,
requested: &Dim,
) -> Result<image::DynamicImage> {
use image::imageops::FilterType;
let thumbnail = if !requested.crop() {
let Dim { width, height, .. } = requested.scaled(&Dim {
width: image.width(),