Add multipart interaction form extraction

This commit is contained in:
slhx agent
2026-05-10 21:25:10 +02:00
parent b53a7ba449
commit f33b306ec0
3 changed files with 194 additions and 6 deletions
+117 -5
View File
@@ -1,6 +1,6 @@
use axum::async_trait;
use axum::body::{to_bytes, Body};
use axum::extract::{FromRequest, FromRequestParts};
use axum::extract::{FromRequest, FromRequestParts, Multipart};
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
use axum::response::IntoResponse;
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
@@ -107,10 +107,19 @@ pub struct EffectResponse {
pub batch: EffectBatch,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionFile {
pub name: String,
pub file_name: Option<String>,
pub content_type: Option<String>,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionForm {
pub handle_id: u32,
fields: Vec<(String, String)>,
files: Vec<InteractionFile>,
}
pub struct HandlerRegistry {
@@ -143,11 +152,52 @@ impl InteractionForm {
Self {
handle_id,
fields: fields.into_iter().collect(),
files: Vec::new(),
}
}
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
let fields = parse_urlencoded_pairs(body)?;
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
}
// req: multipart/001, req: multipart/002
pub async fn parse_multipart(mut multipart: Multipart) -> Result<Self, InteractionFormRejection> {
let mut fields = Vec::new();
let mut files = Vec::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| InteractionFormRejection::InvalidBody)?
{
let Some(name) = field.name().map(str::to_owned) else {
continue;
};
let file_name = field.file_name().map(str::to_owned);
let content_type = field.content_type().map(str::to_owned);
let bytes = field
.bytes()
.await
.map_err(|_| InteractionFormRejection::InvalidBody)?;
if file_name.is_some() {
files.push(InteractionFile {
name,
file_name,
content_type,
bytes: bytes.to_vec(),
});
} else {
let value = String::from_utf8(bytes.to_vec())
.map_err(|_| InteractionFormRejection::InvalidBody)?;
fields.push((name, value));
}
}
Self::from_parts(fields, files)
}
fn from_parts(fields: Vec<(String, String)>, files: Vec<InteractionFile>) -> Result<Self, InteractionFormRejection> {
let Some(handle) = fields
.iter()
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
@@ -157,7 +207,7 @@ impl InteractionForm {
let handle_id = handle
.parse::<u32>()
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
Ok(Self { handle_id, fields })
Ok(Self { handle_id, fields, files })
}
pub fn value(&self, name: &str) -> Option<&str> {
@@ -175,6 +225,14 @@ impl InteractionForm {
pub fn fields(&self) -> &[(String, String)] {
&self.fields
}
pub fn files(&self) -> &[InteractionFile] {
&self.files
}
pub fn file(&self, name: &str) -> Option<&InteractionFile> {
self.files.iter().find(|file| file.name == name)
}
}
impl HandlerRegistry {
@@ -219,7 +277,7 @@ impl HandlerRegistry {
impl IntoResponse for InteractionFormRejection {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid urlencoded slhx form body"),
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid slhx form body"),
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h slhx handle field"),
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
};
@@ -235,6 +293,13 @@ where
type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> {
if is_multipart(req.headers()) {
let multipart = Multipart::from_request(req, _state)
.await
.map_err(|_| InteractionFormRejection::InvalidBody)?;
return Self::parse_multipart(multipart).await;
}
let bytes = to_bytes(req.into_body(), 1024 * 1024)
.await
.map_err(|_| InteractionFormRejection::InvalidBody)?;
@@ -242,6 +307,18 @@ where
}
}
fn is_multipart(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|content_type| {
content_type
.split(';')
.next()
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("multipart/form-data"))
})
}
impl PageMode {
pub fn from_headers(headers: &HeaderMap) -> Self {
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
@@ -402,7 +479,8 @@ fn hex(byte: u8) -> Option<u8> {
#[cfg(test)]
mod tests {
use super::{html_with_root_fingerprint, BuildFingerprint};
use super::{html_with_root_fingerprint, BuildFingerprint, InteractionForm};
use axum::{body::Body, extract::FromRequest, http::Request};
#[test]
fn root_fingerprint_is_added_to_initial_root() {
@@ -426,4 +504,38 @@ mod tests {
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
}
#[tokio::test]
async fn interaction_form_extracts_multipart_fields_and_files() {
let boundary = "slhx-test-boundary";
let body = concat!(
"--slhx-test-boundary\r\n",
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
"7\r\n",
"--slhx-test-boundary\r\n",
"Content-Disposition: form-data; name=\"title\"\r\n\r\n",
"Report\r\n",
"--slhx-test-boundary\r\n",
"Content-Disposition: form-data; name=\"upload\"; filename=\"a.txt\"\r\n",
"Content-Type: text/plain\r\n\r\n",
"hello\r\n",
"--slhx-test-boundary--\r\n",
);
let request = Request::builder()
.header(
axum::http::header::CONTENT_TYPE,
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap();
let form = InteractionForm::from_request(request, &()).await.unwrap();
assert_eq!(form.handle_id, 7);
assert_eq!(form.value("title"), Some("Report"));
let file = form.file("upload").unwrap();
assert_eq!(file.file_name.as_deref(), Some("a.txt"));
assert_eq!(file.content_type.as_deref(), Some("text/plain"));
assert_eq!(file.bytes, b"hello");
}
}