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
8#[cfg(feature = "media_thumbnail")]
9use std::io::Cursor;
10use std::{cmp::min, num::Saturating as Sat, sync::Arc, time::Duration};
11
12use futures::{StreamExt, pin_mut};
13#[cfg(feature = "media_thumbnail")]
14use image::{DynamicImage, ImageFormat, ImageReader, Limits, imageops::FilterType};
15#[cfg(feature = "media_thumbnail")]
16use ruma::http_headers::ContentDispositionType;
17use ruma::{Mxc, UInt, UserId, http_headers::ContentDisposition, media::Method};
18use tokio::sync::Notify;
19use tuwunel_core::{
20	Err, Result, checked, err, implement,
21	utils::{result::LogDebugErr, stream::IterStream},
22};
23
24use super::{Media, data::Metadata};
25
26/// Content type of every thumbnail tuwunel generates.
27#[cfg(feature = "media_thumbnail")]
28const PNG: &str = "image/png";
29
30/// Bytes the decoder is budgeted per pixel of the picture it is asked for.
31#[cfg(feature = "media_thumbnail")]
32const BYTES_PER_PIXEL: u64 = 4;
33
34/// Filename a generated thumbnail is disposed under, per the media repository
35/// specification, rather than the name of the file it was generated from.
36#[cfg(feature = "media_thumbnail")]
37const THUMBNAIL_NAME: &str = "thumbnail.png";
38
39/// Dimension specification for a thumbnail.
40#[derive(Debug)]
41pub struct Dim {
42	pub width: u32,
43	pub height: u32,
44	pub method: Method,
45}
46
47impl super::Service {
48	/// Uploads or replaces a file thumbnail.
49	#[tracing::instrument(
50		level = "debug",
51		ret(level = "debug")
52		skip(self, file),
53	)]
54	pub async fn upload_thumbnail(
55		&self,
56		mxc: &Mxc<'_>,
57		content_disposition: Option<&ContentDisposition>,
58		content_type: Option<&str>,
59		dim: &Dim,
60		file: &[u8],
61	) -> Result {
62		let key =
63			self.db
64				.create_file_metadata(mxc, None, dim, content_disposition, content_type)?;
65
66		//TODO: Dangling metadata in database if creation fails
67		self.create_media_file(&key, file).await?;
68		Ok(())
69	}
70
71	#[tracing::instrument(
72		level = "debug",
73		err(level = "debug")
74		skip(self),
75	)]
76	pub async fn get_or_fetch_thumbnail(
77		&self,
78		mxc: &Mxc<'_>,
79		dim: &Dim,
80		timeout_ms: Duration,
81		user: &UserId,
82	) -> Result<Media> {
83		if let Ok(media) = self
84			.get_thumbnail(mxc, dim, Some(timeout_ms))
85			.await
86		{
87			return Ok(media);
88		}
89
90		if self
91			.services
92			.globals
93			.server_is_ours(mxc.server_name)
94		{
95			return Err!(Request(NotFound("Local thumbnail not found.")));
96		}
97
98		let lock = self.federation_mutex.lock(&mxc.to_string()).await;
99
100		if self
101			.db
102			.file_metadata_exists(mxc, &dim.normalized())
103			.await
104		{
105			drop(lock);
106			return self.get_thumbnail(mxc, dim, None).await;
107		}
108
109		self.fetch_remote_thumbnail(mxc, None, timeout_ms, dim)
110			.await
111	}
112
113	/// Download a thumbnail and wait up to a timeout_ms if it is pending.
114	#[tracing::instrument(
115		level = "debug",
116		err(level = "debug")
117		skip(self),
118	)]
119	pub async fn get_thumbnail(
120		&self,
121		mxc: &Mxc<'_>,
122		dim: &Dim,
123		timeout_duration: Option<Duration>,
124	) -> Result<Media> {
125		if let Ok(meta) = self.get_stored_thumbnail(mxc, dim).await {
126			return Ok(meta);
127		}
128
129		let Some(timeout_duration) = timeout_duration else {
130			return Err!(Request(NotFound("Media thumbnail not found.")));
131		};
132
133		let Ok(_pending) = self.db.search_pending_mxc(mxc).await else {
134			return Err!(Request(NotFound("Media thumbnail not found.")));
135		};
136
137		let notifier = self
138			.mxc_state
139			.notifiers
140			.lock()?
141			.entry(mxc.to_string().into())
142			.or_insert_with(|| Arc::new(Notify::new()))
143			.clone();
144
145		if tokio::time::timeout(timeout_duration, notifier.notified())
146			.await
147			.is_err()
148		{
149			return Err!(Request(NotYetUploaded("Media has not been uploaded yet")));
150		}
151
152		self.get_stored_thumbnail(mxc, dim).await
153	}
154
155	/// Downloads a file's thumbnail.
156	///
157	/// Here's an example on how it works:
158	///
159	/// - Client requests an image with width=567, height=567
160	/// - Server rounds that up to (800, 600), so it doesn't have to save too
161	///   many thumbnails
162	/// - Server rounds that up again to (958, 600) to fix the aspect ratio
163	///   (only for width,height>96)
164	/// - Server creates the thumbnail and sends it to the user
165	///
166	/// For width,height <= 96 the server uses another thumbnailing algorithm
167	/// which crops the image afterwards.
168	#[tracing::instrument(
169		name = "thumbnail",
170		level = "debug",
171		err(level = "trace")
172		skip(self),
173	)]
174	pub async fn get_stored_thumbnail(&self, mxc: &Mxc<'_>, dim: &Dim) -> Result<Media> {
175		// 0, 0 because that's the original file
176		let dim = dim.normalized();
177
178		if let Ok(metadata) = self.db.search_file_metadata(mxc, &dim).await {
179			return self.get_thumbnail_saved(metadata).await;
180		}
181
182		// the original may be lazy preview media promoted on this very request;
183		// only an image is worth serving in a thumbnail's place
184		let Ok(metadata) = self
185			.db
186			.search_file_metadata(mxc, &Dim::default())
187			.await
188		else {
189			let media = self.get_stored(mxc).await?;
190
191			return media
192				.content_type
193				.as_deref()
194				.is_some_and(|content_type| content_type.starts_with("image/"))
195				.then_some(media)
196				.ok_or_else(|| err!(Request(NotFound("Media not found."))));
197		};
198
199		self.get_thumbnail_generate(mxc, &dim, metadata)
200			.await
201	}
202}
203
204/// Using saved thumbnail
205#[implement(super::Service)]
206#[tracing::instrument(name = "saved", level = "debug", skip_all)]
207async fn get_thumbnail_saved(&self, data: Metadata) -> Result<Media> {
208	let path = self.get_media_name_sha256(&data.key);
209	let fetch = self
210		.storage_providers()
211		.stream()
212		.filter_map(async |provider| {
213			provider
214				.get(path.as_str())
215				.await
216				.log_debug_err()
217				.ok()
218		});
219
220	pin_mut!(fetch);
221	let Some(bytes) = fetch.next().await else {
222		return Err!(Request(NotFound("Media thumbnail not found.")));
223	};
224
225	Ok(into_media(data, bytes.to_vec()))
226}
227
228/// Generate a thumbnail
229#[cfg(feature = "media_thumbnail")]
230#[implement(super::Service)]
231#[tracing::instrument(name = "generate", level = "debug", skip(self, data))]
232async fn get_thumbnail_generate(
233	&self,
234	mxc: &Mxc<'_>,
235	dim: &Dim,
236	data: Metadata,
237) -> Result<Media> {
238	let Ok(media) = self.get_stored(mxc).await else {
239		return Err!("Could not find original media.");
240	};
241
242	let frame = self.video_frame(mxc, dim, &media).await;
243	let from_video = frame.is_some();
244
245	let Ok(image) = self.decode(frame.as_deref().unwrap_or(&media.content)) else {
246		// a frame the thumbnailer refuses is this video's verdict too; without
247		// it the program would run again on the next request for any size
248		if from_video {
249			self.remember_failure(mxc);
250		}
251
252		// Couldn't parse file to generate thumbnail, send original
253		return Ok(into_media(data, media.content));
254	};
255
256	drop(frame);
257
258	// a video is never servable in place of its own thumbnail, so its frame is
259	// re-encoded however small it is
260	let source = Dim::new(image.width(), image.height(), None);
261	if !from_video && dim.is_passthrough(&source)? {
262		return Ok(into_media(data, media.content));
263	}
264
265	// nothing below reads the original, which on the video path is the whole
266	// staged file, and the encode and the store must not hold it
267	drop(media);
268
269	let mut thumbnail_bytes = Vec::new();
270	let thumbnail = thumbnail_generate(&image, dim)?;
271	let mut cursor = Cursor::new(&mut thumbnail_bytes);
272
273	thumbnail
274		.write_to(&mut cursor, ImageFormat::Png)
275		.map_err(|error| err!(error!(?error, "Error writing PNG thumbnail.")))?;
276
277	// a generated thumbnail is a PNG rather than the uploaded file, and carries
278	// the name the media repository specification asks of one whether or not the
279	// original arrived with a name of its own
280	let content_disposition = ContentDisposition {
281		disposition_type: ContentDispositionType::Inline,
282		filename: Some(THUMBNAIL_NAME.to_owned()),
283	};
284
285	let data = Metadata {
286		content_type: Some(PNG.to_owned()),
287		content_disposition: Some(content_disposition),
288		..data
289	};
290
291	// Save thumbnail in database so we don't have to generate it again next time
292	let thumbnail_key = self.db.create_file_metadata(
293		mxc,
294		None,
295		dim,
296		data.content_disposition.as_ref(),
297		data.content_type.as_deref(),
298	)?;
299
300	self.create_media_file(&thumbnail_key, &thumbnail_bytes)
301		.await?;
302
303	Ok(into_media(data, thumbnail_bytes))
304}
305
306#[cfg(not(feature = "media_thumbnail"))]
307#[implement(super::Service)]
308#[tracing::instrument(name = "fallback", level = "debug", skip_all)]
309async fn get_thumbnail_generate(
310	&self,
311	_mxc: &Mxc<'_>,
312	_dim: &Dim,
313	data: Metadata,
314) -> Result<Media> {
315	self.get_thumbnail_saved(data).await
316}
317
318/// Decode a picture whose header declares no more than the configured pixel
319/// count. The dimensions are checked before any decoder allocates, since
320/// `Limits` enforces only a byte budget and leaves a decoder free to ignore it.
321#[cfg(feature = "media_thumbnail")]
322#[implement(super::Service)]
323#[tracing::instrument(name = "decode", level = "trace", skip_all)]
324fn decode(&self, bytes: &[u8]) -> Result<DynamicImage> {
325	let budget = self.services.config.media_thumbnail_max_pixels;
326	let (width, height) = reader(bytes)?
327		.into_dimensions()
328		.map_err(|error| err!(debug_warn!(?error, "Failed to read picture dimensions.")))?;
329
330	let pixels = u64::from(width).saturating_mul(u64::from(height));
331
332	if pixels > budget {
333		return Err!(debug_warn!(%width, %height, "Picture is past the {budget} pixel budget."));
334	}
335
336	let mut limits = Limits::no_limits();
337	limits.max_alloc = Some(budget.saturating_mul(BYTES_PER_PIXEL));
338
339	let mut reader = reader(bytes)?;
340	reader.limits(limits);
341
342	reader
343		.decode()
344		.map_err(|error| err!(debug_warn!(?error, "Failed to decode picture.")))
345}
346
347#[cfg(feature = "media_thumbnail")]
348fn reader(bytes: &[u8]) -> Result<ImageReader<Cursor<&[u8]>>> {
349	ImageReader::new(Cursor::new(bytes))
350		.with_guessed_format()
351		.map_err(Into::into)
352}
353
354#[cfg(feature = "media_thumbnail")]
355pub(super) fn thumbnail_generate(image: &DynamicImage, requested: &Dim) -> Result<DynamicImage> {
356	let thumbnail = if !requested.crop() {
357		let Dim { width, height, .. } = requested.scaled(&Dim {
358			width: image.width(),
359			height: image.height(),
360			..Dim::default()
361		})?;
362		image.thumbnail_exact(width, height)
363	} else {
364		// upscaling is forbidden outright, and resize_to_fill enlarges a source
365		// smaller than the request to meet it
366		let width = min(requested.width, image.width());
367		let height = min(requested.height, image.height());
368
369		image.resize_to_fill(width, height, FilterType::CatmullRom)
370	};
371
372	Ok(thumbnail)
373}
374
375fn into_media(data: Metadata, content: Vec<u8>) -> Media {
376	Media {
377		content,
378		content_type: data.content_type,
379		content_disposition: data.content_disposition,
380	}
381}
382
383impl Dim {
384	/// Instantiate a Dim from Ruma integers with optional method.
385	pub fn from_ruma(width: UInt, height: UInt, method: Option<Method>) -> Result<Self> {
386		let width = width
387			.try_into()
388			.map_err(|e| err!(Request(InvalidParam("Width is invalid: {e:?}"))))?;
389		let height = height
390			.try_into()
391			.map_err(|e| err!(Request(InvalidParam("Height is invalid: {e:?}"))))?;
392
393		Ok(Self::new(width, height, method))
394	}
395
396	/// Instantiate a Dim with optional method
397	#[inline]
398	#[must_use]
399	pub fn new(width: u32, height: u32, method: Option<Method>) -> Self {
400		Self {
401			width,
402			height,
403			method: method.unwrap_or(Method::Scale),
404		}
405	}
406
407	pub fn scaled(&self, image: &Self) -> Result<Self> {
408		let image_width = image.width;
409		let image_height = image.height;
410
411		let width = min(self.width, image_width);
412		let height = min(self.height, image_height);
413
414		let use_width = Sat(width) * Sat(image_height) < Sat(height) * Sat(image_width);
415
416		let x = if use_width {
417			let dividend = (Sat(height) * Sat(image_width)).0;
418			checked!(dividend / image_height)?
419		} else {
420			width
421		};
422
423		let y = if !use_width {
424			let dividend = (Sat(width) * Sat(image_height)).0;
425			checked!(dividend / image_width)?
426		} else {
427			height
428		};
429
430		Ok(Self {
431			width: x,
432			height: y,
433			method: Method::Scale,
434		})
435	}
436
437	/// Returns true when generation cannot improve on the source and the
438	/// original should be served instead: either the request would upscale, or
439	/// the generated thumbnail would carry the source's own dimensions.
440	pub fn is_passthrough(&self, source: &Self) -> Result<bool> {
441		if self.width > source.width || self.height > source.height {
442			return Ok(true);
443		}
444
445		let (width, height) = if self.crop() {
446			(self.width, self.height)
447		} else {
448			let scaled = self.scaled(source)?;
449			(scaled.width, scaled.height)
450		};
451
452		Ok(width == source.width && height == source.height)
453	}
454
455	/// Returns width, height of the thumbnail and whether it should be cropped.
456	/// Returns None when the server should send the original file.
457	/// Ignores the input Method.
458	#[must_use]
459	pub fn normalized(&self) -> Self {
460		match (self.width, self.height) {
461			| (0..=32, 0..=32) => Self::new(32, 32, Some(Method::Crop)),
462			| (0..=96, 0..=96) => Self::new(96, 96, Some(Method::Crop)),
463			| (0..=320, 0..=240) => Self::new(320, 240, Some(Method::Scale)),
464			| (0..=640, 0..=480) => Self::new(640, 480, Some(Method::Scale)),
465			| (0..=800, 0..=600) => Self::new(800, 600, Some(Method::Scale)),
466			| _ => Self::default(),
467		}
468	}
469
470	/// Returns true if the method is Crop.
471	#[inline]
472	#[must_use]
473	pub fn crop(&self) -> bool { self.method == Method::Crop }
474}
475
476impl Default for Dim {
477	#[inline]
478	fn default() -> Self {
479		Self {
480			width: 0,
481			height: 0,
482			method: Method::Scale,
483		}
484	}
485}