use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use bytes::Bytes;
use futures_util::stream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::{
BadRequestException, HttpException, InternalServerErrorException, PayloadTooLargeException,
Result,
};
static NEXT_UPLOAD_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UploadConfig {
temp_dir: PathBuf,
}
impl Default for UploadConfig {
fn default() -> Self {
Self {
temp_dir: std::env::temp_dir().join("caelix-uploads"),
}
}
}
impl UploadConfig {
pub fn upload_temp_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.temp_dir = path.into();
self
}
pub fn temp_dir(&self) -> &Path {
&self.temp_dir
}
}
#[derive(Debug)]
pub struct UploadedFile {
field_name: String,
file_name: Option<String>,
content_type: Option<String>,
headers: Vec<(String, String)>,
size: u64,
temp_path: Option<PathBuf>,
}
impl UploadedFile {
pub fn field_name(&self) -> &str {
&self.field_name
}
pub fn file_name(&self) -> Option<&str> {
self.file_name.as_deref()
}
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
pub fn headers(&self) -> &[(String, String)] {
&self.headers
}
pub fn size(&self) -> u64 {
self.size
}
pub fn validate_max_size(&self, max_size: u64) -> Result<()> {
if self.size > max_size {
return Err(PayloadTooLargeException::new(format!(
"uploaded file exceeds the declared limit of {max_size} bytes"
)));
}
Ok(())
}
pub async fn validate_content_type(
&self,
allowed_content_types: &[&str],
trust_content_type_header: bool,
) -> Result<()> {
let actual = if trust_content_type_header {
self.content_type
.as_deref()
.and_then(normalized_mime_type)
.ok_or_else(|| {
BadRequestException::new("uploaded file is missing a valid content type header")
})?
} else {
self.detected_content_type().await?
};
if allowed_content_types
.iter()
.filter_map(|content_type| normalized_mime_type(content_type))
.any(|content_type| content_type == actual)
{
return Ok(());
}
Err(BadRequestException::new(format!(
"uploaded file content type `{actual}` is not allowed"
)))
}
pub fn temp_path(&self) -> &Path {
self.temp_path
.as_deref()
.expect("uploaded file path is available until it is persisted")
}
pub async fn read_bytes(&self) -> Result<Bytes> {
tokio::fs::read(self.temp_path())
.await
.map(Bytes::from)
.map_err(storage_error)
}
pub async fn persist_to(mut self, destination: impl AsRef<Path>) -> Result<PathBuf> {
let destination = destination.as_ref().to_path_buf();
let source = self.temp_path().to_path_buf();
let mut source_file = tokio::fs::File::open(&source)
.await
.map_err(storage_error)?;
let mut destination_file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&destination)
.await
.map_err(storage_error)?;
tokio::io::copy(&mut source_file, &mut destination_file)
.await
.map_err(storage_error)?;
destination_file.flush().await.map_err(storage_error)?;
tokio::fs::remove_file(&source)
.await
.map_err(storage_error)?;
self.temp_path.take();
Ok(destination)
}
async fn detected_content_type(&self) -> Result<String> {
const MIME_SNIFF_BYTES: usize = 8 * 1024;
let mut file = tokio::fs::File::open(self.temp_path())
.await
.map_err(storage_error)?;
let mut bytes = vec![0; MIME_SNIFF_BYTES];
let length = file.read(&mut bytes).await.map_err(storage_error)?;
let kind = infer::get(&bytes[..length]).ok_or_else(|| {
BadRequestException::new("could not determine uploaded file content type")
})?;
normalized_mime_type(kind.mime_type()).ok_or_else(|| {
BadRequestException::new("could not determine uploaded file content type")
})
}
}
impl Drop for UploadedFile {
fn drop(&mut self) {
if let Some(path) = self.temp_path.take() {
let _ = std::fs::remove_file(path);
}
}
}
#[derive(Debug, Default)]
pub struct MultipartForm {
text_fields: BTreeMap<String, Vec<String>>,
file_fields: BTreeMap<String, Vec<UploadedFile>>,
}
impl MultipartForm {
pub async fn parse(
content_type: &str,
body: Bytes,
config: &UploadConfig,
limit: usize,
) -> Result<Self> {
let boundary = multer::parse_boundary(content_type)
.map_err(|_| BadRequestException::new("invalid multipart request boundary"))?;
if body.len() > limit {
return Err(limit_error(limit));
}
tokio::fs::create_dir_all(config.temp_dir())
.await
.map_err(storage_error)?;
let stream = stream::once(async move { Ok::<Bytes, std::io::Error>(body) });
let mut multipart = multer::Multipart::new(stream, boundary);
let mut form = Self::default();
let mut received = 0usize;
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|_| BadRequestException::new("invalid multipart request body"))?
{
let name = field.name().map(ToOwned::to_owned).ok_or_else(|| {
BadRequestException::new("multipart part is missing a field name")
})?;
let file_name = field.file_name().map(ToOwned::to_owned);
let content_type = field.content_type().map(ToString::to_string);
let headers = field
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect::<Vec<_>>();
if file_name.is_some() {
let path = temporary_path(config);
let mut output = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.await
.map_err(storage_error)?;
let mut size = 0u64;
while let Some(chunk) = field
.chunk()
.await
.map_err(|_| BadRequestException::new("invalid multipart request body"))?
{
received = received.saturating_add(chunk.len());
if received > limit {
return Err(limit_error(limit));
}
output.write_all(&chunk).await.map_err(storage_error)?;
size += chunk.len() as u64;
}
output.flush().await.map_err(storage_error)?;
form.file_fields
.entry(name.clone())
.or_default()
.push(UploadedFile {
field_name: name,
file_name,
content_type,
headers,
size,
temp_path: Some(path),
});
} else {
let bytes = field
.bytes()
.await
.map_err(|_| BadRequestException::new("invalid multipart request body"))?;
received = received.saturating_add(bytes.len());
if received > limit {
return Err(limit_error(limit));
}
let value = String::from_utf8(bytes.to_vec())
.map_err(|_| BadRequestException::new("multipart text fields must be UTF-8"))?;
form.text_fields.entry(name).or_default().push(value);
}
}
Ok(form)
}
pub fn deserialize<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
for (name, values) in &self.text_fields {
for value in values {
serializer.append_pair(name, value);
}
}
serde_html_form::from_str(&serializer.finish())
.map_err(|_| BadRequestException::new("invalid multipart form fields"))
}
pub fn text(&self, name: &str) -> Option<&[String]> {
self.text_fields.get(name).map(Vec::as_slice)
}
pub fn files(&self, name: &str) -> Option<&[UploadedFile]> {
self.file_fields.get(name).map(Vec::as_slice)
}
pub fn take_file(&mut self, name: &str) -> Result<Option<UploadedFile>> {
let Some(mut files) = self.file_fields.remove(name) else {
return Ok(None);
};
if files.len() != 1 {
return Err(BadRequestException::new(format!(
"multipart field `{name}` must contain exactly one file"
)));
}
Ok(files.pop())
}
pub fn take_files(&mut self, name: &str) -> Vec<UploadedFile> {
self.file_fields.remove(name).unwrap_or_default()
}
}
fn temporary_path(config: &UploadConfig) -> PathBuf {
let id = NEXT_UPLOAD_ID.fetch_add(1, Ordering::Relaxed);
config.temp_dir().join(format!(
"upload-{}-{}-{}",
std::process::id(),
id,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
pub fn upload_limit_error(limit: usize) -> HttpException {
limit_error(limit)
}
fn limit_error(limit: usize) -> HttpException {
PayloadTooLargeException::new(format!(
"request body exceeds the configured limit of {limit} bytes"
))
}
fn storage_error(error: std::io::Error) -> HttpException {
InternalServerErrorException::new(error)
}
fn normalized_mime_type(value: &str) -> Option<String> {
let value = value.split(';').next()?.trim();
(!value.is_empty()).then(|| value.to_ascii_lowercase())
}