chore: move image stuff to axum-image
This commit is contained in:
parent
dbed2b2457
commit
e8cc541f45
15 changed files with 48 additions and 244 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
@ -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]]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(())
|
||||
}
|
|
@ -3,7 +3,6 @@
|
|||
#![doc(html_logo_url = "/public/tetratto_bunny.webp")]
|
||||
mod assets;
|
||||
mod cookie;
|
||||
mod image;
|
||||
mod macros;
|
||||
mod routes;
|
||||
mod sanitize;
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
ns_store: {},
|
||||
classes: {},
|
||||
service_hosts: {
|
||||
buckets: \"{{ config.service_hosts.buckets }}\",
|
||||
buckets: \"{{ config.service_hosts.buckets|safe }}\",
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<u8> {
|
||||
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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -6,7 +6,7 @@ use tetratto_core::model::{oauth, ApiReturn, Error};
|
|||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
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(),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue