// SPDX-License-Identifier: AGPL-3.0-or-later use axum::{body, extract::Request}; use std::collections::BTreeMap; use url::form_urlencoded; const MAX_FORM_BYTES: usize = 1024 * 1024; pub fn clean_string(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } } pub fn parse_comma_separated(value: &str) -> Vec { comma_separated_values(value).map(str::to_owned).collect() } fn comma_separated_values(value: &str) -> impl Iterator { value .split([',', '\n', '\r']) .map(str::trim) .filter(|value| !value.is_empty()) } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MultiValueForm { fields: BTreeMap>, } impl MultiValueForm { pub async fn from_request(request: Request) -> Option { let bytes = body::to_bytes(request.into_body(), MAX_FORM_BYTES) .await .ok()?; Some(Self::parse(bytes.as_ref())) } pub fn parse(bytes: &[u8]) -> Self { let mut fields: BTreeMap> = BTreeMap::new(); for (key, value) in form_urlencoded::parse(bytes) { fields .entry(key.into_owned()) .or_default() .push(value.into_owned()); } Self { fields } } pub fn contains_key(&self, key: &str) -> bool { self.fields.contains_key(key) } pub fn has_key_starting_with(&self, prefix: &str) -> bool { self.fields.keys().any(|key| key.starts_with(prefix)) } pub fn values(&self, key: &str) -> &[String] { self.fields.get(key).map(Vec::as_slice).unwrap_or_default() } pub fn first(&self, key: &str) -> Option<&str> { self.fields .get(key) .and_then(|values| values.first()) .map(String::as_str) } pub fn clean(&self, key: &str) -> Option { self.first(key).and_then(clean_string) } pub fn parse_value(&self, key: &str) -> Result, T::Err> { self.parse_value_any(&[key]) } pub fn parse_value_any( &self, keys: &[&str], ) -> Result, T::Err> { keys.iter() .find_map(|key| self.first(key)) .map(str::parse) .transpose() } pub fn parse_u32(&self, key: &str) -> Option { self.first(key).and_then(|value| value.parse().ok()) } pub fn parse_u64(&self, key: &str) -> Option { self.first(key).and_then(|value| value.parse().ok()) } pub fn bool_value(&self, key: &str) -> bool { self.fields.get(key).is_some_and(|values| { values .iter() .any(|value| matches!(value.as_str(), "1" | "true" | "on")) }) } pub fn opt_out_value(&self, key: &str) -> bool { !self.contains_key(&format!("{key}_present")) || self.bool_value(key) } pub fn list_values(&self, key: &str) -> Vec { self.fields .get(key) .into_iter() .flat_map(|values| values.iter()) .flat_map(|value| comma_separated_values(value)) .map(str::to_owned) .collect() } pub fn list_values_any(&self, keys: &[&str]) -> Vec { keys.iter().flat_map(|key| self.list_values(key)).collect() } pub fn parse_list_values(&self, keys: &[&str]) -> Result, T::Err> { self.list_values_any(keys) .iter() .map(|value| value.parse()) .collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn clean_string_trims_whitespace() { assert_eq!(clean_string(" hello "), Some("hello".to_owned())); } #[test] fn clean_string_returns_none_for_empty() { assert_eq!(clean_string(""), None); assert_eq!(clean_string(" "), None); assert_eq!(clean_string("\t\n"), None); } #[test] fn clean_string_preserves_inner_spaces() { assert_eq!( clean_string(" hello world "), Some("hello world".to_owned()) ); } #[test] fn parse_comma_separated_splits() { assert_eq!(parse_comma_separated("a, b, c"), vec!["a", "b", "c"]); assert_eq!(parse_comma_separated("a\nb\r\nc"), vec!["a", "b", "c"]); } #[test] fn parse_comma_separated_filters_empty() { assert_eq!(parse_comma_separated("a,,b, ,c"), vec!["a", "b", "c"]); assert!(parse_comma_separated("").is_empty()); assert!(parse_comma_separated(", , ,").is_empty()); } #[test] fn multi_value_form_preserves_repeated_fields() { let form = MultiValueForm::parse(b"fields%5B%5D=avatar&fields%5B%5D=banner&name=Test"); assert_eq!( form.list_values("fields[]"), vec!["avatar".to_owned(), "banner".to_owned()] ); assert_eq!(form.clean("name"), Some("Test".to_owned())); } #[test] fn multi_value_form_list_values_accepts_comma_and_repeated_values() { let form = MultiValueForm::parse(b"acls=user.read,user.write&acls=guild.read"); assert_eq!( form.list_values("acls"), vec![ "user.read".to_owned(), "user.write".to_owned(), "guild.read".to_owned() ] ); } #[test] fn multi_value_form_bool_value_accepts_html_checkbox_values() { assert!(MultiValueForm::parse(b"enabled=on").bool_value("enabled")); assert!(MultiValueForm::parse(b"enabled=true").bool_value("enabled")); assert!(!MultiValueForm::parse(b"enabled=false").bool_value("enabled")); } }