use std::collections::HashMap;
use std::path::{Path, PathBuf};
use axum::extract::Multipart;
use bytes::Bytes;
use tokio::io::AsyncWriteExt;
use crate::error::ApiError;
pub struct MultipartOpts {
pub max_parts: usize,
pub form_field_max_bytes: usize,
pub file_max_bytes: usize,
pub spool_dir: PathBuf,
}
impl Default for MultipartOpts {
fn default() -> Self {
Self {
max_parts: 256,
form_field_max_bytes: 4096,
file_max_bytes: 1024 * 1024 * 1024, spool_dir: std::env::temp_dir().join("cognee-uploads"),
}
}
}
#[derive(Debug)]
pub struct SpooledFile {
pub filename: Option<String>,
pub content_type: Option<String>,
pub path: PathBuf,
pub byte_count: u64,
}
pub struct ParsedForm {
pub fields: HashMap<String, Vec<String>>,
pub files: HashMap<String, Vec<SpooledFile>>,
pub spool_dir: PathBuf,
}
pub struct UploadGuard {
spool_dir: PathBuf,
}
impl UploadGuard {
pub fn new(dir: PathBuf) -> Self {
Self { spool_dir: dir }
}
pub fn dir(&self) -> &Path {
&self.spool_dir
}
}
impl Drop for UploadGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.spool_dir);
}
}
pub async fn parse_multipart(
mut multipart: Multipart,
opts: &MultipartOpts,
request_id: &str,
) -> Result<ParsedForm, ApiError> {
let spool_dir = opts.spool_dir.join(sanitize_path_component(request_id));
tokio::fs::create_dir_all(&spool_dir)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to create spool dir: {e}")))?;
let mut fields: HashMap<String, Vec<String>> = HashMap::new();
let mut files: HashMap<String, Vec<SpooledFile>> = HashMap::new();
let mut part_count = 0usize;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ApiError::BadRequest(format!("multipart parse error: {e}")))?
{
part_count += 1;
if part_count > opts.max_parts {
return Err(ApiError::BadRequest(format!(
"Too many parts (max {})",
opts.max_parts
)));
}
let name = match field.name() {
Some(n) => n.to_owned(),
None => continue, };
let filename = field.file_name().map(|s| s.to_owned());
let content_type = field.content_type().map(|s| s.to_owned());
let is_file = filename.is_some()
|| content_type
.as_deref()
.map(|ct| !ct.starts_with("text/"))
.unwrap_or(false);
if is_file {
let safe_name = filename
.as_deref()
.map(sanitize_path_component)
.unwrap_or_else(|| format!("part-{part_count}"));
let dest = spool_dir.join(format!("{part_count}-{safe_name}"));
let byte_count = stream_to_disk(field, &dest, opts.file_max_bytes).await?;
files.entry(name).or_default().push(SpooledFile {
filename,
content_type,
path: dest,
byte_count,
});
} else {
let data: Bytes = field
.bytes()
.await
.map_err(|e| ApiError::BadRequest(format!("form field read error: {e}")))?;
if data.len() > opts.form_field_max_bytes {
return Err(ApiError::BadRequest(format!(
"Form field {name} exceeds {} bytes",
opts.form_field_max_bytes
)));
}
let value = String::from_utf8_lossy(&data).into_owned();
fields.entry(name).or_default().push(value);
}
}
Ok(ParsedForm {
fields,
files,
spool_dir,
})
}
async fn stream_to_disk(
field: axum::extract::multipart::Field<'_>,
dest: &Path,
max_bytes: usize,
) -> Result<u64, ApiError> {
let mut file = tokio::fs::File::create(dest)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to create spool file: {e}")))?;
let data: Bytes = field
.bytes()
.await
.map_err(|e| ApiError::BadRequest(format!("file read error: {e}")))?;
if data.len() > max_bytes {
return Err(ApiError::BadRequest(format!(
"file part exceeds maximum size of {max_bytes} bytes"
)));
}
file.write_all(&data)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to write spool file: {e}")))?;
Ok(data.len() as u64)
}
pub fn sanitize_path_component(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| {
if c == '/'
|| c == '\\'
|| c == ':'
|| c == '*'
|| c == '?'
|| c == '"'
|| c == '<'
|| c == '>'
|| c == '|'
|| c == '\0'
{
'_'
} else {
c
}
})
.collect();
let mut truncated = sanitized;
while truncated.len() > 200 {
truncated.pop();
}
truncated
}
#[allow(clippy::result_large_err)] pub fn check_filename_traversal(filename: &str) -> Result<(), ApiError> {
if filename.contains("../")
|| filename.contains("..\\")
|| filename.starts_with('/')
|| filename.starts_with('\\')
{
return Err(ApiError::BadRequest(format!(
"Invalid filename: {filename}"
)));
}
Ok(())
}