1use std::cmp::{max, min};
4use std::io::{Cursor, Seek};
5use std::iter::FusedIterator;
6use std::mem;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context as _, Result, ensure, format_err};
10use base64::Engine as _;
11use futures::StreamExt;
12use image::ImageReader;
13use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
14use image::{codecs::jpeg::JpegEncoder, metadata::Orientation};
15use num_traits::{FromPrimitive, cast};
16use tokio::{fs, task};
17use tokio_stream::wrappers::ReadDirStream;
18
19use crate::config::Config;
20use crate::constants::{self, MediaQuality};
21use crate::context::Context;
22use crate::events::EventType;
23use crate::log::{LogExt, warn};
24use crate::message::Viewtype;
25use crate::tools::sanitize_filename;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct BlobObject<'a> {
35 blobdir: &'a Path,
36
37 name: String,
41}
42
43#[derive(Debug, Clone)]
44enum ImageOutputFormat {
45 Png,
46 Jpeg { quality: u8 },
47}
48
49impl<'a> BlobObject<'a> {
50 pub fn create_and_deduplicate(
61 context: &'a Context,
62 src: &Path,
63 original_name: &Path,
64 ) -> Result<BlobObject<'a>> {
65 task::block_in_place(|| {
70 let temp_path;
71 let src_in_blobdir: &Path;
72 let blobdir = context.get_blobdir();
73
74 if src.starts_with(blobdir) {
75 src_in_blobdir = src;
76 } else {
77 info!(
78 context,
79 "Source file not in blobdir. Copying instead of moving in order to prevent moving a file that was still needed."
80 );
81 temp_path = blobdir.join(format!("tmp-{}", rand::random::<u64>()));
82 if std::fs::copy(src, &temp_path).is_err() {
83 std::fs::create_dir_all(blobdir).log_err(context).ok();
85 std::fs::copy(src, &temp_path).context("Copying new blobfile failed")?;
86 };
87 src_in_blobdir = &temp_path;
88 }
89
90 let hash = file_hash(src_in_blobdir)?.to_hex();
91 let hash = hash.as_str();
92 let hash = hash.get(0..31).unwrap_or(hash);
93 let new_file =
94 if let Some(extension) = original_name.extension().filter(|e| e.len() <= 32) {
95 let extension = extension.to_string_lossy().to_lowercase();
96 let extension = sanitize_filename(&extension);
97 format!("$BLOBDIR/{hash}.{extension}")
98 } else {
99 format!("$BLOBDIR/{hash}")
100 };
101
102 let blob = BlobObject {
103 blobdir,
104 name: new_file,
105 };
106 let new_path = blob.to_abs_path();
107
108 std::fs::rename(src_in_blobdir, &new_path)?;
111
112 context.emit_event(EventType::NewBlobFile(blob.as_name().to_string()));
113 Ok(blob)
114 })
115 }
116
117 pub fn create_and_deduplicate_from_bytes(
127 context: &'a Context,
128 data: &[u8],
129 original_name: &str,
130 ) -> Result<BlobObject<'a>> {
131 task::block_in_place(|| {
132 let blobdir = context.get_blobdir();
133 let temp_path = blobdir.join(format!("tmp-{}", rand::random::<u64>()));
134 if std::fs::write(&temp_path, data).is_err() {
135 std::fs::create_dir_all(blobdir).log_err(context).ok();
137 std::fs::write(&temp_path, data).context("writing new blobfile failed")?;
138 };
139
140 BlobObject::create_and_deduplicate(context, &temp_path, Path::new(original_name))
141 })
142 }
143
144 pub fn from_path(context: &'a Context, path: &Path) -> Result<BlobObject<'a>> {
151 let rel_path = path
152 .strip_prefix(context.get_blobdir())
153 .with_context(|| format!("wrong blobdir: {}", path.display()))?;
154 let name = rel_path.to_str().context("wrong name")?;
155 if !BlobObject::is_acceptible_blob_name(name) {
156 return Err(format_err!("bad blob name: {}", rel_path.display()));
157 }
158 BlobObject::from_name(context, name)
159 }
160
161 pub fn from_name(context: &'a Context, name: &str) -> Result<BlobObject<'a>> {
168 let name = match name.starts_with("$BLOBDIR/") {
169 true => name.splitn(2, '/').last().unwrap(),
170 false => name,
171 };
172 if !BlobObject::is_acceptible_blob_name(name) {
173 return Err(format_err!("not an acceptable blob name: {name}"));
174 }
175 Ok(BlobObject {
176 blobdir: context.get_blobdir(),
177 name: format!("$BLOBDIR/{name}"),
178 })
179 }
180
181 pub fn to_abs_path(&self) -> PathBuf {
183 let fname = Path::new(&self.name).strip_prefix("$BLOBDIR/").unwrap();
184 self.blobdir.join(fname)
185 }
186
187 #[allow(rustdoc::private_intra_doc_links)]
198 pub fn as_name(&self) -> &str {
200 &self.name
201 }
202
203 pub fn suffix(&self) -> Option<&str> {
208 let ext = self.name.rsplit('.').next();
209 if ext == Some(&self.name) { None } else { ext }
210 }
211
212 fn is_acceptible_blob_name(name: &str) -> bool {
220 if name.find('/').is_some() {
221 return false;
222 }
223 if name.find('\\').is_some() {
224 return false;
225 }
226 if name.find('\0').is_some() {
227 return false;
228 }
229 true
230 }
231
232 pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<Option<String>> {
241 let Ok(buf) = base64::engine::general_purpose::STANDARD.decode(data) else {
242 return Ok(None);
243 };
244 let name = if let Ok(format) = image::guess_format(&buf) {
245 if let Some(ext) = format.extensions_str().first() {
246 format!("file.{ext}")
247 } else {
248 String::new()
249 }
250 } else {
251 String::new()
252 };
253 let blob = BlobObject::create_and_deduplicate_from_bytes(context, &buf, &name)?;
254 Ok(Some(blob.as_name().to_string()))
255 }
256
257 pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
259 let (max_wh, max_bytes) =
260 match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
261 .unwrap_or_default()
262 {
263 MediaQuality::Balanced => (
264 constants::BALANCED_AVATAR_SIZE,
265 constants::BALANCED_AVATAR_BYTES,
266 ),
267 MediaQuality::Worse => {
268 (constants::WORSE_AVATAR_SIZE, constants::WORSE_AVATAR_BYTES)
269 }
270 };
271
272 let viewtype = &mut Viewtype::Image;
273 let is_avatar = true;
274 self.check_or_recode_to_size(
275 context, None, viewtype, max_wh, max_bytes, is_avatar,
277 )?;
278
279 Ok(())
280 }
281
282 pub async fn check_or_recode_image(
288 &mut self,
289 context: &Context,
290 name: Option<String>,
291 viewtype: &mut Viewtype,
292 ) -> Result<String> {
293 let (max_wh, max_bytes) =
294 match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
295 .unwrap_or_default()
296 {
297 MediaQuality::Balanced => (
298 constants::BALANCED_IMAGE_SIZE,
299 constants::BALANCED_IMAGE_BYTES,
300 ),
301 MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
302 };
303 let is_avatar = false;
304 self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar)
305 }
306
307 #[expect(clippy::arithmetic_side_effects)]
321 fn check_or_recode_to_size(
322 &mut self,
323 context: &Context,
324 name: Option<String>,
325 viewtype: &mut Viewtype,
326 max_wh: u32,
327 max_bytes: usize,
328 is_avatar: bool,
329 ) -> Result<String> {
330 let mut add_white_bg = is_avatar;
332 let mut no_exif = false;
333 let no_exif_ref = &mut no_exif;
334 let mut name = name.unwrap_or_else(|| self.name.clone());
335 let original_name = name.clone();
336 let vt = &mut *viewtype;
337 let res: Result<String> = tokio::task::block_in_place(move || {
338 let mut file = std::fs::File::open(self.to_abs_path())?;
339 let (nr_bytes, exif) = image_metadata(&file)?;
340 *no_exif_ref = exif.is_none();
341 file.rewind()?;
344 let imgreader = ImageReader::new(std::io::BufReader::new(&file)).with_guessed_format();
345 let imgreader = match imgreader {
346 Ok(ir) => ir,
347 _ => {
348 file.rewind()?;
349 ImageReader::with_format(
350 std::io::BufReader::new(&file),
351 ImageFormat::from_path(self.to_abs_path())?,
352 )
353 }
354 };
355 let fmt = imgreader.format().context("Unknown format")?;
356 if *vt == Viewtype::File {
357 *vt = Viewtype::Image;
358 return Ok(name);
359 }
360 let mut img = imgreader.decode().context("image decode failure")?;
361 let orientation = exif
362 .as_ref()
363 .map(|exif| exif_orientation(exif, context))
364 .unwrap_or(Orientation::NoTransforms);
365 let mut encoded = Vec::new();
366
367 if *vt == Viewtype::Sticker {
368 let x_max = img.width().saturating_sub(1);
369 let y_max = img.height().saturating_sub(1);
370 if !img.in_bounds(x_max, y_max)
371 || !(img.get_pixel(0, 0).0[3] == 0
372 || img.get_pixel(x_max, 0).0[3] == 0
373 || img.get_pixel(0, y_max).0[3] == 0
374 || img.get_pixel(x_max, y_max).0[3] == 0)
375 {
376 *vt = Viewtype::Image;
377 } else {
378 return Ok(name);
381 }
382 }
383 img.apply_orientation(orientation);
384
385 let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
388 let exceeds_max_bytes = nr_bytes > max_bytes as u64;
389
390 let jpeg_quality = 75;
391 let ofmt = match fmt {
392 ImageFormat::Png if !exceeds_max_bytes => ImageOutputFormat::Png,
393 ImageFormat::Jpeg => {
394 add_white_bg = false;
395 ImageOutputFormat::Jpeg {
396 quality: jpeg_quality,
397 }
398 }
399 _ => ImageOutputFormat::Jpeg {
400 quality: jpeg_quality,
401 },
402 };
403 let do_scale = exceeds_max_bytes
410 || is_avatar
411 && (exceeds_wh
412 || exif.is_some() && {
413 if mem::take(&mut add_white_bg) {
414 self::add_white_bg(&mut img);
415 }
416 encoded_img_exceeds_bytes(
417 context,
418 &img,
419 ofmt.clone(),
420 max_bytes,
421 &mut encoded,
422 )?
423 });
424
425 if do_scale {
426 let longest_side_len = max(img.width(), img.height());
427
428 let mut target_wh = if !is_avatar {
431 let area_sqrt = (f64::from(img.width()) * f64::from(img.height())).sqrt();
432 let mut resolution_limit: u32 = cast(
435 (f64::from(longest_side_len) * (f64::from(max_wh) / area_sqrt)).floor(),
436 )
437 .unwrap_or(max_wh);
438 if resolution_limit < longest_side_len && resolution_limit > 8 {
441 while !resolution_limit.is_multiple_of(8) {
442 resolution_limit -= 1
443 }
444 }
445 resolution_limit
446 } else {
447 max_wh
448 };
449
450 target_wh = min(target_wh, longest_side_len);
451
452 target_wh = min(target_wh, 65535);
454
455 loop {
456 if mem::take(&mut add_white_bg) {
457 self::add_white_bg(&mut img);
458 }
459
460 let new_img = if is_avatar {
469 img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle)
470 } else {
471 img.thumbnail(target_wh, target_wh)
472 };
473
474 if encoded_img_exceeds_bytes(
475 context,
476 &new_img,
477 ofmt.clone(),
478 max_bytes,
479 &mut encoded,
480 )? && is_avatar
481 {
482 if target_wh < 20 {
483 return Err(format_err!(
484 "Failed to scale image to below {max_bytes}B.",
485 ));
486 }
487
488 target_wh = target_wh * 7 / 8;
489 } else {
490 info!(
491 context,
492 "Final scaled-down image size: {}B ({}px).",
493 encoded.len(),
494 target_wh
495 );
496 break;
497 }
498 }
499 }
500
501 if do_scale || exif.is_some() {
502 if !matches!(fmt, ImageFormat::Jpeg)
504 && matches!(ofmt, ImageOutputFormat::Jpeg { .. })
505 {
506 name = Path::new(&name)
507 .with_extension("jpg")
508 .to_string_lossy()
509 .into_owned();
510 }
511
512 if encoded.is_empty() {
513 if mem::take(&mut add_white_bg) {
514 self::add_white_bg(&mut img);
515 }
516 encode_img(&img, ofmt, &mut encoded)?;
517 }
518
519 self.name = BlobObject::create_and_deduplicate_from_bytes(context, &encoded, &name)
520 .context("failed to write recoded blob to file")?
521 .name;
522 }
523
524 Ok(name)
525 });
526 match res {
527 Ok(_) => res,
528 Err(err) => {
529 if !is_avatar && no_exif {
530 error!(
531 context,
532 "Cannot check/recode image, using original data: {err:#}.",
533 );
534 *viewtype = Viewtype::File;
535 Ok(original_name)
536 } else {
537 Err(err)
538 }
539 }
540 }
541 }
542}
543
544fn file_hash(src: &Path) -> Result<blake3::Hash> {
545 ensure!(
546 !src.starts_with("$BLOBDIR/"),
547 "Use `get_abs_path()` to get the absolute path of the blobfile"
548 );
549 let mut hasher = blake3::Hasher::new();
550 let mut src_file = std::fs::File::open(src)
551 .with_context(|| format!("Failed to open file {}", src.display()))?;
552 hasher
553 .update_reader(&mut src_file)
554 .context("update_reader")?;
555 let hash = hasher.finalize();
556 Ok(hash)
557}
558
559fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
561 let len = file.metadata()?.len();
562 let mut bufreader = std::io::BufReader::new(file);
563 let exif = exif::Reader::new()
564 .continue_on_error(true)
565 .read_from_container(&mut bufreader)
566 .or_else(|e| e.distill_partial_result(|_errors| {}))
567 .ok();
568 Ok((len, exif))
569}
570
571fn exif_orientation(exif: &exif::Exif, context: &Context) -> Orientation {
572 if let Some(orientation) = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
573 && let Some(val) = orientation.value.get_uint(0)
574 && let Ok(val) = TryInto::<u8>::try_into(val)
575 {
576 return Orientation::from_exif(val).unwrap_or({
577 warn!(context, "Exif orientation value ignored: {val:?}.");
578 Orientation::NoTransforms
579 });
580 }
581 Orientation::NoTransforms
582}
583
584pub(crate) struct BlobDirContents<'a> {
591 inner: Vec<PathBuf>,
592 context: &'a Context,
593}
594
595impl<'a> BlobDirContents<'a> {
596 pub(crate) async fn new(context: &'a Context) -> Result<BlobDirContents<'a>> {
597 let readdir = fs::read_dir(context.get_blobdir()).await?;
598 let inner = ReadDirStream::new(readdir)
599 .filter_map(|entry| async move {
600 match entry {
601 Ok(entry) => Some(entry),
602 Err(err) => {
603 error!(context, "Failed to read blob file: {err}.");
604 None
605 }
606 }
607 })
608 .filter_map(|entry| async move {
609 match entry.file_type().await.ok()?.is_file() {
610 true => Some(entry.path()),
611 false => {
612 warn!(
613 context,
614 "Export: Found blob dir entry {} that is not a file, ignoring.",
615 entry.path().display()
616 );
617 None
618 }
619 }
620 })
621 .collect()
622 .await;
623 Ok(Self { inner, context })
624 }
625
626 pub(crate) fn iter(&self) -> BlobDirIter<'_> {
627 BlobDirIter::new(self.context, self.inner.iter())
628 }
629}
630
631pub(crate) struct BlobDirIter<'a> {
633 iter: std::slice::Iter<'a, PathBuf>,
634 context: &'a Context,
635}
636
637impl<'a> BlobDirIter<'a> {
638 fn new(context: &'a Context, iter: std::slice::Iter<'a, PathBuf>) -> BlobDirIter<'a> {
639 Self { iter, context }
640 }
641}
642
643impl<'a> Iterator for BlobDirIter<'a> {
644 type Item = BlobObject<'a>;
645
646 fn next(&mut self) -> Option<Self::Item> {
647 for path in self.iter.by_ref() {
648 match BlobObject::from_path(self.context, path) {
651 Ok(blob) => return Some(blob),
652 Err(err) => warn!(self.context, "{err}"),
653 }
654 }
655 None
656 }
657}
658
659impl FusedIterator for BlobDirIter<'_> {}
660
661fn encode_img(
662 img: &DynamicImage,
663 fmt: ImageOutputFormat,
664 encoded: &mut Vec<u8>,
665) -> anyhow::Result<()> {
666 encoded.clear();
667 let mut buf = Cursor::new(encoded);
668 match fmt {
669 ImageOutputFormat::Png => img.write_to(&mut buf, ImageFormat::Png)?,
670 ImageOutputFormat::Jpeg { quality } => {
671 let encoder = JpegEncoder::new_with_quality(&mut buf, quality);
672 img.clone().into_rgb8().write_with_encoder(encoder)?;
676 }
677 }
678 Ok(())
679}
680
681fn encoded_img_exceeds_bytes(
682 context: &Context,
683 img: &DynamicImage,
684 fmt: ImageOutputFormat,
685 max_bytes: usize,
686 encoded: &mut Vec<u8>,
687) -> anyhow::Result<bool> {
688 encode_img(img, fmt, encoded)?;
689 if encoded.len() > max_bytes {
690 info!(
691 context,
692 "Image size {}B ({}x{}px) exceeds {}B, need to scale down.",
693 encoded.len(),
694 img.width(),
695 img.height(),
696 max_bytes,
697 );
698 return Ok(true);
699 }
700 Ok(false)
701}
702
703fn add_white_bg(img: &mut DynamicImage) {
705 for y in 0..img.height() {
706 for x in 0..img.width() {
707 let mut p = Rgba([255u8, 255, 255, 255]);
708 p.blend(&img.get_pixel(x, y));
709 img.put_pixel(x, y, p);
710 }
711 }
712}
713
714#[cfg(test)]
715mod blob_tests;