88 lines
3.0 KiB
Rust
88 lines
3.0 KiB
Rust
//! Multipart form handling shared by the product and category admin forms.
|
|
//!
|
|
//! Both forms submit a mix of text fields and an optional `image` file part;
|
|
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
|
|
//! uploaded image through the configured storage driver.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use axum::extract::Multipart;
|
|
use loco_rs::prelude::*;
|
|
|
|
use crate::controllers::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
|
|
|
fn normalize_empty(value: Option<String>) -> Option<String> {
|
|
value.and_then(|value| {
|
|
let value = value.trim().to_string();
|
|
(!value.is_empty()).then_some(value)
|
|
})
|
|
}
|
|
|
|
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
|
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
|
pub(crate) struct MultipartForm {
|
|
fields: HashMap<String, String>,
|
|
pub(crate) image: Option<Vec<u8>>,
|
|
}
|
|
|
|
impl MultipartForm {
|
|
/// Trimmed value of a text field, `None` when missing or blank.
|
|
pub(crate) fn text(&self, key: &str) -> Option<String> {
|
|
normalize_empty(self.fields.get(key).cloned())
|
|
}
|
|
|
|
/// Whether a checkbox-style field is checked.
|
|
pub(crate) fn checked(&self, key: &str) -> bool {
|
|
matches!(
|
|
self.fields.get(key).map(String::as_str),
|
|
Some("on" | "true" | "1")
|
|
)
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
|
let mut fields = HashMap::new();
|
|
let mut image = None;
|
|
|
|
while let Some(mut field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
|
{
|
|
let name = field.name().unwrap_or("").to_string();
|
|
if name == "image" {
|
|
let mut data = Vec::new();
|
|
while let Some(chunk) = field
|
|
.chunk()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
|
{
|
|
data.extend_from_slice(&chunk);
|
|
if data.len() > IMAGE_MAX_BYTES {
|
|
return Err(Error::BadRequest(format!(
|
|
"image is larger than {} MB",
|
|
IMAGE_MAX_BYTES / 1024 / 1024
|
|
)));
|
|
}
|
|
}
|
|
if !data.is_empty() {
|
|
image = Some(data);
|
|
}
|
|
} else {
|
|
let value = field
|
|
.text()
|
|
.await
|
|
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
|
fields.insert(name, value);
|
|
}
|
|
}
|
|
|
|
Ok(MultipartForm { fields, image })
|
|
}
|
|
|
|
/// Store an uploaded image's bytes and return its generated filename.
|
|
pub(crate) async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
|
|
let extension = detect_image_extension(&data)?;
|
|
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
|
|
}
|