Files
AstralRinth/apps/labrinth/src/util/img.rs
aecsocket 4cd8ccd319 Taplo and typos in CI, TOML cleanup (#4510)
* Taplo and typos in CI

* Clean up Cargo.toml files

* Fix CI

* Fix CI

* Run typos in CI

* Loosen typos a bit

* Fix typos

* Fix taplo

* Switch to Tombi

* Fix Tombi errors

* Remove unused typos config

* Tombi fmt

* Remove extraneous cargo fmt

* fix typos
2025-10-12 20:18:38 +00:00

229 lines
6.6 KiB
Rust

use crate::database;
use crate::database::models::image_item;
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::images::ImageContext;
use crate::routes::ApiError;
use color_thief::ColorFormat;
use hex::ToHex;
use image::imageops::FilterType;
use image::{
DynamicImage, EncodableLayout, GenericImageView, ImageError, ImageFormat,
};
use sha1::Digest;
use std::io::Cursor;
use webp::Encoder;
pub fn get_color_from_img(data: &[u8]) -> Result<Option<u32>, ImageError> {
let image = image::load_from_memory(data)?
.resize(256, 256, FilterType::Nearest)
.crop_imm(128, 128, 64, 64);
let color = color_thief::get_palette(
image.to_rgb8().as_bytes(),
ColorFormat::Rgb,
10,
2,
)
.ok()
.and_then(|x| x.first().copied())
.map(|x| ((x.r as u32) << 16) | ((x.g as u32) << 8) | (x.b as u32));
Ok(color)
}
pub struct UploadImageResult {
pub url: String,
pub url_path: String,
pub raw_url: String,
pub raw_url_path: String,
pub publicity: FileHostPublicity,
pub color: Option<u32>,
}
pub async fn upload_image_optimized(
upload_folder: &str,
publicity: FileHostPublicity,
bytes: bytes::Bytes,
file_extension: &str,
target_width: Option<u32>,
min_aspect_ratio: Option<f32>,
file_host: &dyn FileHost,
) -> Result<UploadImageResult, ApiError> {
let content_type = crate::util::ext::get_image_content_type(file_extension)
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Invalid format for image: {file_extension}"
))
})?;
let cdn_url = dotenvy::var("CDN_URL")?;
let hash = sha1::Sha1::digest(&bytes).encode_hex::<String>();
let (processed_image, processed_image_ext) = process_image(
bytes.clone(),
content_type,
target_width,
min_aspect_ratio,
)?;
let color = get_color_from_img(&bytes)?;
// Only upload the processed image if it's smaller than the original
let processed_upload_data = if processed_image.len() < bytes.len() {
Some(
file_host
.upload_file(
content_type,
&format!(
"{}/{}_{}.{}",
upload_folder,
hash,
target_width.unwrap_or(0),
processed_image_ext
),
publicity,
processed_image,
)
.await?,
)
} else {
None
};
let upload_data = file_host
.upload_file(
content_type,
&format!("{upload_folder}/{hash}.{file_extension}"),
publicity,
bytes,
)
.await?;
let url = format!("{}/{}", cdn_url, upload_data.file_name);
Ok(UploadImageResult {
url: processed_upload_data.clone().map_or_else(
|| url.clone(),
|x| format!("{}/{}", cdn_url, x.file_name),
),
url_path: processed_upload_data
.map_or_else(|| upload_data.file_name.clone(), |x| x.file_name),
raw_url: url,
raw_url_path: upload_data.file_name,
publicity,
color,
})
}
fn process_image(
image_bytes: bytes::Bytes,
content_type: &str,
target_width: Option<u32>,
min_aspect_ratio: Option<f32>,
) -> Result<(bytes::Bytes, String), ImageError> {
if content_type.to_lowercase() == "image/gif" {
return Ok((image_bytes, "gif".to_string()));
}
let mut img = image::load_from_memory(&image_bytes)?;
let webp_bytes = convert_to_webp(&img)?;
img = image::load_from_memory(&webp_bytes)?;
// Resize the image
let (orig_width, orig_height) = img.dimensions();
let aspect_ratio = orig_width as f32 / orig_height as f32;
if let Some(target_width) = target_width
&& img.width() > target_width
{
let new_height = (target_width as f32 / aspect_ratio).round() as u32;
img = img.resize(target_width, new_height, FilterType::Lanczos3);
}
if let Some(min_aspect_ratio) = min_aspect_ratio {
// Crop if necessary
if aspect_ratio < min_aspect_ratio {
let crop_height =
(img.width() as f32 / min_aspect_ratio).round() as u32;
let y_offset = (img.height() - crop_height) / 2;
img = img.crop_imm(0, y_offset, img.width(), crop_height);
}
}
// Optimize and compress
let mut output = Vec::new();
img.write_to(&mut Cursor::new(&mut output), ImageFormat::WebP)?;
Ok((bytes::Bytes::from(output), "webp".to_string()))
}
fn convert_to_webp(img: &DynamicImage) -> Result<Vec<u8>, ImageError> {
let rgba = img.to_rgba8();
let encoder = Encoder::from_rgba(&rgba, img.width(), img.height());
let webp = encoder.encode(75.0); // Quality factor: 0-100, 75 is a good balance
Ok(webp.to_vec())
}
pub async fn delete_old_images(
image_url: Option<String>,
raw_image_url: Option<String>,
publicity: FileHostPublicity,
file_host: &dyn FileHost,
) -> Result<(), ApiError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let cdn_url_start = format!("{cdn_url}/");
if let Some(image_url) = image_url {
let name = image_url.split(&cdn_url_start).nth(1);
if let Some(icon_path) = name {
file_host.delete_file(icon_path, publicity).await?;
}
}
if let Some(raw_image_url) = raw_image_url {
let name = raw_image_url.split(&cdn_url_start).nth(1);
if let Some(icon_path) = name {
file_host.delete_file(icon_path, publicity).await?;
}
}
Ok(())
}
// check changes to associated images
// if they no longer exist in the String list, delete them
// Eg: if description is modified and no longer contains a link to an image
pub async fn delete_unused_images(
context: ImageContext,
reference_strings: Vec<&str>,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> Result<(), ApiError> {
let uploaded_images =
database::models::DBImage::get_many_contexted(context, transaction)
.await?;
for image in uploaded_images {
let mut should_delete = true;
for reference in &reference_strings {
if image.url.contains(reference) {
should_delete = false;
break;
}
}
if should_delete {
image_item::DBImage::remove(image.id, transaction, redis).await?;
image_item::DBImage::clear_cache(image.id, redis).await?;
}
}
Ok(())
}