From f33b306ec06e3ebc96e6376496c393db834cbdcb Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 10 May 2026 21:25:10 +0200 Subject: [PATCH] Add multipart interaction form extraction --- Cargo.lock | 73 ++++++++++++++++++++++++++ slhx-axum/Cargo.toml | 5 +- slhx-axum/src/lib.rs | 122 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 194 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70302ec..cd26a75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -85,6 +86,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "cobs" version = "0.3.0" @@ -106,6 +113,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -199,6 +215,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "indexmap" version = "2.14.0" @@ -233,6 +255,23 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -381,6 +420,7 @@ dependencies = [ "axum", "slhx-core", "slhx-js", + "tokio", ] [[package]] @@ -418,6 +458,12 @@ dependencies = [ "slhx-core", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -461,6 +507,27 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tower" version = "0.5.3" @@ -521,6 +588,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "zmij" version = "1.0.21" diff --git a/slhx-axum/Cargo.toml b/slhx-axum/Cargo.toml index 0d4f456..66a2373 100644 --- a/slhx-axum/Cargo.toml +++ b/slhx-axum/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true path = "src/lib.rs" [dependencies] -axum = { version = "0.7", default-features = false } +axum = { version = "0.7", default-features = false, features = ["multipart"] } slhx-core = { path = "../slhx-core" } slhx-js = { path = "../slhx-js" } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/slhx-axum/src/lib.rs b/slhx-axum/src/lib.rs index 616d84d..bf5809e 100644 --- a/slhx-axum/src/lib.rs +++ b/slhx-axum/src/lib.rs @@ -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, + pub content_type: Option, + pub bytes: Vec, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct InteractionForm { pub handle_id: u32, fields: Vec<(String, String)>, + files: Vec, } 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 { - 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 { + 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) -> Result { 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::() .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, _state: &S) -> Result { + 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 { #[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, "
Docs
"); } + + #[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"); + } }