murgamu 0.7.4

A NestJS-inspired web framework for Rust
Documentation
use super::MurFormField;
use super::MurMultipart;
use super::MurMultipartConfig;
use super::MurUploadedFile;
use crate::core::error::MurError;
use hyper::body::Bytes;
use std::path::Path;

pub struct MurMultipartUtils;

impl MurMultipartUtils {
	pub fn parse_boundary(content_type: &str) -> Result<String, MurError> {
		if !content_type.to_lowercase().contains("multipart/form-data") {
			return Err(MurError::BadRequest(
				"Content-Type must be multipart/form-data".into(),
			));
		}

		for part in content_type.split(';') {
			let part = part.trim();
			if let Some(boundary) = part.strip_prefix("boundary=") {
				let boundary = boundary.trim_matches('"').trim_matches('\'');
				if boundary.is_empty() {
					return Err(MurError::BadRequest("Empty boundary".into()));
				}
				return Ok(boundary.to_string());
			}
		}

		Err(MurError::BadRequest(
			"Missing boundary in Content-Type".into(),
		))
	}

	pub fn parse_multipart_body(
		body: &Bytes,
		boundary: &str,
		config: &MurMultipartConfig,
	) -> Result<MurMultipart, MurError> {
		let mut multipart = MurMultipart::empty();
		let delimiter = format!("--{}", boundary);
		let _end_delimiter = format!("--{}--", boundary);
		let body_str = String::from_utf8_lossy(body);
		let parts: Vec<&str> = body_str.split(&delimiter).collect();
		let mut file_count = 0;
		let mut field_count = 0;

		for part in parts.iter().skip(1) {
			let part = part.trim_start_matches("\r\n").trim_end_matches("\r\n");
			if part.is_empty() || part.starts_with("--") {
				continue;
			}

			field_count += 1;
			if field_count > config.max_fields {
				return Err(MurError::BadRequest(format!(
					"Too many fields (max: {})",
					config.max_fields
				)));
			}

			let field = Self::parse_multipart_part(part, config)?;

			match &field {
				MurFormField::Text { name, value } => {
					multipart
						.text_fields
						.entry(name.clone())
						.or_default()
						.push(value.clone());
				}
				MurFormField::File(file) => {
					file_count += 1;
					if file_count > config.max_files {
						return Err(MurError::BadRequest(format!(
							"Too many files (max: {})",
							config.max_files
						)));
					}

					if file.size() > config.max_file_size {
						return Err(MurError::BadRequest(format!(
							"File '{}' exceeds maximum size of {} bytes",
							file.filename(),
							config.max_file_size
						)));
					}

					if !config.allowed_extensions.is_empty() {
						if let Some(ext) = file.extension() {
							if !config
								.allowed_extensions
								.iter()
								.any(|e| e.eq_ignore_ascii_case(ext))
							{
								return Err(MurError::BadRequest(format!(
									"File extension '{}' is not allowed",
									ext
								)));
							}
						} else {
							return Err(MurError::BadRequest(
								"Files must have an extension".into(),
							));
						}
					}

					if !config.allowed_mime_types.is_empty()
						|| !config
							.allowed_mime_types
							.iter()
							.any(|t| t.eq_ignore_ascii_case(file.content_type()))
					{
						return Err(MurError::BadRequest(format!(
							"Content type '{}' is not allowed",
							file.content_type()
						)));
					}

					multipart.total_file_size += file.size();
					multipart
						.file_fields
						.entry(file.field_name.clone())
						.or_default()
						.push(file.clone());
				}
			}

			multipart.fields.push(field);
		}

		Ok(multipart)
	}

	pub fn parse_multipart_part(
		part: &str,
		config: &MurMultipartConfig,
	) -> Result<MurFormField, MurError> {
		let (headers_str, body) = match part.find("\r\n\r\n") {
			Some(pos) => (&part[..pos], &part[pos + 4..]),
			None => match part.find("\n\n") {
				Some(pos) => (&part[..pos], &part[pos + 2..]),
				None => return Err(MurError::BadRequest("Invalid multipart part format".into())),
			},
		};
		let mut name = None;
		let mut filename = None;
		let mut content_type = String::from("text/plain");

		for line in headers_str.lines() {
			let line = line.trim();
			if line.is_empty() {
				continue;
			}

			if let Some(value) = line.strip_prefix("Content-Disposition:") {
				let value = value.trim();

				if let Some(name_match) = Self::extract_header_param(value, "name") {
					if name_match.len() > config.max_field_name_length {
						return Err(MurError::BadRequest(format!(
							"Field name exceeds maximum length of {}",
							config.max_field_name_length
						)));
					}
					name = Some(name_match);
				}

				if let Some(filename_match) = Self::extract_header_param(value, "filename") {
					filename = Some(filename_match);
				}
			} else if let Some(value) = line.strip_prefix("Content-Type:") {
				content_type = value.trim().to_string();
			}
		}

		let name = name.ok_or_else(|| MurError::BadRequest("Missing field name in part".into()))?;

		if let Some(filename) = filename {
			let data = Bytes::from(body.as_bytes().to_vec());
			Ok(MurFormField::File(MurUploadedFile::new(
				filename,
				content_type,
				data,
				name,
			)))
		} else {
			Ok(MurFormField::Text {
				name,
				value: body.to_string(),
			})
		}
	}

	pub fn extract_header_param(header: &str, param: &str) -> Option<String> {
		let pattern = format!("{}=", param);

		for part in header.split(';') {
			let part = part.trim();
			if let Some(value) = part.strip_prefix(&pattern) {
				let value = value.trim_matches('"').trim_matches('\'');
				return Some(value.to_string());
			}
		}

		None
	}

	pub fn sanitize_filename(filename: &str) -> String {
		let filename = filename
			.chars()
			.filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_' || *c == ' ')
			.collect::<String>();
		let filename = filename.trim_matches(|c| c == '.' || c == ' ');
		let filename = if filename.len() > 255 {
			&filename[..255]
		} else {
			filename
		};

		if filename.is_empty() {
			"unnamed_file".to_string()
		} else {
			filename.to_string()
		}
	}

	pub fn extract_extension(filename: &str) -> Option<String> {
		Path::new(filename)
			.extension()
			.and_then(|ext| ext.to_str())
			.map(|ext| ext.to_lowercase())
	}

	pub fn rand_u32() -> u32 {
		use std::collections::hash_map::RandomState;
		use std::hash::{BuildHasher, Hasher};

		let state = RandomState::new();
		let mut hasher = state.build_hasher();
		hasher.write_usize(std::ptr::null::<()>() as usize);
		hasher.finish() as u32
	}

	pub fn mur_parse_multipart(body: &[u8], content_type: &str) -> Result<MurMultipart, MurError> {
		let boundary = Self::parse_boundary(content_type)?;
		Self::parse_multipart_body(
			&Bytes::from(body.to_vec()),
			&boundary,
			&MurMultipartConfig::default(),
		)
	}

	pub fn mur_parse_multipart_with_config(
		body: &[u8],
		content_type: &str,
		config: &MurMultipartConfig,
	) -> Result<MurMultipart, MurError> {
		let boundary = Self::parse_boundary(content_type)?;
		Self::parse_multipart_body(&Bytes::from(body.to_vec()), &boundary, config)
	}

	pub fn mur_is_multipart_content_type(content_type: &str) -> bool {
		content_type.to_lowercase().contains("multipart/form-data")
	}

	pub fn mur_extract_boundary(content_type: &str) -> Option<String> {
		Self::parse_boundary(content_type).ok()
	}
}