1#[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#[cfg(feature = "media_thumbnail")]
28const PNG: &str = "image/png";
29
30#[cfg(feature = "media_thumbnail")]
32const BYTES_PER_PIXEL: u64 = 4;
33
34#[cfg(feature = "media_thumbnail")]
37const THUMBNAIL_NAME: &str = "thumbnail.png";
38
39#[derive(Debug)]
41pub struct Dim {
42 pub width: u32,
43 pub height: u32,
44 pub method: Method,
45}
46
47impl super::Service {
48 #[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 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 #[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 #[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 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 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#[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#[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 if from_video {
249 self.remember_failure(mxc);
250 }
251
252 return Ok(into_media(data, media.content));
254 };
255
256 drop(frame);
257
258 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 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 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 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#[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 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 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 #[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 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 #[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 #[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}