Skip to main content

tuwunel_service/media/
thumbnail.rs

1//! Media Thumbnails
2//!
3//! This functionality is gated by 'media_thumbnail', but not at the unit level
4//! for historical and simplicity reasons. Instead the feature gates the
5//! inclusion of dependencies and nulls out results using the existing interface
6//! when not featured.
7
8use std::{cmp, num::Saturating as Sat, sync::Arc, time::Duration};
9
10use futures::{StreamExt, pin_mut};
11use ruma::{Mxc, UInt, UserId, http_headers::ContentDisposition, media::Method};
12use tokio::sync::Notify;
13use tuwunel_core::{
14	Err, Result, checked, err, implement,
15	utils::{result::LogDebugErr, stream::IterStream},
16};
17
18use super::{Media, data::Metadata};
19
20/// Dimension specification for a thumbnail.
21#[derive(Debug)]
22pub struct Dim {
23	pub width: u32,
24	pub height: u32,
25	pub method: Method,
26}
27
28impl super::Service {
29	/// Uploads or replaces a file thumbnail.
30	#[tracing::instrument(
31		level = "debug",
32		ret(level = "debug")
33		skip(self, file),
34	)]
35	pub async fn upload_thumbnail(
36		&self,
37		mxc: &Mxc<'_>,
38		content_disposition: Option<&ContentDisposition>,
39		content_type: Option<&str>,
40		dim: &Dim,
41		file: &[u8],
42	) -> Result {
43		let key =
44			self.db
45				.create_file_metadata(mxc, None, dim, content_disposition, content_type)?;
46
47		//TODO: Dangling metadata in database if creation fails
48		self.create_media_file(&key, file).await?;
49		Ok(())
50	}
51
52	#[tracing::instrument(
53		level = "debug",
54		err(level = "debug")
55		skip(self),
56	)]
57	pub async fn get_or_fetch_thumbnail(
58		&self,
59		mxc: &Mxc<'_>,
60		dim: &Dim,
61		timeout_ms: Duration,
62		user: &UserId,
63	) -> Result<Media> {
64		if let Ok(media) = self
65			.get_thumbnail(mxc, dim, Some(timeout_ms))
66			.await
67		{
68			return Ok(media);
69		}
70
71		if self
72			.services
73			.globals
74			.server_is_ours(mxc.server_name)
75		{
76			return Err!(Request(NotFound("Local thumbnail not found.")));
77		}
78
79		let lock = self.federation_mutex.lock(&mxc.to_string()).await;
80
81		if self
82			.db
83			.file_metadata_exists(mxc, &dim.normalized())
84			.await
85		{
86			drop(lock);
87			return self.get_thumbnail(mxc, dim, None).await;
88		}
89
90		self.fetch_remote_thumbnail(mxc, None, timeout_ms, dim)
91			.await
92	}
93
94	/// Download a thumbnail and wait up to a timeout_ms if it is pending.
95	#[tracing::instrument(
96		level = "debug",
97		err(level = "debug")
98		skip(self),
99	)]
100	pub async fn get_thumbnail(
101		&self,
102		mxc: &Mxc<'_>,
103		dim: &Dim,
104		timeout_duration: Option<Duration>,
105	) -> Result<Media> {
106		if let Ok(meta) = self.get_stored_thumbnail(mxc, dim).await {
107			return Ok(meta);
108		}
109
110		let Some(timeout_duration) = timeout_duration else {
111			return Err!(Request(NotFound("Media thumbnail not found.")));
112		};
113
114		let Ok(_pending) = self.db.search_pending_mxc(mxc).await else {
115			return Err!(Request(NotFound("Media thumbnail not found.")));
116		};
117
118		let notifier = self
119			.mxc_state
120			.notifiers
121			.lock()?
122			.entry(mxc.to_string().into())
123			.or_insert_with(|| Arc::new(Notify::new()))
124			.clone();
125
126		if tokio::time::timeout(timeout_duration, notifier.notified())
127			.await
128			.is_err()
129		{
130			return Err!(Request(NotYetUploaded("Media has not been uploaded yet")));
131		}
132
133		self.get_stored_thumbnail(mxc, dim).await
134	}
135
136	/// Downloads a file's thumbnail.
137	///
138	/// Here's an example on how it works:
139	///
140	/// - Client requests an image with width=567, height=567
141	/// - Server rounds that up to (800, 600), so it doesn't have to save too
142	///   many thumbnails
143	/// - Server rounds that up again to (958, 600) to fix the aspect ratio
144	///   (only for width,height>96)
145	/// - Server creates the thumbnail and sends it to the user
146	///
147	/// For width,height <= 96 the server uses another thumbnailing algorithm
148	/// which crops the image afterwards.
149	#[tracing::instrument(
150		name = "thumbnail",
151		level = "debug",
152		err(level = "trace")
153		skip(self),
154	)]
155	pub async fn get_stored_thumbnail(&self, mxc: &Mxc<'_>, dim: &Dim) -> Result<Media> {
156		// 0, 0 because that's the original file
157		let dim = dim.normalized();
158
159		if let Ok(metadata) = self.db.search_file_metadata(mxc, &dim).await {
160			return self.get_thumbnail_saved(metadata).await;
161		}
162
163		let metadata = self
164			.db
165			.search_file_metadata(mxc, &Dim::default())
166			.await?;
167
168		self.get_thumbnail_generate(mxc, &dim, metadata)
169			.await
170	}
171}
172
173/// Using saved thumbnail
174#[implement(super::Service)]
175#[tracing::instrument(name = "saved", level = "debug", skip_all)]
176async fn get_thumbnail_saved(&self, data: Metadata) -> Result<Media> {
177	let path = self.get_media_name_sha256(&data.key);
178	let fetch = self
179		.storage_providers()
180		.stream()
181		.filter_map(async |provider| {
182			provider
183				.get(path.as_str())
184				.await
185				.log_debug_err()
186				.ok()
187		});
188
189	pin_mut!(fetch);
190	let Some(bytes) = fetch.next().await else {
191		return Err!(Request(NotFound("Media thumbnail not found.")));
192	};
193
194	Ok(into_media(data, bytes.to_vec()))
195}
196
197/// Generate a thumbnail
198#[cfg(feature = "media_thumbnail")]
199#[implement(super::Service)]
200#[tracing::instrument(name = "generate", level = "debug", skip(self, data))]
201async fn get_thumbnail_generate(
202	&self,
203	mxc: &Mxc<'_>,
204	dim: &Dim,
205	data: Metadata,
206) -> Result<Media> {
207	let Ok(media) = self.get_stored(mxc).await else {
208		return Err!("Could not find original media.");
209	};
210
211	let Ok(image) = image::load_from_memory(&media.content) else {
212		// Couldn't parse file to generate thumbnail, send original
213		return Ok(into_media(data, media.content));
214	};
215
216	if dim.width > image.width() || dim.height > image.height() {
217		return Ok(into_media(data, media.content));
218	}
219
220	let mut thumbnail_bytes = Vec::new();
221	let thumbnail = thumbnail_generate(&image, dim)?;
222	let mut cursor = std::io::Cursor::new(&mut thumbnail_bytes);
223	thumbnail
224		.write_to(&mut cursor, image::ImageFormat::Png)
225		.map_err(|error| err!(error!(?error, "Error writing PNG thumbnail.")))?;
226
227	// Save thumbnail in database so we don't have to generate it again next time
228	let thumbnail_key = self.db.create_file_metadata(
229		mxc,
230		None,
231		dim,
232		data.content_disposition.as_ref(),
233		data.content_type.as_deref(),
234	)?;
235
236	self.create_media_file(&thumbnail_key, &thumbnail_bytes)
237		.await?;
238
239	Ok(into_media(data, thumbnail_bytes))
240}
241
242#[cfg(not(feature = "media_thumbnail"))]
243#[implement(super::Service)]
244#[tracing::instrument(name = "fallback", level = "debug", skip_all)]
245async fn get_thumbnail_generate(
246	&self,
247	_mxc: &Mxc<'_>,
248	_dim: &Dim,
249	data: Metadata,
250) -> Result<Media> {
251	self.get_thumbnail_saved(data).await
252}
253
254#[cfg(feature = "media_thumbnail")]
255fn thumbnail_generate(
256	image: &image::DynamicImage,
257	requested: &Dim,
258) -> Result<image::DynamicImage> {
259	use image::imageops::FilterType;
260
261	let thumbnail = if !requested.crop() {
262		let Dim { width, height, .. } = requested.scaled(&Dim {
263			width: image.width(),
264			height: image.height(),
265			..Dim::default()
266		})?;
267		image.thumbnail_exact(width, height)
268	} else {
269		image.resize_to_fill(requested.width, requested.height, FilterType::CatmullRom)
270	};
271
272	Ok(thumbnail)
273}
274
275fn into_media(data: Metadata, content: Vec<u8>) -> Media {
276	Media {
277		content,
278		content_type: data.content_type,
279		content_disposition: data.content_disposition,
280	}
281}
282
283impl Dim {
284	/// Instantiate a Dim from Ruma integers with optional method.
285	pub fn from_ruma(width: UInt, height: UInt, method: Option<Method>) -> Result<Self> {
286		let width = width
287			.try_into()
288			.map_err(|e| err!(Request(InvalidParam("Width is invalid: {e:?}"))))?;
289		let height = height
290			.try_into()
291			.map_err(|e| err!(Request(InvalidParam("Height is invalid: {e:?}"))))?;
292
293		Ok(Self::new(width, height, method))
294	}
295
296	/// Instantiate a Dim with optional method
297	#[inline]
298	#[must_use]
299	pub fn new(width: u32, height: u32, method: Option<Method>) -> Self {
300		Self {
301			width,
302			height,
303			method: method.unwrap_or(Method::Scale),
304		}
305	}
306
307	pub fn scaled(&self, image: &Self) -> Result<Self> {
308		let image_width = image.width;
309		let image_height = image.height;
310
311		let width = cmp::min(self.width, image_width);
312		let height = cmp::min(self.height, image_height);
313
314		let use_width = Sat(width) * Sat(image_height) < Sat(height) * Sat(image_width);
315
316		let x = if use_width {
317			let dividend = (Sat(height) * Sat(image_width)).0;
318			checked!(dividend / image_height)?
319		} else {
320			width
321		};
322
323		let y = if !use_width {
324			let dividend = (Sat(width) * Sat(image_height)).0;
325			checked!(dividend / image_width)?
326		} else {
327			height
328		};
329
330		Ok(Self {
331			width: x,
332			height: y,
333			method: Method::Scale,
334		})
335	}
336
337	/// Returns width, height of the thumbnail and whether it should be cropped.
338	/// Returns None when the server should send the original file.
339	/// Ignores the input Method.
340	#[must_use]
341	pub fn normalized(&self) -> Self {
342		match (self.width, self.height) {
343			| (0..=32, 0..=32) => Self::new(32, 32, Some(Method::Crop)),
344			| (0..=96, 0..=96) => Self::new(96, 96, Some(Method::Crop)),
345			| (0..=320, 0..=240) => Self::new(320, 240, Some(Method::Scale)),
346			| (0..=640, 0..=480) => Self::new(640, 480, Some(Method::Scale)),
347			| (0..=800, 0..=600) => Self::new(800, 600, Some(Method::Scale)),
348			| _ => Self::default(),
349		}
350	}
351
352	/// Returns true if the method is Crop.
353	#[inline]
354	#[must_use]
355	pub fn crop(&self) -> bool { self.method == Method::Crop }
356}
357
358impl Default for Dim {
359	#[inline]
360	fn default() -> Self {
361		Self {
362			width: 0,
363			height: 0,
364			method: Method::Scale,
365		}
366	}
367}