deltachat/
blob.rs

1//! # Blob directory management.
2
3use std::cmp::max;
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::codecs::jpeg::JpegEncoder;
14use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
15use num_traits::FromPrimitive;
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/// Represents a file in the blob directory.
28///
29/// The object has a name, which will always be valid UTF-8.  Having a
30/// blob object does not imply the respective file exists, however
31/// when using one of the `create*()` methods a unique file is
32/// created.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct BlobObject<'a> {
35    blobdir: &'a Path,
36
37    /// The name of the file on the disc.
38    /// Note that this is NOT the user-visible filename,
39    /// which is only stored in Param::Filename on the message.
40    name: String,
41}
42
43#[derive(Debug, Clone)]
44enum ImageOutputFormat {
45    Png,
46    Jpeg { quality: u8 },
47}
48
49impl<'a> BlobObject<'a> {
50    /// Creates a blob object by copying or renaming an existing file.
51    /// If the source file is already in the blobdir, it will be renamed,
52    /// otherwise it will be copied to the blobdir first.
53    ///
54    /// In order to deduplicate files that contain the same data,
55    /// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
56    /// The `original_name` param is only used to get the extension.
57    ///
58    /// This is done in a in way which avoids race-conditions when multiple files are
59    /// concurrently created.
60    pub fn create_and_deduplicate(
61        context: &'a Context,
62        src: &Path,
63        original_name: &Path,
64    ) -> Result<BlobObject<'a>> {
65        // `create_and_deduplicate{_from_bytes}()` do blocking I/O, but can still be called
66        // from an async context thanks to `block_in_place()`.
67        // Tokio's "async" I/O functions are also just thin wrappers around the blocking I/O syscalls,
68        // so we are doing essentially the same here.
69        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                    // Maybe the blobdir didn't exist
84                    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            // This will also replace an already-existing file.
109            // Renaming is atomic, so this will avoid race conditions.
110            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    /// Creates a new blob object with the file contents in `data`.
118    /// In order to deduplicate files that contain the same data,
119    /// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
120    /// The `original_name` param is only used to get the extension.
121    ///
122    /// The `data` will be written into the file without race-conditions.
123    ///
124    /// This function does blocking I/O, but it can still be called from an async context
125    /// because `block_in_place()` is used to leave the async runtime if necessary.
126    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                // Maybe the blobdir didn't exist
136                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    /// Returns a [BlobObject] for an existing blob from a path.
145    ///
146    /// The path must designate a file directly in the blobdir and
147    /// must use a valid blob name.  That is after sanitisation the
148    /// name must still be the same, that means it must be valid UTF-8
149    /// and not have any special characters in it.
150    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    /// Returns a [BlobObject] for an existing blob.
162    ///
163    /// The `name` may optionally be prefixed with the `$BLOBDIR/`
164    /// prefixed, as returned by [BlobObject::as_name].  This is how
165    /// you want to create a [BlobObject] for a filename read from the
166    /// database.
167    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    /// Returns the absolute path to the blob in the filesystem.
182    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    /// Returns the blob name, as stored in the database.
188    ///
189    /// This returns the blob in the `$BLOBDIR/<name>` format used in
190    /// the database.  Do not use this unless you're about to store
191    /// this string in the database or [Params].  Eventually even
192    /// those conversions should be handled by the type system.
193    ///
194    /// Note that this is NOT the user-visible filename,
195    /// which is only stored in Param::Filename on the message.
196    ///
197    #[allow(rustdoc::private_intra_doc_links)]
198    /// [Params]: crate::param::Params
199    pub fn as_name(&self) -> &str {
200        &self.name
201    }
202
203    /// Returns the extension of the blob.
204    ///
205    /// If a blob's filename has an extension, it is always guaranteed
206    /// to be lowercase.
207    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    /// Checks whether a name is a valid blob name.
213    ///
214    /// This is slightly less strict than stanitise_name, presumably
215    /// someone already created a file with such a name so we just
216    /// ensure it's not actually a path in disguise.
217    ///
218    /// Acceptible blob name always have to be valid utf-8.
219    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    /// Returns path to the stored Base64-decoded blob.
233    ///
234    /// If `data` represents an image of known format, this adds the corresponding extension.
235    ///
236    /// Even though this function is not async, it's OK to call it from an async context.
237    ///
238    /// Returns an error if there is an I/O problem,
239    /// but in case of a failure to decode base64 returns `Ok(None)`.
240    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    /// Recode image to avatar size.
258    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, // The name of an avatar doesn't matter
276            viewtype, max_wh, max_bytes, is_avatar,
277        )?;
278
279        Ok(())
280    }
281
282    /// Checks or recodes an image pointed by the [BlobObject] so that it fits into limits on the
283    /// image width, height and file size specified by the config.
284    ///
285    /// Recoding is only done for [`Viewtype::Image`]. For [`Viewtype::File`], if it's a correct
286    /// image, `*viewtype` is set to [`Viewtype::Image`].
287    ///
288    /// On some platforms images are passed to Core as [`Viewtype::Sticker`]. We recheck if the
289    /// image is a true sticker assuming that it must have at least one fully transparent corner,
290    /// otherwise `*viewtype` is set to [`Viewtype::Image`].
291    pub async fn check_or_recode_image(
292        &mut self,
293        context: &Context,
294        name: Option<String>,
295        viewtype: &mut Viewtype,
296    ) -> Result<String> {
297        let (max_wh, max_bytes) =
298            match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
299                .unwrap_or_default()
300            {
301                MediaQuality::Balanced => (
302                    constants::BALANCED_IMAGE_SIZE,
303                    constants::BALANCED_IMAGE_BYTES,
304                ),
305                MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
306            };
307        let is_avatar = false;
308        self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar)
309    }
310
311    /// Checks or recodes the image so that it fits into limits on width/height and/or byte size.
312    ///
313    /// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `max_wh` and proceeds
314    /// with the result (even if `max_bytes` is still exceeded).
315    ///
316    /// If `is_avatar`, the resolution will be reduced in a loop until the image fits `max_bytes`.
317    ///
318    /// This modifies the blob object in-place.
319    ///
320    /// Additionally, if you pass the user-visible filename as `name`
321    /// then the updated user-visible filename will be returned;
322    /// this may be necessary because the format may be changed to JPG,
323    /// i.e. "image.png" -> "image.jpg".
324    #[expect(clippy::arithmetic_side_effects)]
325    fn check_or_recode_to_size(
326        &mut self,
327        context: &Context,
328        name: Option<String>,
329        viewtype: &mut Viewtype,
330        max_wh: u32,
331        max_bytes: usize,
332        is_avatar: bool,
333    ) -> Result<String> {
334        // Add white background only to avatars to spare the CPU.
335        let mut add_white_bg = is_avatar;
336        let mut no_exif = false;
337        let no_exif_ref = &mut no_exif;
338        let mut name = name.unwrap_or_else(|| self.name.clone());
339        let original_name = name.clone();
340        let vt = &mut *viewtype;
341        let res: Result<String> = tokio::task::block_in_place(move || {
342            let mut file = std::fs::File::open(self.to_abs_path())?;
343            let (nr_bytes, exif) = image_metadata(&file)?;
344            *no_exif_ref = exif.is_none();
345            // It's strange that BufReader modifies a file position while it takes a non-mut
346            // reference. Ok, just rewind it.
347            file.rewind()?;
348            let imgreader = ImageReader::new(std::io::BufReader::new(&file)).with_guessed_format();
349            let imgreader = match imgreader {
350                Ok(ir) => ir,
351                _ => {
352                    file.rewind()?;
353                    ImageReader::with_format(
354                        std::io::BufReader::new(&file),
355                        ImageFormat::from_path(self.to_abs_path())?,
356                    )
357                }
358            };
359            let fmt = imgreader.format().context("Unknown format")?;
360            if *vt == Viewtype::File {
361                *vt = Viewtype::Image;
362                return Ok(name);
363            }
364            let mut img = imgreader.decode().context("image decode failure")?;
365            let orientation = exif.as_ref().map(|exif| exif_orientation(exif, context));
366            let mut encoded = Vec::new();
367
368            if *vt == Viewtype::Sticker {
369                let x_max = img.width().saturating_sub(1);
370                let y_max = img.height().saturating_sub(1);
371                if !img.in_bounds(x_max, y_max)
372                    || !(img.get_pixel(0, 0).0[3] == 0
373                        || img.get_pixel(x_max, 0).0[3] == 0
374                        || img.get_pixel(0, y_max).0[3] == 0
375                        || img.get_pixel(x_max, y_max).0[3] == 0)
376                {
377                    *vt = Viewtype::Image;
378                } else {
379                    // Core doesn't auto-assign `Viewtype::Sticker` to messages and stickers coming
380                    // from UIs shouldn't contain sensitive Exif info.
381                    return Ok(name);
382                }
383            }
384
385            img = match orientation {
386                Some(90) => img.rotate90(),
387                Some(180) => img.rotate180(),
388                Some(270) => img.rotate270(),
389                _ => img,
390            };
391
392            // max_wh is the maximum image width and height, i.e. the resolution-limit.
393            // target_wh target-resolution for resizing the image.
394            let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
395            let mut target_wh = if exceeds_wh {
396                max_wh
397            } else {
398                max(img.width(), img.height())
399            };
400            let exceeds_max_bytes = nr_bytes > max_bytes as u64;
401
402            let jpeg_quality = 75;
403            let ofmt = match fmt {
404                ImageFormat::Png if !exceeds_max_bytes => ImageOutputFormat::Png,
405                ImageFormat::Jpeg => {
406                    add_white_bg = false;
407                    ImageOutputFormat::Jpeg {
408                        quality: jpeg_quality,
409                    }
410                }
411                _ => ImageOutputFormat::Jpeg {
412                    quality: jpeg_quality,
413                },
414            };
415            // We need to rewrite images with Exif to remove metadata such as location,
416            // camera model, etc.
417            //
418            // TODO: Fix lost animation and transparency when recoding using the `image` crate. And
419            // also `Viewtype::Gif` (maybe renamed to `Animation`) should be used for animated
420            // images.
421            let do_scale = exceeds_max_bytes
422                || is_avatar
423                    && (exceeds_wh
424                        || exif.is_some() && {
425                            if mem::take(&mut add_white_bg) {
426                                self::add_white_bg(&mut img);
427                            }
428                            encoded_img_exceeds_bytes(
429                                context,
430                                &img,
431                                ofmt.clone(),
432                                max_bytes,
433                                &mut encoded,
434                            )?
435                        });
436
437            if do_scale {
438                loop {
439                    if mem::take(&mut add_white_bg) {
440                        self::add_white_bg(&mut img);
441                    }
442
443                    // resize() results in often slightly better quality,
444                    // however, comes at high price of being 4+ times slower than thumbnail().
445                    // for a typical camera image that is sent, this may be a change from "instant" (500ms) to "long time waiting" (3s).
446                    // as we do not have recoding in background while chat has already a preview,
447                    // we vote for speed.
448                    // exception is the avatar image: this is far more often sent than recoded,
449                    // usually has less pixels by cropping, UI that needs to wait anyways,
450                    // and also benefits from slightly better (5%) encoding of Triangle-filtered images.
451                    let new_img = if is_avatar {
452                        img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle)
453                    } else {
454                        img.thumbnail(target_wh, target_wh)
455                    };
456
457                    if encoded_img_exceeds_bytes(
458                        context,
459                        &new_img,
460                        ofmt.clone(),
461                        max_bytes,
462                        &mut encoded,
463                    )? && is_avatar
464                    {
465                        if target_wh < 20 {
466                            return Err(format_err!(
467                                "Failed to scale image to below {max_bytes}B.",
468                            ));
469                        }
470
471                        target_wh = target_wh * 2 / 3;
472                    } else {
473                        info!(
474                            context,
475                            "Final scaled-down image size: {}B ({}px).",
476                            encoded.len(),
477                            target_wh
478                        );
479                        break;
480                    }
481                }
482            }
483
484            if do_scale || exif.is_some() {
485                // The file format is JPEG/PNG now, we may have to change the file extension
486                if !matches!(fmt, ImageFormat::Jpeg)
487                    && matches!(ofmt, ImageOutputFormat::Jpeg { .. })
488                {
489                    name = Path::new(&name)
490                        .with_extension("jpg")
491                        .to_string_lossy()
492                        .into_owned();
493                }
494
495                if encoded.is_empty() {
496                    if mem::take(&mut add_white_bg) {
497                        self::add_white_bg(&mut img);
498                    }
499                    encode_img(&img, ofmt, &mut encoded)?;
500                }
501
502                self.name = BlobObject::create_and_deduplicate_from_bytes(context, &encoded, &name)
503                    .context("failed to write recoded blob to file")?
504                    .name;
505            }
506
507            Ok(name)
508        });
509        match res {
510            Ok(_) => res,
511            Err(err) => {
512                if !is_avatar && no_exif {
513                    error!(
514                        context,
515                        "Cannot check/recode image, using original data: {err:#}.",
516                    );
517                    *viewtype = Viewtype::File;
518                    Ok(original_name)
519                } else {
520                    Err(err)
521                }
522            }
523        }
524    }
525}
526
527fn file_hash(src: &Path) -> Result<blake3::Hash> {
528    ensure!(
529        !src.starts_with("$BLOBDIR/"),
530        "Use `get_abs_path()` to get the absolute path of the blobfile"
531    );
532    let mut hasher = blake3::Hasher::new();
533    let mut src_file = std::fs::File::open(src)
534        .with_context(|| format!("Failed to open file {}", src.display()))?;
535    hasher
536        .update_reader(&mut src_file)
537        .context("update_reader")?;
538    let hash = hasher.finalize();
539    Ok(hash)
540}
541
542/// Returns image file size and Exif.
543fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
544    let len = file.metadata()?.len();
545    let mut bufreader = std::io::BufReader::new(file);
546    let exif = exif::Reader::new()
547        .continue_on_error(true)
548        .read_from_container(&mut bufreader)
549        .or_else(|e| e.distill_partial_result(|_errors| {}))
550        .ok();
551    Ok((len, exif))
552}
553
554fn exif_orientation(exif: &exif::Exif, context: &Context) -> i32 {
555    if let Some(orientation) = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
556        // possible orientation values are described at http://sylvana.net/jpegcrop/exif_orientation.html
557        // we only use rotation, in practise, flipping is not used.
558        match orientation.value.get_uint(0) {
559            Some(3) => return 180,
560            Some(6) => return 90,
561            Some(8) => return 270,
562            other => warn!(context, "Exif orientation value ignored: {other:?}."),
563        }
564    }
565    0
566}
567
568/// All files in the blobdir.
569///
570/// This exists so we can have a [`BlobDirIter`] which needs something to own the data of
571/// it's `&Path`.  Use [`BlobDirContents::iter`] to create the iterator.
572///
573/// Additionally pre-allocating this means we get a length for progress report.
574pub(crate) struct BlobDirContents<'a> {
575    inner: Vec<PathBuf>,
576    context: &'a Context,
577}
578
579impl<'a> BlobDirContents<'a> {
580    pub(crate) async fn new(context: &'a Context) -> Result<BlobDirContents<'a>> {
581        let readdir = fs::read_dir(context.get_blobdir()).await?;
582        let inner = ReadDirStream::new(readdir)
583            .filter_map(|entry| async move {
584                match entry {
585                    Ok(entry) => Some(entry),
586                    Err(err) => {
587                        error!(context, "Failed to read blob file: {err}.");
588                        None
589                    }
590                }
591            })
592            .filter_map(|entry| async move {
593                match entry.file_type().await.ok()?.is_file() {
594                    true => Some(entry.path()),
595                    false => {
596                        warn!(
597                            context,
598                            "Export: Found blob dir entry {} that is not a file, ignoring.",
599                            entry.path().display()
600                        );
601                        None
602                    }
603                }
604            })
605            .collect()
606            .await;
607        Ok(Self { inner, context })
608    }
609
610    pub(crate) fn iter(&self) -> BlobDirIter<'_> {
611        BlobDirIter::new(self.context, self.inner.iter())
612    }
613}
614
615/// A iterator over all the [`BlobObject`]s in the blobdir.
616pub(crate) struct BlobDirIter<'a> {
617    iter: std::slice::Iter<'a, PathBuf>,
618    context: &'a Context,
619}
620
621impl<'a> BlobDirIter<'a> {
622    fn new(context: &'a Context, iter: std::slice::Iter<'a, PathBuf>) -> BlobDirIter<'a> {
623        Self { iter, context }
624    }
625}
626
627impl<'a> Iterator for BlobDirIter<'a> {
628    type Item = BlobObject<'a>;
629
630    fn next(&mut self) -> Option<Self::Item> {
631        for path in self.iter.by_ref() {
632            // In theory this can error but we'd have corrupted filenames in the blobdir, so
633            // silently skipping them is fine.
634            match BlobObject::from_path(self.context, path) {
635                Ok(blob) => return Some(blob),
636                Err(err) => warn!(self.context, "{err}"),
637            }
638        }
639        None
640    }
641}
642
643impl FusedIterator for BlobDirIter<'_> {}
644
645fn encode_img(
646    img: &DynamicImage,
647    fmt: ImageOutputFormat,
648    encoded: &mut Vec<u8>,
649) -> anyhow::Result<()> {
650    encoded.clear();
651    let mut buf = Cursor::new(encoded);
652    match fmt {
653        ImageOutputFormat::Png => img.write_to(&mut buf, ImageFormat::Png)?,
654        ImageOutputFormat::Jpeg { quality } => {
655            let encoder = JpegEncoder::new_with_quality(&mut buf, quality);
656            // Convert image into RGB8 to avoid the error
657            // "The encoder or decoder for Jpeg does not support the color type Rgba8"
658            // (<https://github.com/image-rs/image/issues/2211>).
659            img.clone().into_rgb8().write_with_encoder(encoder)?;
660        }
661    }
662    Ok(())
663}
664
665fn encoded_img_exceeds_bytes(
666    context: &Context,
667    img: &DynamicImage,
668    fmt: ImageOutputFormat,
669    max_bytes: usize,
670    encoded: &mut Vec<u8>,
671) -> anyhow::Result<bool> {
672    encode_img(img, fmt, encoded)?;
673    if encoded.len() > max_bytes {
674        info!(
675            context,
676            "Image size {}B ({}x{}px) exceeds {}B, need to scale down.",
677            encoded.len(),
678            img.width(),
679            img.height(),
680            max_bytes,
681        );
682        return Ok(true);
683    }
684    Ok(false)
685}
686
687/// Removes transparency from an image using a white background.
688fn add_white_bg(img: &mut DynamicImage) {
689    for y in 0..img.height() {
690        for x in 0..img.width() {
691            let mut p = Rgba([255u8, 255, 255, 255]);
692            p.blend(&img.get_pixel(x, y));
693            img.put_pixel(x, y, p);
694        }
695    }
696}
697
698#[cfg(test)]
699mod blob_tests;