use axum::body::Bytes;
use image::{
codecs::jpeg::JpegEncoder, imageops::FilterType, metadata::Orientation, DynamicImage,
ImageDecoder, ImageError, ImageFormat, ImageReader, Limits, Rgb, RgbImage,
};
use std::io::Cursor;
use tokio::sync::Semaphore;
pub const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024;
pub const MAX_EDGE: u32 = 2048;
const JPEG_QUALITY: u8 = 85;
static PROCESSING: Semaphore = Semaphore::const_new(1);
#[derive(Debug)]
pub enum PrepareError {
Heif,
Unsupported,
Invalid(String),
TooLarge,
}
impl std::fmt::Display for PrepareError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PrepareError::Heif => f.write_str(
"HEIC and AVIF photos can't be read. Upload from the phone's own browser, \
which converts them to JPEG, or set the iPhone camera to Most Compatible \
(Settings, Camera, Formats).",
),
PrepareError::Unsupported => {
f.write_str("Only JPEG, PNG and WebP pictures are supported.")
}
PrepareError::Invalid(e) => write!(f, "The picture could not be read: {e}"),
PrepareError::TooLarge => f.write_str("The picture has too many pixels to process."),
}
}
}
pub async fn prepare_async(bytes: Bytes) -> Result<Vec<u8>, PrepareError> {
let permit = PROCESSING
.acquire()
.await
.expect("the processing semaphore is never closed");
tokio::task::spawn_blocking(move || {
let _permit = permit;
prepare(&bytes)
})
.await
.map_err(|e| PrepareError::Invalid(e.to_string()))?
}
pub fn prepare(bytes: &[u8]) -> Result<Vec<u8>, PrepareError> {
if is_heif(bytes) {
return Err(PrepareError::Heif);
}
let format = image::guess_format(bytes).map_err(|_| PrepareError::Unsupported)?;
if !matches!(
format,
ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP
) {
return Err(PrepareError::Unsupported);
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
reader.limits(Limits::default());
let mut decoder = reader.into_decoder().map_err(decode_error)?;
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
let image = DynamicImage::from_decoder(decoder).map_err(decode_error)?;
let fits = image.width() <= MAX_EDGE && image.height() <= MAX_EDGE;
let mut picture = DynamicImage::ImageRgb8(flatten(image));
if !fits {
picture = picture.resize(MAX_EDGE, MAX_EDGE, FilterType::CatmullRom);
}
picture.apply_orientation(orientation);
encode_jpeg(&picture.into_rgb8())
}
fn is_heif(bytes: &[u8]) -> bool {
const BRANDS: [&[u8; 4]; 10] = [
b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis",
];
bytes.len() >= 12 && &bytes[4..8] == b"ftyp" && BRANDS.iter().any(|b| &bytes[8..12] == *b)
}
fn decode_error(e: ImageError) -> PrepareError {
match e {
ImageError::Limits(_) => PrepareError::TooLarge,
e => PrepareError::Invalid(e.to_string()),
}
}
fn flatten(image: DynamicImage) -> RgbImage {
if !image.color().has_alpha() {
return image.into_rgb8();
}
let rgba = image.into_rgba8();
RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| {
let [r, g, b, a] = rgba.get_pixel(x, y).0;
let a = u32::from(a);
let over_white = |c: u8| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8;
Rgb([over_white(r), over_white(g), over_white(b)])
})
}
fn encode_jpeg(picture: &RgbImage) -> Result<Vec<u8>, PrepareError> {
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY)
.encode_image(picture)
.map_err(|e| PrepareError::Invalid(e.to_string()))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageEncoder, Rgba, RgbaImage};
fn gradient(width: u32, height: u32) -> RgbImage {
RgbImage::from_fn(width, height, |x, y| {
Rgb([
(x * 7 % 256) as u8,
(y * 5 % 256) as u8,
((x + y) % 256) as u8,
])
})
}
fn encode(picture: DynamicImage, format: ImageFormat) -> Vec<u8> {
let mut out = Cursor::new(Vec::new());
picture.write_to(&mut out, format).unwrap();
out.into_inner()
}
fn jpeg(picture: &RgbImage, quality: u8, exif: Option<Vec<u8>>) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
if let Some(exif) = exif {
encoder.set_exif_metadata(exif).unwrap();
}
encoder.encode_image(picture).unwrap();
out
}
fn exif_orientation(value: u16) -> Vec<u8> {
let mut exif = b"MM\0\x2a\0\0\0\x08".to_vec();
exif.extend_from_slice(&1u16.to_be_bytes());
exif.extend_from_slice(&0x0112u16.to_be_bytes());
exif.extend_from_slice(&3u16.to_be_bytes());
exif.extend_from_slice(&1u32.to_be_bytes());
exif.extend_from_slice(&value.to_be_bytes());
exif.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
exif
}
fn decoded(bytes: &[u8]) -> DynamicImage {
assert_eq!(image::guess_format(bytes).unwrap(), ImageFormat::Jpeg);
image::load_from_memory(bytes).unwrap()
}
#[test]
fn an_oversized_picture_is_scaled_to_the_max_edge() {
let input = jpeg(&gradient(3000, 1500), 90, None);
let out = decoded(&prepare(&input).unwrap());
assert_eq!((out.width(), out.height()), (MAX_EDGE, MAX_EDGE / 2));
}
#[test]
fn a_png_is_stored_as_jpeg() {
let input = encode(DynamicImage::ImageRgb8(gradient(64, 32)), ImageFormat::Png);
let out = decoded(&prepare(&input).unwrap());
assert_eq!((out.width(), out.height()), (64, 32));
}
#[test]
fn a_webp_is_stored_as_jpeg() {
let input = encode(DynamicImage::ImageRgb8(gradient(64, 32)), ImageFormat::WebP);
let out = decoded(&prepare(&input).unwrap());
assert_eq!((out.width(), out.height()), (64, 32));
}
#[test]
fn transparency_is_laid_over_white() {
let clear = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0]));
let input = encode(DynamicImage::ImageRgba8(clear), ImageFormat::Png);
let out = decoded(&prepare(&input).unwrap()).into_rgb8();
for pixel in out.pixels() {
assert!(
pixel.0.iter().all(|&c| c >= 250),
"expected white, got {pixel:?}"
);
}
}
#[test]
fn every_upload_is_re_encoded() {
const SMUGGLED: &[u8] = b"<script>alert(1)</script>";
let mut input = jpeg(&gradient(64, 64), 40, Some(exif_orientation(1)));
input.extend_from_slice(SMUGGLED);
let out = prepare(&input).unwrap();
assert_ne!(out, input);
assert!(
!out.windows(SMUGGLED.len()).any(|w| w == SMUGGLED),
"trailing data survived"
);
assert!(
!out.windows(4).any(|w| w == b"Exif"),
"the Exif segment survived"
);
let out = decoded(&out);
assert_eq!((out.width(), out.height()), (64, 64));
}
#[test]
fn the_exif_orientation_is_applied() {
let input = jpeg(&gradient(40, 20), 40, Some(exif_orientation(6)));
let out = decoded(&prepare(&input).unwrap());
assert_eq!((out.width(), out.height()), (20, 40));
}
#[test]
fn heic_and_avif_are_recognised() {
for brand in [b"heic", b"mif1", b"avif"] {
let mut input = b"\0\0\0\x18ftyp".to_vec();
input.extend_from_slice(brand);
input.extend_from_slice(b"\0\0\0\0mif1heic");
assert!(
matches!(prepare(&input), Err(PrepareError::Heif)),
"{} must be refused as HEIF",
String::from_utf8_lossy(brand)
);
}
}
#[test]
fn other_formats_are_unsupported() {
for input in [&b"GIF89a\x01\0\x01\0\0\0\0;"[..], b"just some text", b""] {
assert!(matches!(prepare(input), Err(PrepareError::Unsupported)));
}
}
#[test]
fn a_truncated_jpeg_is_invalid() {
let input = jpeg(&gradient(64, 64), 90, None);
assert!(matches!(
prepare(&input[..20]),
Err(PrepareError::Invalid(_))
));
}
}