use crate::error::FrameworkError;
use bytes::Bytes;
use ferro_storage::{Disk, PutOptions};
use futures_util::StreamExt;
use http_body_util::BodyStream;
use hyper::body::Incoming;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct UploadedFile {
pub field_name: String,
pub file_name: Option<String>,
pub content_type: Option<String>,
pub bytes: Bytes,
}
impl UploadedFile {
pub fn size(&self) -> usize {
self.bytes.len()
}
pub fn extension(&self) -> Option<&str> {
self.file_name
.as_deref()
.and_then(|n| Path::new(n).extension())
.and_then(|e| e.to_str())
}
pub fn is_image(&self) -> bool {
self.content_type
.as_deref()
.map(|ct| ct.starts_with("image/"))
.unwrap_or(false)
}
pub async fn store(&self, disk: &Disk, path: &str) -> Result<(), ferro_storage::Error> {
let opts = PutOptions::new().content_type(
self.content_type
.as_deref()
.unwrap_or("application/octet-stream"),
);
disk.put_with_options(path, self.bytes.clone(), opts).await
}
}
#[derive(Debug)]
pub struct MultipartForm {
pub(crate) files_map: HashMap<String, Vec<UploadedFile>>,
pub(crate) text_fields: HashMap<String, String>,
}
impl MultipartForm {
pub fn file(&self, field: &str) -> Option<&UploadedFile> {
self.files_map.get(field).and_then(|v| v.first())
}
pub fn files(&self, field: &str) -> &[UploadedFile] {
self.files_map
.get(field)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn field(&self, name: &str) -> Option<&str> {
self.text_fields.get(name).map(|s| s.as_str())
}
pub fn fields(&self) -> &HashMap<String, String> {
&self.text_fields
}
}
pub(crate) async fn parse_multipart_body(
body: Incoming,
content_type: &str,
max_file_bytes: u64,
max_fields: usize,
) -> Result<MultipartForm, FrameworkError> {
let boundary = multer::parse_boundary(content_type).map_err(|_| {
FrameworkError::domain(
"Content-Type is not multipart/form-data or missing boundary",
400,
)
})?;
let body_stream = BodyStream::new(body)
.filter_map(|result| async move { result.map(|frame| frame.into_data().ok()).transpose() });
let constraints =
multer::Constraints::new().size_limit(multer::SizeLimit::new().per_field(max_file_bytes));
let mut multipart = multer::Multipart::with_constraints(body_stream, boundary, constraints);
let mut files_map: HashMap<String, Vec<UploadedFile>> = HashMap::new();
let mut text_fields: HashMap<String, String> = HashMap::new();
let mut field_count: usize = 0;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| FrameworkError::internal(format!("Multipart parse error: {e}")))?
{
field_count += 1;
if field_count > max_fields {
return Err(FrameworkError::domain(
"Too many fields in multipart request",
400,
));
}
let field_name = field.name().map(|s| s.to_string()).unwrap_or_default();
let file_name = field.file_name().map(|s| s.to_string());
let content_type = field.content_type().map(|m| m.to_string());
let bytes = field.bytes().await.map_err(|e| match e {
multer::Error::FieldSizeExceeded { .. } | multer::Error::StreamSizeExceeded { .. } => {
FrameworkError::domain("Upload field exceeds maximum size", 413)
}
_ => FrameworkError::internal(format!("Field read error: {e}")),
})?;
if file_name.is_some() {
files_map
.entry(field_name.clone())
.or_default()
.push(UploadedFile {
field_name,
file_name,
content_type,
bytes,
});
} else {
let value = String::from_utf8(bytes.to_vec()).map_err(|_| {
FrameworkError::internal("Multipart text field contains invalid UTF-8")
})?;
text_fields.insert(field_name, value);
}
}
Ok(MultipartForm {
files_map,
text_fields,
})
}
pub fn validate_mime(file: &UploadedFile, allowed: &[&str]) -> Result<(), FrameworkError> {
let ct = file.content_type.as_deref().unwrap_or("");
if allowed.contains(&ct) {
Ok(())
} else {
Err(FrameworkError::domain(
format!(
"File type '{ct}' is not allowed; accepted: {}",
allowed.join(", ")
),
422,
))
}
}
pub fn validate_size(file: &UploadedFile, max_bytes: usize) -> Result<(), FrameworkError> {
if file.size() <= max_bytes {
Ok(())
} else {
Err(FrameworkError::domain(
format!("File too large: {} bytes (max {max_bytes})", file.size()),
422,
))
}
}
pub(crate) fn max_file_bytes() -> u64 {
let mb = std::env::var("UPLOAD_MAX_SIZE_MB")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(10);
mb.max(1) * 1024 * 1024
}
pub(crate) fn max_fields() -> usize {
std::env::var("UPLOAD_MAX_FIELDS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(100)
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use http_body_util::{BodyStream, Full};
fn make_multipart_body(
boundary: &str,
parts: &[(&str, &[u8], Option<&str>)],
) -> (Bytes, String) {
let ct = format!("multipart/form-data; boundary={boundary}");
let mut body: Vec<u8> = Vec::new();
for (name, value, filename) in parts {
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
match filename {
Some(fname) => body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
)
.as_bytes(),
),
None => body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n")
.as_bytes(),
),
}
body.extend_from_slice(value);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(Bytes::from(body), ct)
}
async fn parse_for_test(
raw: Bytes,
content_type: &str,
max_bytes: u64,
max_fields_cap: usize,
) -> Result<MultipartForm, FrameworkError> {
let boundary = multer::parse_boundary(content_type).map_err(|_| {
FrameworkError::internal("Content-Type is not multipart/form-data or missing boundary")
})?;
let body = Full::new(raw);
let stream = BodyStream::new(body).filter_map(|result| async move {
result.map(|frame| frame.into_data().ok()).transpose()
});
let constraints =
multer::Constraints::new().size_limit(multer::SizeLimit::new().per_field(max_bytes));
let mut multipart = multer::Multipart::with_constraints(stream, boundary, constraints);
let mut files_map: HashMap<String, Vec<UploadedFile>> = HashMap::new();
let mut text_fields: HashMap<String, String> = HashMap::new();
let mut field_count: usize = 0;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| FrameworkError::internal(format!("Multipart parse error: {e}")))?
{
field_count += 1;
if field_count > max_fields_cap {
return Err(FrameworkError::internal(
"Too many fields in multipart request",
));
}
let field_name = field.name().map(|s| s.to_string()).unwrap_or_default();
let file_name = field.file_name().map(|s| s.to_string());
let content_type = field.content_type().map(|m| m.to_string());
let bytes = field
.bytes()
.await
.map_err(|e| FrameworkError::internal(format!("Field read error: {e}")))?;
if file_name.is_some() {
files_map
.entry(field_name.clone())
.or_default()
.push(UploadedFile {
field_name,
file_name,
content_type,
bytes,
});
} else {
let value = String::from_utf8(bytes.to_vec()).map_err(|_| {
FrameworkError::internal("Multipart text field contains invalid UTF-8")
})?;
text_fields.insert(field_name, value);
}
}
Ok(MultipartForm {
files_map,
text_fields,
})
}
#[tokio::test]
async fn multipart_parses_fields() {
let (raw, ct) = make_multipart_body(
"BOUNDARY",
&[
("title", b"hello", None),
("avatar", b"\x89PNG\r\n\x1a\n", Some("avatar.png")),
],
);
let form = parse_for_test(raw, &ct, 10 * 1024 * 1024, 100)
.await
.expect("parses");
assert_eq!(form.field("title"), Some("hello"));
let file = form.file("avatar").expect("avatar present");
assert_eq!(file.field_name, "avatar");
assert_eq!(file.file_name.as_deref(), Some("avatar.png"));
assert_eq!(file.bytes.as_ref(), b"\x89PNG\r\n\x1a\n");
}
#[tokio::test]
async fn multipart_form_accessors() {
let (raw, ct) = make_multipart_body(
"B",
&[
("photos", b"AAA", Some("a.jpg")),
("photos", b"BBB", Some("b.jpg")),
("caption", b"two photos", None),
],
);
let form = parse_for_test(raw, &ct, 10 * 1024 * 1024, 100)
.await
.expect("parses");
assert_eq!(form.file("photos").unwrap().bytes.as_ref(), b"AAA");
assert_eq!(form.files("photos").len(), 2);
assert_eq!(form.files("photos")[1].bytes.as_ref(), b"BBB");
assert!(form.files("absent").is_empty());
assert!(form.file("absent").is_none());
assert_eq!(form.field("caption"), Some("two photos"));
assert_eq!(form.fields().len(), 1);
}
#[tokio::test]
async fn uploaded_file_fields() {
let (raw, ct) = make_multipart_body("B", &[("doc", b"PDFDATA", Some("report.pdf"))]);
let form = parse_for_test(raw, &ct, 1024, 100).await.expect("parses");
let file = form.file("doc").expect("present");
assert_eq!(file.field_name, "doc");
assert_eq!(file.file_name.as_deref(), Some("report.pdf"));
assert_eq!(
file.content_type.as_deref(),
Some("application/octet-stream")
);
assert_eq!(file.bytes.len(), b"PDFDATA".len());
}
#[test]
fn uploaded_file_size_returns_byte_len() {
let f = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: None,
bytes: Bytes::from_static(b"12345"),
};
assert_eq!(f.size(), 5);
}
#[test]
fn extension_from_filename() {
let with_ext = UploadedFile {
field_name: "f".into(),
file_name: Some("avatar.png".into()),
content_type: None,
bytes: Bytes::new(),
};
let no_ext = UploadedFile {
field_name: "f".into(),
file_name: Some("noext".into()),
content_type: None,
bytes: Bytes::new(),
};
let none = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: None,
bytes: Bytes::new(),
};
assert_eq!(with_ext.extension(), Some("png"));
assert_eq!(no_ext.extension(), None);
assert_eq!(none.extension(), None);
}
#[test]
fn is_image_true_false() {
let img = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: Some("image/jpeg".into()),
bytes: Bytes::new(),
};
let pdf = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: Some("application/pdf".into()),
bytes: Bytes::new(),
};
let none = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: None,
bytes: Bytes::new(),
};
assert!(img.is_image());
assert!(!pdf.is_image());
assert!(!none.is_image());
}
#[tokio::test]
async fn multipart_missing_boundary() {
let raw = Bytes::from_static(b"irrelevant");
let err = parse_for_test(raw, "application/json", 1024, 100)
.await
.expect_err("must error");
let msg = format!("{err}");
assert!(
msg.contains("Content-Type is not multipart/form-data or missing boundary"),
"unexpected error message: {msg}"
);
}
#[tokio::test]
async fn multipart_size_limit_rejects_oversized_field() {
let big = vec![b'A'; 50];
let (raw, ct) = make_multipart_body("B", &[("blob", &big, Some("big.bin"))]);
let err = parse_for_test(raw, &ct, 10, 100)
.await
.expect_err("oversized must error");
let msg = format!("{err}");
assert!(
msg.contains("Multipart parse error") || msg.contains("Field read error"),
"expected size-limit error from multer, got: {msg}"
);
}
#[tokio::test]
async fn multipart_max_fields_rejects_excess() {
let (raw, ct) = make_multipart_body(
"B",
&[("a", b"1", None), ("b", b"2", None), ("c", b"3", None)],
);
let err = parse_for_test(raw, &ct, 1024, 2)
.await
.expect_err("must reject excess fields");
let msg = format!("{err}");
assert!(
msg.contains("Too many fields in multipart request"),
"unexpected error message: {msg}"
);
}
#[test]
fn validate_mime_accepts_allowed() {
let f = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: Some("image/png".into()),
bytes: Bytes::new(),
};
validate_mime(&f, &["image/png", "image/jpeg"]).expect("png is allowed");
}
#[test]
fn validate_mime_rejects_disallowed() {
let f = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: Some("application/x-msdownload".into()),
bytes: Bytes::new(),
};
let err = validate_mime(&f, &["image/png"]).expect_err("must reject exe");
let msg = format!("{err}");
assert!(msg.contains("application/x-msdownload"));
assert!(msg.contains("image/png"));
}
#[test]
fn validate_size_accepts_within_cap() {
let f = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: None,
bytes: Bytes::from_static(b"hello"),
};
validate_size(&f, 10).expect("5 bytes is within 10");
}
#[test]
fn validate_size_rejects_over_cap() {
let f = UploadedFile {
field_name: "f".into(),
file_name: None,
content_type: None,
bytes: Bytes::from_static(b"hello world!!"),
};
let err = validate_size(&f, 5).expect_err("13 > 5");
let msg = format!("{err}");
assert!(msg.contains("13 bytes"));
assert!(msg.contains("max 5"));
}
#[tokio::test]
async fn store_to_memory_disk() {
use ferro_storage::{DiskConfig, Storage};
let storage = Storage::with_config("mem", vec![("mem", DiskConfig::memory())]);
let disk = storage.disk("mem").expect("memory disk exists");
let file = UploadedFile {
field_name: "avatar".into(),
file_name: Some("photo.png".into()),
content_type: Some("image/png".into()),
bytes: Bytes::from_static(b"\x89PNG\r\n\x1a\n"),
};
file.store(&disk, "uploads/photo.png")
.await
.expect("store succeeds");
let stored = disk
.get("uploads/photo.png")
.await
.expect("file readable after store");
assert_eq!(stored.as_ref(), b"\x89PNG\r\n\x1a\n");
assert!(disk.exists("uploads/photo.png").await.unwrap());
}
}