From e8cc541f45b165a8fb547aa17e72f568bfaf4ce1 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 24 Aug 2025 12:08:13 -0400 Subject: [PATCH] chore: move image stuff to axum-image --- Cargo.lock | 17 +- crates/app/Cargo.toml | 5 +- crates/app/src/image.rs | 201 ------------------ crates/app/src/main.rs | 1 - .../app/src/public/html/profile/settings.lisp | 6 +- crates/app/src/public/html/root.lisp | 2 +- crates/app/src/routes/api/v1/ads.rs | 8 +- crates/app/src/routes/api/v1/auth/images.rs | 20 +- .../src/routes/api/v1/communities/emojis.rs | 2 +- .../src/routes/api/v1/communities/images.rs | 10 +- .../src/routes/api/v1/communities/posts.rs | 2 +- .../routes/api/v1/communities/questions.rs | 2 +- crates/app/src/routes/api/v1/mod.rs | 2 +- crates/app/src/routes/api/v1/products.rs | 8 +- crates/app/src/routes/api/v1/uploads.rs | 6 +- 15 files changed, 48 insertions(+), 244 deletions(-) delete mode 100644 crates/app/src/image.rs diff --git a/Cargo.lock b/Cargo.lock index b24d466..8812118 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,20 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-image" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de35bd1017c1de1f86ceec9abf59e33670dfced76bd6ef756f469ac4588af4f7" +dependencies = [ + "axum", + "axum-extra", + "image", + "serde", + "serde_json", + "webp", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -3342,12 +3356,12 @@ dependencies = [ "async-stripe", "axum", "axum-extra", + "axum-image", "cf-turnstile", "contrasted", "cookie", "emojis", "futures-util", - "image", "mime_guess", "nanoneo", "pathbufd", @@ -3363,7 +3377,6 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "webp", ] [[package]] diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index f817b9c..e808140 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -21,12 +21,11 @@ tower-http = { version = "0.6.6", features = [ ] } axum = { version = "0.8.4", features = ["macros", "ws"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } -axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } +axum-extra = { version = "0.10.1", features = ["cookie"] } ammonia = "4.1.1" tetratto-shared = { path = "../shared" } tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } -image = "0.25.6" reqwest = { version = "0.12.23", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.142" @@ -43,6 +42,6 @@ async-stripe = { version = "0.41.0", features = [ "connect", ] } emojis = "0.7.2" -webp = "0.3.0" nanoneo = "0.2.0" cookie = "0.18.1" +axum-image = "0.1.1" diff --git a/crates/app/src/image.rs b/crates/app/src/image.rs deleted file mode 100644 index a6fd32e..0000000 --- a/crates/app/src/image.rs +++ /dev/null @@ -1,201 +0,0 @@ -use axum::{ - body::Bytes, - extract::{FromRequest, Request}, - http::{StatusCode, header::CONTENT_TYPE}, -}; -use axum_extra::extract::Multipart; -use serde::de::DeserializeOwned; -use std::{fs::File, io::BufWriter}; - -/// An image extractor accepting: -/// * `multipart/form-data` -/// * `image/png` -/// * `image/jpeg` -/// * `image/avif` -/// * `image/webp` -pub struct Image(pub Bytes, pub String); - -impl FromRequest for Image -where - Bytes: FromRequest, - S: Send + Sync, -{ - type Rejection = StatusCode; - - async fn from_request(req: Request, state: &S) -> Result { - let Some(content_type) = req.headers().get(CONTENT_TYPE) else { - return Err(StatusCode::BAD_REQUEST); - }; - - let content_type = content_type.to_str().unwrap(); - let content_type_string = content_type.to_string(); - - let body = if content_type.starts_with("multipart/form-data") { - let mut multipart = Multipart::from_request(req, state) - .await - .map_err(|_| StatusCode::BAD_REQUEST)?; - - let Ok(Some(field)) = multipart.next_field().await else { - return Err(StatusCode::BAD_REQUEST); - }; - - field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)? - } else if (content_type == "image/avif") - | (content_type == "image/jpeg") - | (content_type == "image/png") - | (content_type == "image/webp") - | (content_type == "image/gif") - { - Bytes::from_request(req, state) - .await - .map_err(|_| StatusCode::BAD_REQUEST)? - } else { - return Err(StatusCode::BAD_REQUEST); - }; - - Ok(Self(body, content_type_string)) - } -} - -/// A file extractor accepting: -/// * `multipart/form-data` -/// -/// Will also attempt to parse out the **last** field in the multipart upload -/// as the given struct from JSON. Every other field is put into a vector of bytes, -/// as they are seen as raw binary data. -pub struct JsonMultipart(pub Vec, pub T); - -impl FromRequest for JsonMultipart -where - Bytes: FromRequest, - S: Send + Sync, - T: DeserializeOwned, -{ - type Rejection = (StatusCode, String); - - async fn from_request(req: Request, state: &S) -> Result { - let Some(content_type) = req.headers().get(CONTENT_TYPE) else { - return Err(( - StatusCode::BAD_REQUEST, - "no content type header".to_string(), - )); - }; - - let content_type = content_type.to_str().unwrap(); - - if !content_type.starts_with("multipart/form-data") { - return Err(( - StatusCode::BAD_REQUEST, - "expected multipart/form-data".to_string(), - )); - } - - let mut multipart = Multipart::from_request(req, state).await.map_err(|_| { - ( - StatusCode::BAD_REQUEST, - "could not read multipart".to_string(), - ) - })?; - - let mut body: Vec = { - let mut out = Vec::new(); - - while let Ok(Some(field)) = multipart.next_field().await { - out.push( - field - .bytes() - .await - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?, - ); - } - - out - }; - - let last = match body.pop() { - Some(b) => b, - None => { - return Err(( - StatusCode::BAD_REQUEST, - "could not read json data".to_string(), - )); - } - }; - - let json: T = match serde_json::from_str(&match String::from_utf8(last.to_vec()) { - Ok(s) => s, - Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())), - }) { - Ok(s) => s, - Err(e) => { - return Err((StatusCode::BAD_REQUEST, e.to_string())); - } - }; - - Ok(Self(body, json)) - } -} - -/// Create an image buffer given an input of `bytes`. -pub fn save_buffer(path: &str, bytes: Vec, format: image::ImageFormat) -> std::io::Result<()> { - let pre_img_buffer = match image::load_from_memory(&bytes) { - Ok(i) => i, - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Image failed", - )); - } - }; - - let file = File::create(path)?; - let mut writer = BufWriter::new(file); - - if pre_img_buffer.write_to(&mut writer, format).is_err() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - "Image conversion failed", - )); - }; - - Ok(()) -} - -const WEBP_ENCODE_QUALITY: f32 = 85.0; - -/// Create a WEBP image buffer given an input of `bytes`. -pub fn save_webp_buffer(path: &str, bytes: Vec, quality: Option) -> std::io::Result<()> { - let img = match image::load_from_memory(&bytes) { - Ok(i) => i, - Err(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Image failed", - )); - } - }; - - let encoder = match webp::Encoder::from_image(&img) { - Ok(e) => e, - Err(e) => { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )); - } - }; - - let mem = encoder.encode(match quality { - Some(q) => q, - None => WEBP_ENCODE_QUALITY, - }); - - if std::fs::write(path, &*mem).is_err() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - "Image conversion failed", - )); - }; - - Ok(()) -} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b0098b7..50651ae 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -3,7 +3,6 @@ #![doc(html_logo_url = "/public/tetratto_bunny.webp")] mod assets; mod cookie; -mod image; mod macros; mod routes; mod sanitize; diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 1834efd..8f69ff3 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -676,7 +676,7 @@ (text "{{ text \"general:action.view\" }}"))) (button ("class" "raised small red") - ("onclick" "remove_upload('{{ upload.id }}')") + ("onclick" "remove_upload('{{ upload.bucket }}', '{{ upload.id }}')") (text "{{ icon \"x\" }}") (span (text "{{ text \"stacks:label.remove\" }}"))))) @@ -701,7 +701,7 @@ (str (text "general:action.save")))))) (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (script - (text "globalThis.remove_upload = async (id) => { + (text "globalThis.remove_upload = async (bucket, id) => { if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this? This action is permanent.\", @@ -710,7 +710,7 @@ return; } - fetch(`/api/v1/uploads/${id}`, { + fetch(`/api/v1/uploads/${bucket}/${id}`, { method: \"DELETE\", }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 68bfc13..9d67642 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -32,7 +32,7 @@ ns_store: {}, classes: {}, service_hosts: { - buckets: \"{{ config.service_hosts.buckets }}\", + buckets: \"{{ config.service_hosts.buckets|safe }}\", } }; diff --git a/crates/app/src/routes/api/v1/ads.rs b/crates/app/src/routes/api/v1/ads.rs index f2c9744..c99ac25 100644 --- a/crates/app/src/routes/api/v1/ads.rs +++ b/crates/app/src/routes/api/v1/ads.rs @@ -1,9 +1,4 @@ -use crate::{ - cookie::CookieJar, - get_user_from_token, - image::{save_webp_buffer, JsonMultipart}, - State, -}; +use crate::{cookie::CookieJar, get_user_from_token, State}; use axum::{ extract::Path, response::{Html, IntoResponse}, @@ -17,6 +12,7 @@ use tetratto_core::model::{ ApiReturn, Error, }; use super::{CreateAd, UpdateAdIsRunning}; +use axum_image::{encode::save_webp_buffer, extract::JsonMultipart}; const MAXIMUM_AD_FILE_SIZE: usize = 2_097_152; diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 1e6087d..0d257e7 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -7,12 +7,8 @@ use tetratto_core::model::{ uploads::{MediaType, MediaUpload}, ApiReturn, Error, }; - -use crate::{ - State, - image::{Image, save_buffer}, - get_user_from_token, -}; +use crate::{State, get_user_from_token}; +use axum_image::{encode::save_image_buffer, extract::Image}; pub fn read_image(path: PathBufD) -> Vec { let mut bytes = Vec::new(); @@ -102,7 +98,11 @@ pub async fn upload_avatar_request( } // upload image - match save_buffer(&path.to_string(), img.0.to_vec(), image::ImageFormat::Avif) { + match save_image_buffer( + &path.to_string(), + img.0.to_vec(), + axum_image::ImageFormat::Avif, + ) { Ok(_) => Json(ApiReturn { ok: true, message: "Avatar uploaded. It might take a bit to update".to_string(), @@ -187,7 +187,11 @@ pub async fn upload_banner_request( } // upload image - match save_buffer(&path.to_string(), img.0.to_vec(), image::ImageFormat::Avif) { + match save_image_buffer( + &path.to_string(), + img.0.to_vec(), + axum_image::ImageFormat::Avif, + ) { Ok(_) => Json(ApiReturn { ok: true, message: "Banner uploaded. It might take a bit to update".to_string(), diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 8417658..3b23b4b 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -2,7 +2,6 @@ use std::fs::exists; use pathbufd::PathBufD; use crate::{ get_user_from_token, - image::{save_webp_buffer, Image}, routes::api::v1::{auth::images::read_image, UpdateEmojiName}, State, }; @@ -13,6 +12,7 @@ use tetratto_core::model::{ uploads::{CustomEmoji, MediaType, MediaUpload}, ApiReturn, Error, }; +use axum_image::{encode::save_webp_buffer, extract::Image}; /// Expand a unicode emoji into its Gemoji shortcode. pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 9f32ef3..bfbf5e7 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -3,13 +3,11 @@ use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use std::fs::exists; use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth}; - use crate::{ - State, - image::{Image, save_buffer}, - get_user_from_token, + State, get_user_from_token, routes::api::v1::auth::images::{MAXIMUM_FILE_SIZE, read_image}, }; +use axum_image::{encode::save_image_buffer, extract::Image}; /// Get a community's avatar image /// `/api/v1/communities/{id}/avatar` @@ -146,7 +144,7 @@ pub async fn upload_avatar_request( bytes.push(byte); } - match save_buffer(&path, bytes, image::ImageFormat::Avif) { + match save_image_buffer(&path, bytes, axum_image::ImageFormat::Avif) { Ok(_) => Json(ApiReturn { ok: true, message: "Avatar uploaded. It might take a bit to update".to_string(), @@ -201,7 +199,7 @@ pub async fn upload_banner_request( bytes.push(byte); } - match save_buffer(&path, bytes, image::ImageFormat::Avif) { + match save_image_buffer(&path, bytes, axum_image::ImageFormat::Avif) { Ok(_) => Json(ApiReturn { ok: true, message: "Banner uploaded. It might take a bit to update".to_string(), diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 02f2453..7b88c71 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -16,7 +16,6 @@ use tetratto_core::model::{ }; use crate::{ check_user_blocked_or_private, get_user_from_token, - image::{save_webp_buffer, JsonMultipart}, routes::{ api::v1::{ CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, UpdatePostIsOpen, @@ -26,6 +25,7 @@ use crate::{ }, State, }; +use axum_image::{encode::save_webp_buffer, extract::JsonMultipart}; // maximum file dimensions: 2048x2048px (4 MiB) pub const MAXIMUM_FILE_SIZE: usize = 4194304; diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index de6cbb2..5488532 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -15,10 +15,10 @@ use tetratto_core::model::{ }; use crate::{ get_user_from_token, - image::JsonMultipart, routes::{api::v1::CreateQuestion, pages::PaginatedQuery}, State, }; +use axum_image::extract::JsonMultipart; pub async fn create_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5f94bd5..2724e39 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -704,7 +704,7 @@ pub fn routes() -> Router { delete(notes::delete_by_dir_request), ) // uploads - .route("/uploads/{id}", delete(uploads::delete_request)) + .route("/uploads/{bucket}/{id}", delete(uploads::delete_request)) .route("/uploads/{id}/alt", post(uploads::update_alt_request)) // services .route("/services", get(services::list_request)) diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index bc24c9d..afa20e2 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,9 +1,4 @@ -use crate::{ - cookie::CookieJar, - get_user_from_token, - image::{save_webp_buffer, JsonMultipart}, - State, -}; +use crate::{cookie::CookieJar, get_user_from_token, State}; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use tetratto_core::model::{ economy::{Product, ProductFulfillmentMethod}, @@ -17,6 +12,7 @@ use super::{ UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads, }; +use axum_image::{encode::save_webp_buffer, extract::JsonMultipart}; pub async fn create_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 8ddc307..146663f 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -6,7 +6,7 @@ use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( jar: CookieJar, Extension(data): Extension, - Path(id): Path, + Path((bucket, id)): Path<(String, usize)>, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) { @@ -14,7 +14,7 @@ pub async fn delete_request( None => return Json(Error::NotAllowed.into()), }; - let upload = match data.2.get_upload_by_id(id).await { + let upload = match data.2.get_upload_by_id_bucket(id, &bucket).await { Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; @@ -23,7 +23,7 @@ pub async fn delete_request( return Json(Error::NotAllowed.into()); } - match data.2.delete_upload(id).await { + match data.2.delete_upload_with_bucket(id, &bucket).await { Ok(_) => Json(ApiReturn { ok: true, message: "Upload deleted".to_string(),