ripress 2.5.1

An Express.js-inspired web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
#![warn(missing_docs)]
use crate::helpers::{extract_boundary, parse_multipart_form};
use crate::next::Next;
use crate::req::body::FormData;
use crate::url::encode;
use crate::{context::HttpResponse, req::HttpRequest, types::MiddlewareOutput};
use tokio::fs::{create_dir_all, File};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;

/// Builtin File Upload Middleware
///
/// This middleware handles file uploads by processing request bodies and saving
/// them to a specified upload directory. It supports raw binary uploads
/// (application/octet-stream) and browser uploads via `multipart/form-data`.
/// It automatically generates unique filenames using UUIDs and infers
/// file extensions from the binary content.
///
/// ## Features
///
/// * **Automatic file extension detection** using the `infer` crate
/// * **Unique filename generation** with UUIDs to prevent conflicts
/// * **Graceful error handling** - continues request processing even if upload fails
/// * **Configurable upload directory** with fallback to "uploads"
/// * **File size limits** - configurable maximum file size per file
/// * **File count limits** - configurable maximum number of files per request
/// * **File type filtering** - optional whitelist of allowed file extensions
/// * **Non-blocking operation** - doesn't short-circuit requests on upload failures
/// * **Supports multipart/form-data** - extracts ALL file parts and saves them
/// * **Multiple file support** - handles multiple files in a single request
/// * **Form field preservation** - text fields from multipart forms are preserved
///
/// ## Configuration
///
/// The middleware accepts an optional `FileUploadConfiguration` struct with the following options:
///
/// * `upload_dir` - Directory path for file uploads (default: "uploads")
/// * `max_file_size` - Maximum size per file in bytes (default: 10 MB)
/// * `max_files` - Maximum number of files per request (default: 100)
/// * `allowed_file_types` - Vector of allowed file extensions (default: empty = all types allowed)
///
/// ## Behavior
///
/// The middleware processes requests as follows:
///
/// 1. **Content-Type Detection**: Identifies multipart/form-data vs binary content
/// 2. **Body Extraction**: Retrieves raw bytes from request body with fallback to form data
/// 3. **Multipart Parsing**: If multipart, extracts both file and text fields
/// 4. **Validation**: Checks file count, size, and type limits
/// 5. **Directory Creation**: Automatically creates the upload directory if it doesn't exist
/// 6. **File Processing**: Saves all valid files with unique filenames and detected extensions
/// 7. **Form Field Injection**: Adds text fields to request form data and file field names
/// 8. **Error Handling**: Logs errors but continues request processing without short-circuiting
///
/// ## Form Data Integration
///
/// The middleware integrates with the request's form data system:
///
/// * **Text fields** from multipart forms are added to the request's form data
/// * **File field names** are populated with the generated filenames for uploaded files
/// * **Single binary uploads** use "file" as the default field name
///
/// ## Examples
///
/// Basic usage with default configuration:
///
/// ```rust
/// use ripress::{app::App, middlewares::file_upload::{FileUploadConfiguration, file_upload}};
///
/// let mut app = App::new();
/// app.use_pre_middleware("/upload", file_upload(Some(FileUploadConfiguration::default())));
/// ```
///
/// Custom configuration with size and type limits:
///
/// ```rust
/// use ripress::{app::App, middlewares::file_upload::{file_upload, FileUploadConfiguration}};
///
/// let mut app = App::new();
/// let config = FileUploadConfiguration {
///     upload_dir: "user_uploads".to_string(),
///     max_file_size: 1024 * 1024 * 5, // 5 MB
///     max_files: 10,
///     allowed_file_types: vec!["jpg".to_string(), "png".to_string(), "pdf".to_string()],
/// };
/// app.use_pre_middleware("/upload", file_upload(Some(config)));
/// ```
///
/// Using default configuration (no argument needed):
///
/// ```rust
/// use ripress::{app::App, middlewares::file_upload::file_upload};
///
/// let mut app = App::new();
/// app.use_pre_middleware("/upload", file_upload(None));
/// ```
///
/// Route handler that processes uploaded files:
///
/// ```rust
/// use ripress::context::{HttpRequest, HttpResponse};
///
/// async fn upload_handler(req: HttpRequest, res: HttpResponse) -> HttpResponse {
///     // Access uploaded file via form field (for single uploads)
///     let form_data = req.form_data().unwrap();
///
///     if let Some(filename) = form_data.get("file") {
///         res.ok().text(format!("File uploaded as: {}", filename))
///     } else {
///         res.ok().text("No file was uploaded")
///     }
/// }
/// ```
///
/// Handling multipart form with multiple files:
///
/// ```rust
/// use ripress::context::{HttpRequest, HttpResponse};
///
/// async fn multi_upload_handler(req: HttpRequest, res: HttpResponse) -> HttpResponse {
///     let mut uploaded_files = Vec::new();
///     let form_data = req.form_data().unwrap();
///     
///     // Check each possible file field
///     for field_name in ["avatar", "document", "attachment"] {
///         if let Some(filename) = form_data.get(field_name) {
///             uploaded_files.push(format!("{}: {}", field_name, filename));
///         }
///     }
///     
///     // Access text fields from the multipart form
///     let user_name = form_data.get("name").unwrap_or("Anonymous");
///     
///     if uploaded_files.is_empty() {
///         res.ok().text("No files were uploaded")
///     } else {
///         res.ok().text(format!(
///             "User: {}\nUploaded files:\n{}",
///             user_name,
///             uploaded_files.join("\n")
///         ))
///     }
/// }
/// ```
///
/// ## Error Handling
///
/// The middleware is designed to be non-blocking and fault-tolerant:
///
/// * **Upload failures** are logged to stderr but don't stop request processing
/// * **Directory creation failures** are logged but allow the request to continue
/// * **File size exceeded** - individual files are skipped with logging
/// * **Too many files** - entire request is logged but continues without uploads
/// * **Disallowed file types** - individual files are skipped with logging
/// * **File write failures** are logged but don't short-circuit the request
/// * **Body parsing failures** - logged and request continues without uploads
///
/// ## Security Considerations
///
/// * **File type validation** - Use `allowed_file_types` to restrict uploads
/// * **Size limits** - Configure `max_file_size` and `max_files` appropriately
/// * **Unique filenames** - UUID-based names prevent directory traversal and conflicts
/// * **Directory isolation** - Files are saved only within the configured upload directory
/// * **No execution** - Middleware only handles storage, not file execution
///
/// ## Dependencies
///
/// This middleware requires the following crates:
/// * `tokio` - For async file operations and directory creation
/// * `uuid` - For generating unique filenames
///
/// ## Logging
///
/// The middleware logs various events to stderr for debugging:
/// * Directory creation failures
/// * File size limit exceeded
/// * File count limit exceeded  
/// * Disallowed file type attempts
/// * File creation and write failures
/// * Body parsing failures
///
/// ## Performance Notes
///
/// * Files are processed sequentially, not in parallel
/// * Large files are loaded entirely into memory before writing
/// * Directory creation is checked on every request (consider pre-creating directories)
/// * File type detection requires reading file headers
///
/// ## Compatibility
///
/// * Works with both single binary uploads and multipart/form-data
/// * Backwards compatible with existing form field access patterns
/// * Graceful degradation when uploads fail
/// * Case-insensitive Content-Type header detection

/// Configuration struct for the file upload middleware
///
/// This struct defines all configurable aspects of the file upload behavior,
/// including storage location, size limits, and file type restrictions.
#[derive(Clone)]
pub struct FileUploadConfiguration {
    /// Directory path where uploaded files will be stored
    ///
    /// The directory will be created automatically if it doesn't exist.
    /// Relative paths are resolved from the current working directory.
    pub upload_dir: String,

    /// Maximum size allowed for individual files in bytes
    ///
    /// Files exceeding this limit will be skipped and logged.
    /// Default is 10 MB (1024 * 1024 * 10).
    pub max_file_size: u64,

    /// Maximum number of files allowed per request
    ///
    /// Requests with more files will be logged and no files will be uploaded.
    /// Default is 100.
    pub max_files: u64,

    /// List of allowed file extensions (without dots)
    ///
    /// If empty, all file types are allowed. Extensions are detected automatically
    /// using the `infer` crate based on file headers, not filenames.
    /// Example: vec!["jpg".to_string(), "png".to_string(), "pdf".to_string()]
    pub allowed_file_types: Vec<String>,
}

impl Default for FileUploadConfiguration {
    fn default() -> Self {
        Self {
            upload_dir: "uploads".to_string(),
            max_file_size: 1024 * 1024 * 10,
            max_files: 100,
            allowed_file_types: Vec::new(),
        }
    }
}

/// Creates a file upload middleware function
///
/// Returns a middleware function that can be used with `app.use_pre_middleware()` to handle
/// file uploads on specified routes. The middleware processes multipart/form-data and
/// binary uploads, saving files to the configured directory with unique UUID-based names.
///
/// ## Parameters
///
/// * `config` - Optional configuration. If `None`, uses default settings.
///
/// ## Returns
///
/// A middleware function compatible with the ripress framework that:
/// * Processes file uploads from request bodies
/// * Saves files with unique filenames and detected extensions  
/// * Adds form fields for uploaded filenames
/// * Preserves text fields from multipart forms
/// * Handles errors gracefully without blocking requests
///
/// ## Thread Safety
///
/// The returned middleware is `Send + Sync + Clone` and can be safely used
/// across multiple threads and cloned for multiple routes.
pub fn file_upload(
    config: Option<FileUploadConfiguration>,
) -> impl Fn(HttpRequest, HttpResponse, Next) -> MiddlewareOutput + Send + Sync + Clone + 'static {
    let config = config.unwrap_or_default();
    move |mut req, res, next| {
        let config = config.clone();
        let upload_path = config.upload_dir.clone();
        Box::pin(async move {
            let content_type = req.headers.content_type().unwrap_or_default();
            let is_multipart = content_type.to_lowercase().contains("multipart/form-data");
            let boundary = if is_multipart {
                extract_boundary(&content_type)
            } else {
                None
            };

            let bytes_vec = if is_multipart {
                match req.bytes() {
                    Ok(bytes) => bytes.to_vec(),
                    Err(e) => {
                        eprintln!(
                            "File upload middleware: multipart/form-data detected but req.bytes() failed error: {}",
                            e
                        );
                        return next.call(req, res).await;
                    }
                }
            } else {
                match req.bytes() {
                    Ok(bytes) => bytes.to_vec(),
                    Err(_) => match req.form_data() {
                        Ok(form_data) => {
                            let form_string = form_data_to_string(form_data);
                            if form_string.is_empty() {
                                eprintln!("File upload middleware: No form data available");
                                return next.call(req, res).await;
                            }
                            form_string.into_bytes()
                        }
                        Err(_) => {
                            eprintln!(
                                "File upload middleware: Both bytes() and form_data() failed"
                            );
                            return next.call(req, res).await;
                        }
                    },
                }
            };

            let (fields, file_parts) = if let Some(ref boundary_str) = boundary {
                parse_multipart_form(&bytes_vec, boundary_str)
            } else {
                (Vec::new(), Vec::new())
            };

            for (k, v) in fields {
                req.insert_form_field(&k, &v);
            }

            let files_to_process = if !file_parts.is_empty() {
                file_parts
            } else if boundary.is_some() {
                Vec::new()
            } else {
                vec![(bytes_vec, Some("file"))]
            };

            if files_to_process.len() > config.max_files as usize {
                eprintln!(
                    "File upload middleware: Too many files ({} > {})",
                    files_to_process.len(),
                    config.max_files
                );
                return next.call(req, res).await;
            }

            if let Err(e) = create_dir_all(&upload_path).await {
                eprintln!("Failed to create upload directory '{}': {}", upload_path, e);
                return next.call(req, res).await;
            }

            let mut uploaded_files = Vec::new();

            for (file_bytes, field_name_opt) in files_to_process {
                if file_bytes.len() > config.max_file_size as usize {
                    eprintln!(
                        "File upload middleware: File too large ({} bytes > {} bytes)",
                        file_bytes.len(),
                        config.max_file_size
                    );
                    continue;
                }

                let (file_bytes, _original_filename, field_name) = match field_name_opt {
                    Some(field) => (file_bytes, None::<String>, Some(field)),
                    None => (file_bytes, None::<String>, None),
                };
                let extension = infer::get(&file_bytes)
                    .map(|info| info.extension())
                    .unwrap_or("bin");

                if !config.allowed_file_types.is_empty() {
                    let ext_norm = extension.to_ascii_lowercase();
                    let ext_norm = if ext_norm == "jpg" {
                        "jpeg".to_string()
                    } else {
                        ext_norm
                    };
                    let allowed = config.allowed_file_types.iter().any(|e| {
                        let e = e.to_ascii_lowercase();
                        e == ext_norm || (e == "jpg" && ext_norm == "jpeg")
                    });
                    if !allowed {
                        eprintln!(
                            "File upload middleware: File type '{}' not allowed (allowed types: {:?})",
                            extension, config.allowed_file_types
                        );
                        continue;
                    }
                }

                let id = Uuid::new_v4();
                let filename = format!("{}.{}", id, extension);
                let filename_with_path = format!("{}/{}.{}", upload_path, id, extension);

                match File::create(&filename_with_path).await {
                    Ok(mut file) => {
                        if let Err(e) = file.write_all(&file_bytes).await {
                            eprintln!("Failed to write file '{}': {}", filename_with_path, e);
                            continue;
                        }

                        uploaded_files.push(filename.clone());

                        if let Some(field_name) = field_name {
                            req.insert_form_field(&field_name, &filename);
                        }
                    }
                    Err(e) => {
                        eprintln!("Failed to create file '{}': {}", filename_with_path, e);
                        continue;
                    }
                }
            }

            return next.call(req, res).await;
        })
    }
}

/// Converts HashMap<String, String> form data to a string representation
///
/// This is an internal helper function used as a fallback when binary data
/// extraction fails. It URL-encodes form key-value pairs into a query string format.
///
/// ## Parameters
///
/// * `form_data` - Reference to the form data HashMap
///
/// ## Returns
///
/// A URL-encoded string representation of the form data, or an empty string
/// if the form data is empty.
#[cfg(feature = "file-upload")]
fn form_data_to_string(form_data: &FormData) -> String {
    if form_data.is_empty() {
        return String::new();
    }

    form_data
        .iter()
        .map(|(key, value)| format!("{}={}", encode(key), encode(value)))
        .collect::<Vec<_>>()
        .join("&")
}