chore: move image stuff to axum-image

This commit is contained in:
trisua 2025-08-24 12:08:13 -04:00
parent dbed2b2457
commit e8cc541f45
15 changed files with 48 additions and 244 deletions

17
Cargo.lock generated
View file

@ -268,6 +268,20 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "axum-macros" name = "axum-macros"
version = "0.5.0" version = "0.5.0"
@ -3342,12 +3356,12 @@ dependencies = [
"async-stripe", "async-stripe",
"axum", "axum",
"axum-extra", "axum-extra",
"axum-image",
"cf-turnstile", "cf-turnstile",
"contrasted", "contrasted",
"cookie", "cookie",
"emojis", "emojis",
"futures-util", "futures-util",
"image",
"mime_guess", "mime_guess",
"nanoneo", "nanoneo",
"pathbufd", "pathbufd",
@ -3363,7 +3377,6 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"webp",
] ]
[[package]] [[package]]

View file

@ -21,12 +21,11 @@ tower-http = { version = "0.6.6", features = [
] } ] }
axum = { version = "0.8.4", features = ["macros", "ws"] } axum = { version = "0.8.4", features = ["macros", "ws"] }
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } 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" ammonia = "4.1.1"
tetratto-shared = { path = "../shared" } tetratto-shared = { path = "../shared" }
tetratto-core = { path = "../core" } tetratto-core = { path = "../core" }
tetratto-l10n = { path = "../l10n" } tetratto-l10n = { path = "../l10n" }
image = "0.25.6"
reqwest = { version = "0.12.23", features = ["json", "stream"] } reqwest = { version = "0.12.23", features = ["json", "stream"] }
regex = "1.11.1" regex = "1.11.1"
serde_json = "1.0.142" serde_json = "1.0.142"
@ -43,6 +42,6 @@ async-stripe = { version = "0.41.0", features = [
"connect", "connect",
] } ] }
emojis = "0.7.2" emojis = "0.7.2"
webp = "0.3.0"
nanoneo = "0.2.0" nanoneo = "0.2.0"
cookie = "0.18.1" cookie = "0.18.1"
axum-image = "0.1.1"

View file

@ -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<S> FromRequest<S> for Image
where
Bytes: FromRequest<S>,
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
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<T: DeserializeOwned>(pub Vec<Bytes>, pub T);
impl<S, T> FromRequest<S> for JsonMultipart<T>
where
Bytes: FromRequest<S>,
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = (StatusCode, String);
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
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<Bytes> = {
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<u8>, 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<u8>, quality: Option<f32>) -> 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(())
}

View file

@ -3,7 +3,6 @@
#![doc(html_logo_url = "/public/tetratto_bunny.webp")] #![doc(html_logo_url = "/public/tetratto_bunny.webp")]
mod assets; mod assets;
mod cookie; mod cookie;
mod image;
mod macros; mod macros;
mod routes; mod routes;
mod sanitize; mod sanitize;

View file

@ -676,7 +676,7 @@
(text "{{ text \"general:action.view\" }}"))) (text "{{ text \"general:action.view\" }}")))
(button (button
("class" "raised small red") ("class" "raised small red")
("onclick" "remove_upload('{{ upload.id }}')") ("onclick" "remove_upload('{{ upload.bucket }}', '{{ upload.id }}')")
(text "{{ icon \"x\" }}") (text "{{ icon \"x\" }}")
(span (span
(text "{{ text \"stacks:label.remove\" }}"))))) (text "{{ text \"stacks:label.remove\" }}")))))
@ -701,7 +701,7 @@
(str (text "general:action.save")))))) (str (text "general:action.save"))))))
(text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}")
(script (script
(text "globalThis.remove_upload = async (id) => { (text "globalThis.remove_upload = async (bucket, id) => {
if ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This action is permanent.\", \"Are you sure you would like to do this? This action is permanent.\",
@ -710,7 +710,7 @@
return; return;
} }
fetch(`/api/v1/uploads/${id}`, { fetch(`/api/v1/uploads/${bucket}/${id}`, {
method: \"DELETE\", method: \"DELETE\",
}) })
.then((res) => res.json()) .then((res) => res.json())

View file

@ -32,7 +32,7 @@
ns_store: {}, ns_store: {},
classes: {}, classes: {},
service_hosts: { service_hosts: {
buckets: \"{{ config.service_hosts.buckets }}\", buckets: \"{{ config.service_hosts.buckets|safe }}\",
} }
}; };

View file

@ -1,9 +1,4 @@
use crate::{ use crate::{cookie::CookieJar, get_user_from_token, State};
cookie::CookieJar,
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
State,
};
use axum::{ use axum::{
extract::Path, extract::Path,
response::{Html, IntoResponse}, response::{Html, IntoResponse},
@ -17,6 +12,7 @@ use tetratto_core::model::{
ApiReturn, Error, ApiReturn, Error,
}; };
use super::{CreateAd, UpdateAdIsRunning}; use super::{CreateAd, UpdateAdIsRunning};
use axum_image::{encode::save_webp_buffer, extract::JsonMultipart};
const MAXIMUM_AD_FILE_SIZE: usize = 2_097_152; const MAXIMUM_AD_FILE_SIZE: usize = 2_097_152;

View file

@ -7,12 +7,8 @@ use tetratto_core::model::{
uploads::{MediaType, MediaUpload}, uploads::{MediaType, MediaUpload},
ApiReturn, Error, ApiReturn, Error,
}; };
use crate::{State, get_user_from_token};
use crate::{ use axum_image::{encode::save_image_buffer, extract::Image};
State,
image::{Image, save_buffer},
get_user_from_token,
};
pub fn read_image(path: PathBufD) -> Vec<u8> { pub fn read_image(path: PathBufD) -> Vec<u8> {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
@ -102,7 +98,11 @@ pub async fn upload_avatar_request(
} }
// upload image // 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(_) => Json(ApiReturn {
ok: true, ok: true,
message: "Avatar uploaded. It might take a bit to update".to_string(), message: "Avatar uploaded. It might take a bit to update".to_string(),
@ -187,7 +187,11 @@ pub async fn upload_banner_request(
} }
// upload image // 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(_) => Json(ApiReturn {
ok: true, ok: true,
message: "Banner uploaded. It might take a bit to update".to_string(), message: "Banner uploaded. It might take a bit to update".to_string(),

View file

@ -2,7 +2,6 @@ use std::fs::exists;
use pathbufd::PathBufD; use pathbufd::PathBufD;
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
image::{save_webp_buffer, Image},
routes::api::v1::{auth::images::read_image, UpdateEmojiName}, routes::api::v1::{auth::images::read_image, UpdateEmojiName},
State, State,
}; };
@ -13,6 +12,7 @@ use tetratto_core::model::{
uploads::{CustomEmoji, MediaType, MediaUpload}, uploads::{CustomEmoji, MediaType, MediaUpload},
ApiReturn, Error, ApiReturn, Error,
}; };
use axum_image::{encode::save_webp_buffer, extract::Image};
/// Expand a unicode emoji into its Gemoji shortcode. /// Expand a unicode emoji into its Gemoji shortcode.
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {

View file

@ -3,13 +3,11 @@ use crate::cookie::CookieJar;
use pathbufd::{PathBufD, pathd}; use pathbufd::{PathBufD, pathd};
use std::fs::exists; use std::fs::exists;
use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth}; use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth};
use crate::{ use crate::{
State, State, get_user_from_token,
image::{Image, save_buffer},
get_user_from_token,
routes::api::v1::auth::images::{MAXIMUM_FILE_SIZE, read_image}, 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 /// Get a community's avatar image
/// `/api/v1/communities/{id}/avatar` /// `/api/v1/communities/{id}/avatar`
@ -146,7 +144,7 @@ pub async fn upload_avatar_request(
bytes.push(byte); 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(_) => Json(ApiReturn {
ok: true, ok: true,
message: "Avatar uploaded. It might take a bit to update".to_string(), 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); 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(_) => Json(ApiReturn {
ok: true, ok: true,
message: "Banner uploaded. It might take a bit to update".to_string(), message: "Banner uploaded. It might take a bit to update".to_string(),

View file

@ -16,7 +16,6 @@ use tetratto_core::model::{
}; };
use crate::{ use crate::{
check_user_blocked_or_private, get_user_from_token, check_user_blocked_or_private, get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
routes::{ routes::{
api::v1::{ api::v1::{
CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, UpdatePostIsOpen, CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, UpdatePostIsOpen,
@ -26,6 +25,7 @@ use crate::{
}, },
State, State,
}; };
use axum_image::{encode::save_webp_buffer, extract::JsonMultipart};
// maximum file dimensions: 2048x2048px (4 MiB) // maximum file dimensions: 2048x2048px (4 MiB)
pub const MAXIMUM_FILE_SIZE: usize = 4194304; pub const MAXIMUM_FILE_SIZE: usize = 4194304;

View file

@ -15,10 +15,10 @@ use tetratto_core::model::{
}; };
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
image::JsonMultipart,
routes::{api::v1::CreateQuestion, pages::PaginatedQuery}, routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
State, State,
}; };
use axum_image::extract::JsonMultipart;
pub async fn create_request( pub async fn create_request(
jar: CookieJar, jar: CookieJar,

View file

@ -704,7 +704,7 @@ pub fn routes() -> Router {
delete(notes::delete_by_dir_request), delete(notes::delete_by_dir_request),
) )
// uploads // 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)) .route("/uploads/{id}/alt", post(uploads::update_alt_request))
// services // services
.route("/services", get(services::list_request)) .route("/services", get(services::list_request))

View file

@ -1,9 +1,4 @@
use crate::{ use crate::{cookie::CookieJar, get_user_from_token, State};
cookie::CookieJar,
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
State,
};
use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{ use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod}, economy::{Product, ProductFulfillmentMethod},
@ -17,6 +12,7 @@ use super::{
UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice,
UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads,
}; };
use axum_image::{encode::save_webp_buffer, extract::JsonMultipart};
pub async fn create_request( pub async fn create_request(
jar: CookieJar, jar: CookieJar,

View file

@ -6,7 +6,7 @@ use tetratto_core::model::{oauth, ApiReturn, Error};
pub async fn delete_request( pub async fn delete_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(id): Path<usize>, Path((bucket, id)): Path<(String, usize)>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) { 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()), 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, Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()), Err(e) => return Json(Error::MiscError(e.to_string()).into()),
}; };
@ -23,7 +23,7 @@ pub async fn delete_request(
return Json(Error::NotAllowed.into()); 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(_) => Json(ApiReturn {
ok: true, ok: true,
message: "Upload deleted".to_string(), message: "Upload deleted".to_string(),