use std::collections::{HashMap, HashSet};
use percent_encoding::percent_decode_str;
use topcoat::{
Result,
context::Cx,
router::{
Body,
request::{Bytes, FromRequest},
},
};
use super::StagedUpload;
pub(crate) struct FormParts {
pub(crate) pairs: Vec<(String, String)>,
pub(crate) files: HashMap<String, Vec<StagedUpload>>,
pub(crate) file_part_names: HashSet<String>,
}
impl FormParts {
pub(crate) fn last(&self, key: &str) -> Option<&str> {
self.pairs
.iter()
.rev()
.find(|(posted, _)| posted == key)
.map(|(_, value)| value.as_str())
}
}
pub(crate) async fn parse_body(
cx: &Cx,
body: Body,
capture: Capture,
) -> Result<FormParts, topcoat::Error> {
let content_type =
topcoat::context::try_context::<http::request::Parts>(cx).and_then(|parts| {
parts
.headers
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok().map(|s| s.to_string()))
});
if content_type
.as_deref()
.is_some_and(is_multipart_content_type)
{
return parse_multipart_values(cx, body, capture).await;
}
let bytes = Bytes::from_request(cx, body).await.map_err(|error| {
if error.is::<topcoat::router::error::ContentTooLargeError>() {
error
} else {
topcoat::router::error::bad_request("cannot read form body").into()
}
})?;
Ok(FormParts {
pairs: form_pairs_from_request_parts(
content_type.as_deref(),
bytes.as_ref(),
crate::limits::of(cx).body_bytes,
)?,
files: HashMap::new(),
file_part_names: HashSet::new(),
})
}
pub(crate) async fn parse_file_body(cx: &Cx, body: Body) -> Result<FormParts, topcoat::Error> {
let multipart = topcoat::context::try_context::<http::request::Parts>(cx)
.and_then(|parts| parts.headers.get(http::header::CONTENT_TYPE))
.and_then(|value| value.to_str().ok())
.is_some_and(is_multipart_content_type);
if !multipart {
return Err(topcoat::router::error::bad_request("expected a multipart body").into());
}
parse_multipart_values(cx, body, Capture::Any).await
}
#[derive(Clone, Copy)]
pub(crate) enum Capture {
None,
Persistable,
Any,
}
fn is_multipart_content_type(ct: &str) -> bool {
ct.split(';')
.next()
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("multipart/form-data"))
}
async fn parse_multipart_values(
cx: &Cx,
body: Body,
capture: Capture,
) -> Result<FormParts, topcoat::Error> {
use topcoat::router::{content::multipart::Multipart, request::FromRequest};
let mut out = FormParts {
pairs: Vec::new(),
files: HashMap::new(),
file_part_names: HashSet::new(),
};
let mut counted = Counter {
seen: 0,
cap: crate::limits::of(cx).body_bytes,
};
let mut multipart = Multipart::from_request(cx, body).await?;
while let Some(mut field) = multipart.next_field().await? {
let Some(name) = field.name().map(str::to_string) else {
read_bounded(&mut field, &mut counted, None).await?;
continue;
};
if name.is_empty() {
read_bounded(&mut field, &mut counted, None).await?;
continue;
}
let filename =
filename_star_from_headers(&field).or_else(|| field.file_name().map(str::to_string));
match filename {
Some(f) if !f.is_empty() => {
let sanitized = super::sanitize_filename(&f);
let staged = match capture {
Capture::None => false,
Capture::Persistable => !sanitized.is_empty(),
Capture::Any => true,
};
let mut bytes = Vec::new();
read_bounded(&mut field, &mut counted, staged.then_some(&mut bytes)).await?;
match capture {
Capture::None => {}
Capture::Persistable | Capture::Any => out
.files
.entry(name.clone())
.or_default()
.push(StagedUpload {
filename: sanitized.clone(),
bytes,
}),
}
out.file_part_names.insert(name.clone());
out.pairs.push((name, sanitized));
}
Some(_) => {
read_bounded(&mut field, &mut counted, None).await?;
out.files.remove(&name);
out.file_part_names.insert(name.clone());
out.pairs.push((name, String::new()));
}
None => {
let text = field.text().await?;
counted.count(text.len())?;
out.file_part_names.remove(&name);
out.files.remove(&name);
out.pairs.push((name, text));
}
}
}
Ok(out)
}
async fn read_bounded(
field: &mut topcoat::router::content::multipart::Field<'_>,
bytes: &mut Counter,
keep: Option<&mut Vec<u8>>,
) -> Result<(), topcoat::Error> {
let mut keep = keep;
while let Some(chunk) = field.chunk().await? {
bytes.count(chunk.len())?;
if let Some(sink) = keep.as_deref_mut() {
sink.extend_from_slice(&chunk);
}
}
Ok(())
}
struct Counter {
seen: usize,
cap: usize,
}
impl Counter {
fn count(&mut self, chunk_len: usize) -> Result<(), topcoat::Error> {
let Some(total) = self.seen.checked_add(chunk_len) else {
return Err(topcoat::router::error::content_too_large().into());
};
self.seen = total;
if self.seen > self.cap {
return Err(topcoat::router::error::content_too_large().into());
}
Ok(())
}
}
fn filename_star_from_headers(
field: &topcoat::router::content::multipart::Field<'_>,
) -> Option<String> {
let raw = field
.headers()
.get(http::header::CONTENT_DISPOSITION)?
.to_str()
.ok()?;
raw.split(';').find_map(|seg| {
let seg = seg.trim();
seg.get(..10)
.filter(|h| h.eq_ignore_ascii_case("filename*="))
.and_then(|_| decode_rfc5987(seg[10..].trim()))
})
}
fn form_pairs_from_request_parts(
content_type: Option<&str>,
bytes: &[u8],
cap: usize,
) -> Result<Vec<(String, String)>, topcoat::Error> {
if bytes.len() > cap {
return Err(topcoat::router::error::content_too_large().into());
}
debug_assert!(
content_type.is_none_or(|ct| !is_multipart_content_type(ct)),
"multipart must stream via parse_multipart_values, not buffer here"
);
Ok(form_urlencoded::parse(bytes).into_owned().collect())
}
fn decode_rfc5987(value: &str) -> Option<String> {
let (charset, rest) = value.split_once('\'')?;
let (_lang, encoded) = rest.split_once('\'')?;
if !charset.eq_ignore_ascii_case("utf-8") {
return None;
}
if !is_pct_encoded(encoded) {
return None;
}
percent_decode_str(encoded)
.decode_utf8()
.ok()
.map(|decoded| decoded.into_owned())
}
fn is_pct_encoded(value: &str) -> bool {
let bytes = value.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if !bytes
.get(i + 1..i + 3)
.is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit))
{
return false;
}
i += 3;
} else {
i += 1;
}
}
true
}
#[cfg(test)]
mod tests;