cognee_http_server/
multipart.rs1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use axum::extract::Multipart;
14use bytes::Bytes;
15use tokio::io::AsyncWriteExt;
16
17use crate::error::ApiError;
18
19pub struct MultipartOpts {
21 pub max_parts: usize,
23 pub form_field_max_bytes: usize,
25 pub file_max_bytes: usize,
27 pub spool_dir: PathBuf,
29}
30
31impl Default for MultipartOpts {
32 fn default() -> Self {
33 Self {
34 max_parts: 256,
35 form_field_max_bytes: 4096,
36 file_max_bytes: 1024 * 1024 * 1024, spool_dir: std::env::temp_dir().join("cognee-uploads"),
38 }
39 }
40}
41
42#[derive(Debug)]
44pub struct SpooledFile {
45 pub filename: Option<String>,
48 pub content_type: Option<String>,
50 pub path: PathBuf,
52 pub byte_count: u64,
54}
55
56pub struct ParsedForm {
58 pub fields: HashMap<String, Vec<String>>,
62 pub files: HashMap<String, Vec<SpooledFile>>,
65 pub spool_dir: PathBuf,
69}
70
71pub struct UploadGuard {
79 spool_dir: PathBuf,
80}
81
82impl UploadGuard {
83 pub fn new(dir: PathBuf) -> Self {
86 Self { spool_dir: dir }
87 }
88
89 pub fn dir(&self) -> &Path {
91 &self.spool_dir
92 }
93}
94
95impl Drop for UploadGuard {
96 fn drop(&mut self) {
97 let _ = std::fs::remove_dir_all(&self.spool_dir);
98 }
99}
100
101pub async fn parse_multipart(
113 mut multipart: Multipart,
114 opts: &MultipartOpts,
115 request_id: &str,
116) -> Result<ParsedForm, ApiError> {
117 let spool_dir = opts.spool_dir.join(sanitize_path_component(request_id));
118 tokio::fs::create_dir_all(&spool_dir)
119 .await
120 .map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to create spool dir: {e}")))?;
121
122 let mut fields: HashMap<String, Vec<String>> = HashMap::new();
123 let mut files: HashMap<String, Vec<SpooledFile>> = HashMap::new();
124 let mut part_count = 0usize;
125
126 while let Some(field) = multipart
127 .next_field()
128 .await
129 .map_err(|e| ApiError::BadRequest(format!("multipart parse error: {e}")))?
130 {
131 part_count += 1;
132 if part_count > opts.max_parts {
133 return Err(ApiError::BadRequest(format!(
134 "Too many parts (max {})",
135 opts.max_parts
136 )));
137 }
138
139 let name = match field.name() {
140 Some(n) => n.to_owned(),
141 None => continue, };
143
144 let filename = field.file_name().map(|s| s.to_owned());
145 let content_type = field.content_type().map(|s| s.to_owned());
146
147 let is_file = filename.is_some()
150 || content_type
151 .as_deref()
152 .map(|ct| !ct.starts_with("text/"))
153 .unwrap_or(false);
154
155 if is_file {
156 let safe_name = filename
157 .as_deref()
158 .map(sanitize_path_component)
159 .unwrap_or_else(|| format!("part-{part_count}"));
160 let dest = spool_dir.join(format!("{part_count}-{safe_name}"));
161 let byte_count = stream_to_disk(field, &dest, opts.file_max_bytes).await?;
162 files.entry(name).or_default().push(SpooledFile {
163 filename,
164 content_type,
165 path: dest,
166 byte_count,
167 });
168 } else {
169 let data: Bytes = field
171 .bytes()
172 .await
173 .map_err(|e| ApiError::BadRequest(format!("form field read error: {e}")))?;
174 if data.len() > opts.form_field_max_bytes {
175 return Err(ApiError::BadRequest(format!(
176 "Form field {name} exceeds {} bytes",
177 opts.form_field_max_bytes
178 )));
179 }
180 let value = String::from_utf8_lossy(&data).into_owned();
181 fields.entry(name).or_default().push(value);
182 }
183 }
184
185 Ok(ParsedForm {
186 fields,
187 files,
188 spool_dir,
189 })
190}
191
192async fn stream_to_disk(
198 field: axum::extract::multipart::Field<'_>,
199 dest: &Path,
200 max_bytes: usize,
201) -> Result<u64, ApiError> {
202 let mut file = tokio::fs::File::create(dest)
203 .await
204 .map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to create spool file: {e}")))?;
205
206 let data: Bytes = field
207 .bytes()
208 .await
209 .map_err(|e| ApiError::BadRequest(format!("file read error: {e}")))?;
210
211 if data.len() > max_bytes {
212 return Err(ApiError::BadRequest(format!(
213 "file part exceeds maximum size of {max_bytes} bytes"
214 )));
215 }
216
217 file.write_all(&data)
218 .await
219 .map_err(|e| ApiError::Internal(anyhow::anyhow!("failed to write spool file: {e}")))?;
220
221 Ok(data.len() as u64)
222}
223
224pub fn sanitize_path_component(name: &str) -> String {
228 let sanitized: String = name
229 .chars()
230 .map(|c| {
231 if c == '/'
232 || c == '\\'
233 || c == ':'
234 || c == '*'
235 || c == '?'
236 || c == '"'
237 || c == '<'
238 || c == '>'
239 || c == '|'
240 || c == '\0'
241 {
242 '_'
243 } else {
244 c
245 }
246 })
247 .collect();
248 let mut truncated = sanitized;
250 while truncated.len() > 200 {
251 truncated.pop();
252 }
253 truncated
254}
255
256#[allow(clippy::result_large_err)] pub fn check_filename_traversal(filename: &str) -> Result<(), ApiError> {
262 if filename.contains("../")
263 || filename.contains("..\\")
264 || filename.starts_with('/')
265 || filename.starts_with('\\')
266 {
267 return Err(ApiError::BadRequest(format!(
268 "Invalid filename: {filename}"
269 )));
270 }
271 Ok(())
272}