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
Generated
+73
View File
@@ -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"
+4 -1
View File
@@ -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"] }
+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");
}
}