Skip to content

Fix file streaming and streaming upload support - #19

Merged
renjuashokan merged 4 commits into
mainfrom
feat/fix_stream_big_file
Dec 20, 2025
Merged

Fix file streaming and streaming upload support#19
renjuashokan merged 4 commits into
mainfrom
feat/fix_stream_big_file

Conversation

@renjuashokan

@renjuashokan renjuashokan commented Dec 2, 2025

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Streaming file uploads for large files with the request body limit disabled.
    • Partial content downloads via Range requests (206 responses) for byte-range streaming.
    • Responses can include an additional SHA-1 hash when available.
  • Improvements

    • Stronger validation, filename sanitization and path containment/security checks.
    • Enhanced logging, chunked/streaming hashing, and clearer response headers.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Dec 2, 2025

Copy link
Copy Markdown

Walkthrough

Uploads now use streaming multipart handling (axum::extract::Multipart) with async chunked writes and optional SHA-512/SHA-1 computation; downloads support HTTP Range requests (206 Partial Content). Typed multipart deserialization and UploadForm were removed; hashing helpers became async streaming functions.

Changes

Cohort / File(s) Summary
File Streaming & Upload Handler
filepi-server/src/handlers/files.rs
Replaced TypedMultipart<UploadForm> with axum::extract::Multipart; upload_file streams multipart fields to disk, validates location/user, canonicalizes paths, supports client-provided SHA-512/SHA-1 deduplication, writes temp files and atomically renames, and returns UploadResponse. stream_file parses Range via parse_range_header, serves partial ranges with 206 Partial Content, and sets Content-Range, Accept-Ranges, Content-Length, Cache-Control, and Content-Disposition. Added parse_range_header and expanded logging/validation.
Models Simplification & Response Change
filepi-server/src/models/mod.rs
Removed UploadForm and typed-multipart imports; UploadResponse gains pub sha1: Option<String> (optional).
Server Configuration
filepi-server/src/main.rs
Added DefaultBodyLimit::disable() router layer for the upload route to allow streaming large request bodies; inline comment explaining streaming uploads.
Async Hash Utilities
filepi-server/src/handlers/hash_utilities.rs
Replaced sync SHA-512 helper with async streaming versions: pub async fn compute_file_sha512_streaming(path: &PathBuf) -> Result<String, std::io::Error> and new pub async fn compute_file_sha1_streaming(path: &PathBuf) -> Result<String, std::io::Error>, both reading in chunks with Tokio async IO.
Dependency Update
filepi-server/Cargo.toml
Added sha1 = "0.10.6" to dependencies for SHA-1 streaming support.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Server as Axum Handler
    participant FS as Filesystem
    note right of Server: Streaming upload flow
    Client->>Server: POST /upload (multipart stream, optional sha512/sha1)
    Server->>Server: parse multipart fields (location, user, filename)
    Server->>Server: canonicalize and validate path containment
    alt client provided hash && file exists
        Server->>FS: open & stream existing file to compute hash
        FS-->>Server: computed hash
        Server-->>Client: 200 OK (skipped=true, metadata)
    else write upload
        Server->>FS: create temp file
        Client-->>Server: stream file chunks
        Server->>FS: write chunks incrementally
        Server->>Server: compute sha512/sha1 (if requested)
        Server->>FS: move temp -> final path (atomic rename)
        Server-->>Client: 201 Created (upload metadata)
    end
Loading
sequenceDiagram
    actor Client
    participant Server as Axum Handler
    participant FS as Filesystem
    note right of Server: Range download flow
    Client->>Server: GET /files/:path (with Range header)
    Server->>Server: parse_range_header(range, file_size)
    Server->>FS: open file and seek to start
    FS-->>Server: stream requested bytes
    Server-->>Client: 206 Partial Content (Content-Range, Accept-Ranges, Content-Length)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Areas to inspect closely:
    • parse_range_header correctness for bytes=start-end, bytes=start-, and bytes=-suffix forms and invalid ranges.
    • upload_file streaming: multipart field handling, temp-file lifecycle, atomic rename, and error paths.
    • SHA-512/SHA-1 deduplication logic: when hashes are computed, compared, and returned.
    • Path canonicalization and containment checks to prevent directory traversal.
    • Async hash utilities: buffer size, use of Tokio async reads, and error handling.

Poem

🐰 I nibble bytes from stream to store,

chunks hop in, I hash and explore,
ranges let viewers skip and play,
temp files hop home at the end of day,
sha1 and sha512 hum — uploads adore.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: implementing streaming support for file uploads and file downloads (range request support), which are the primary objectives across all modified files.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/fix_stream_big_file

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
filepi-server/src/handlers/files.rs (5)

410-456: Range header support looks correct, but consider cache policy.

The Range request implementation correctly:

  • Parses the Range header and validates it
  • Seeks to the requested position
  • Limits the stream using take()
  • Returns 206 Partial Content with proper Content-Range header

However, note that Cache-Control: no-cache (line 451) may not be optimal for video streaming. Consider allowing browser caching for better performance, especially for static video content.

If caching is desired, consider:

-                    (header::CACHE_CONTROL, "no-cache".to_string()),
+                    (header::CACHE_CONTROL, "public, max-age=3600".to_string()),

427-427: Move inline import to top of file.

The use tokio::io::AsyncSeekExt; import is declared inline. For better code organization and consistency, move this import to the top of the file alongside other imports.

Apply this diff to move the import:

 use tokio::fs::File;
 use tokio::io::AsyncReadExt;
+use tokio::io::AsyncSeekExt;
 use tokio_util::io::ReaderStream;

And remove the inline declaration:

             // Seek to the start position
-            use tokio::io::AsyncSeekExt;
             file.seek(std::io::SeekFrom::Start(start))

677-682: Document the field ordering requirement.

The implementation requires location and user fields to be sent before the file field in the multipart request. This constraint is not obvious to API consumers and could lead to confusing errors. Consider documenting this requirement in the API documentation or function documentation.

Add a doc comment to the function:

+/// Handles streaming file uploads.
+///
+/// # Multipart Field Order
+/// The `location` and `user` fields must be provided before the `file` field.
+/// This is required for streaming validation.
 pub async fn upload_file(
     State(config): State<Arc<Config>>,
     mut multipart: axum::extract::Multipart,

783-807: Consider cleanup of partial files on streaming failure.

If streaming fails midway through writing chunks (lines 785-794), the partially written file remains on disk. While not a critical issue since the error is returned to the client, consider implementing cleanup to avoid leaving corrupted partial files.

One approach is to write to a temporary file first, then rename on success:

// Create temp file with unique name
let temp_path = upload_dir.join(format!(".{}.tmp", filename));
let mut output_file = tokio::fs::File::create(&temp_path).await?;

// ... stream chunks ...

output_file.flush().await?;

// On success, rename to final location
tokio::fs::rename(&temp_path, &target_path).await?;

This ensures atomic file creation and automatic cleanup on error (temp file can be cleaned up by a separate process).


830-836: Cache canonical root to avoid redundant filesystem operations.

The root directory is canonicalized here (lines 830-836) and also earlier in the file field processing (lines 711-719). Canonicalization involves filesystem calls and can be expensive. Consider caching canonical_root to avoid redundant operations.

Move the canonicalization to the beginning of the function:

     info!("Starting streaming file upload process");
 
+    // Canonicalize root directory once at the start
+    let canonical_root = PathBuf::from(&config.root_dir)
+        .canonicalize()
+        .map_err(|e| {
+            error!("Failed to canonicalize root directory: {}", e);
+            AppError::InternalError("Invalid root directory configuration".to_string())
+        })?;
+
     let mut location = String::new();
     let mut user = String::new();

Then remove the redundant canonicalizations at lines 711-719 and 830-836, and use the cached canonical_root instead.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f2f479e and 7738b40.

📒 Files selected for processing (2)
  • filepi-server/src/handlers/files.rs (9 hunks)
  • filepi-server/src/models/mod.rs (0 hunks)
💤 Files with no reviewable changes (1)
  • filepi-server/src/models/mod.rs
🧰 Additional context used
🧬 Code graph analysis (1)
filepi-server/src/handlers/files.rs (2)
filepi-server/src/models/file_info.rs (1)
  • from_path (22-107)
filepi-server/src/handlers/hash_utilities.rs (1)
  • compute_file_sha512 (5-11)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Build Rust (arm64)
  • GitHub Check: Build Blazor Frontend
🔇 Additional comments (3)
filepi-server/src/handlers/files.rs (3)

14-14: LGTM: Import changes align with streaming implementation.

The addition of AsyncReadExt and removal of UploadForm are consistent with the migration from buffered TypedMultipart to streaming Multipart.

Also applies to: 24-24


625-628: LGTM: Signature change enables true streaming uploads.

The migration from TypedMultipart<UploadForm> to axum::extract::Multipart enables processing file chunks without buffering the entire file in memory, which is essential for large file uploads.


815-850: LGTM: Upload finalization is correct.

The finalization logic properly:

  • Validates that a file was processed
  • Computes SHA-512 hash of the uploaded file
  • Constructs a complete response with all required fields

Comment thread filepi-server/src/handlers/files.rs
@renjuashokan
renjuashokan force-pushed the feat/fix_stream_big_file branch from 7738b40 to 064e3c4 Compare December 3, 2025 02:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
filepi-server/src/handlers/files.rs (3)

300-320: Directory traversal still possible in serve_file due to lack of canonicalization.

serve_file joins config.root_dir with the user-supplied file_path and only checks abs_path.starts_with(&config.root_dir) without canonicalizing either path. A path like "../etc/passwd" will still pass this check because PathBuf::starts_with operates on raw components and doesn't interpret .., allowing reads outside root_dir.

Recommend canonicalizing both the root and requested path and checking confinement on the canonical paths:

-    let file_path = file_path.trim_start_matches('/');
-    let abs_path = PathBuf::from(&config.root_dir).join(file_path);
-
-    // Security: prevent directory traversal
-    if !abs_path.starts_with(&config.root_dir) {
-        return Err(AppError::BadRequest("Invalid path".to_string()));
-    }
+    let file_path = file_path.trim_start_matches('/');
+    let abs_path = PathBuf::from(&config.root_dir).join(file_path);
+
+    // Security: canonicalize and ensure path stays within root_dir
+    let canonical_root = PathBuf::from(&config.root_dir)
+        .canonicalize()
+        .map_err(|e| {
+            error!("Failed to canonicalize root directory: {}", e);
+            AppError::InternalError("Invalid root directory configuration".to_string())
+        })?;
+
+    let abs_path = abs_path.canonicalize().map_err(|e| {
+        error!("Failed to canonicalize requested path {:?}: {}", abs_path, e);
+        AppError::NotFound("File not found".to_string())
+    })?;
+
+    if !abs_path.starts_with(&canonical_root) {
+        error!(
+            "Security violation: requested path {:?} is outside root {:?}",
+            abs_path, canonical_root
+        );
+        return Err(AppError::BadRequest("Invalid path".to_string()));
+    }

Same pattern should be applied to stream_file below for consistent protection.


368-406: Apply the same canonicalization-based directory traversal protection in stream_file.

stream_file uses the same join + starts_with(&config.root_dir) pattern as serve_file, so a ../ path can escape the root directory and expose arbitrary files for streaming.

Mirror the canonicalization-and-check approach used above for serve_file:

-    let file_path = file_path.trim_start_matches('/');
-    let abs_path = PathBuf::from(&config.root_dir).join(file_path);
-
-    // Security: prevent directory traversal
-    if !abs_path.starts_with(&config.root_dir) {
-        return Err(AppError::BadRequest("Invalid path".to_string()));
-    }
+    let file_path = file_path.trim_start_matches('/');
+    let abs_path = PathBuf::from(&config.root_dir).join(file_path);
+
+    // Security: canonicalize and ensure path stays within root_dir
+    let canonical_root = PathBuf::from(&config.root_dir)
+        .canonicalize()
+        .map_err(|e| {
+            error!("Failed to canonicalize root directory: {}", e);
+            AppError::InternalError("Invalid root directory configuration".to_string())
+        })?;
+
+    let abs_path = abs_path.canonicalize().map_err(|e| {
+        error!("Failed to canonicalize requested path {:?}: {}", abs_path, e);
+        AppError::NotFound("File not found".to_string())
+    })?;
+
+    if !abs_path.starts_with(&canonical_root) {
+        error!(
+            "Security violation: requested path {:?} is outside root {:?}",
+            abs_path, canonical_root
+        );
+        return Err(AppError::BadRequest("Invalid path".to_string()));
+    }

This closes the same traversal hole for the streaming endpoint.


567-642: create_folder allows path traversal via folder_name.

folder_name comes directly from the query, and dir_path is built as:

let dir_path = PathBuf::from(&full_path).join(&folder_name);

Without sanitization, a folder_name like "../outside" (or "..") will successfully pass all checks and create directories outside root_dir (e.g., /srv/root/../outside).

Add basic validation to restrict folder_name to a single, safe path segment:

     let folder_name = params.foldername.as_deref().unwrap_or_default();
@@
-    if folder_name.is_empty() {
+    if folder_name.is_empty() {
         error!("Folder name is empty");
         return Err(AppError::NotFound(format!(
             "Folder name should not be empty"
         )));
     }
+    // Disallow path separators and parent/self components
+    if folder_name == "." || folder_name == ".."
+        || folder_name.contains(['/', '\\', '\0'])
+    {
+        error!("Invalid folder name: '{}'", folder_name);
+        return Err(AppError::BadRequest("Invalid folder name".to_string()));
+    }
     debug!("✓ Folder name validated");

This prevents directory creation outside the configured root.

🧹 Nitpick comments (1)
filepi-server/src/main.rs (1)

6-13: DefaultBodyLimit is disabled for the entire api_routes router, not just /uploadfile.

Router::layer(DefaultBodyLimit::disable()) wraps the whole api_routes router (including routes added before and after), so all these endpoints now accept unbounded bodies. The comment suggests the intent was mainly for the streaming /uploadfile route.

If you only want to relax the limit for /uploadfile (and any other explicit upload endpoints), consider applying the layer at the route level instead of the whole router, for example:

-        .route("/uploadfile", post(files::upload_file))
-        // Disable body limit for streaming upload - we handle chunks directly
-        .layer(DefaultBodyLimit::disable())
+        // Disable body limit for streaming upload - we handle chunks directly
+        .route(
+            "/uploadfile",
+            post(files::upload_file).layer(DefaultBodyLimit::disable()),
+        )

and then decide explicitly which other routes (e.g., /syncfusion/upload) should also have the limit disabled.

Also applies to: 63-82

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7738b40 and 064e3c4.

📒 Files selected for processing (3)
  • filepi-server/src/handlers/files.rs (7 hunks)
  • filepi-server/src/main.rs (2 hunks)
  • filepi-server/src/models/mod.rs (0 hunks)
💤 Files with no reviewable changes (1)
  • filepi-server/src/models/mod.rs
🧰 Additional context used
🧬 Code graph analysis (2)
filepi-server/src/main.rs (1)
filepi-server/src/handlers/files.rs (1)
  • upload_file (648-940)
filepi-server/src/handlers/files.rs (2)
filepi-server/src/handlers/hash_utilities.rs (1)
  • compute_file_sha512 (5-11)
filepi-server/src/models/file_info.rs (1)
  • from_path (22-107)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Build Rust (arm64)
  • GitHub Check: Build Blazor Frontend
🔇 Additional comments (2)
filepi-server/src/handlers/files.rs (2)

1-25: Imports and logging setup align with new streaming handlers.

The added imports for async I/O and structured logging are consistent with the new streaming and multipart logic in this file.


368-476: Range-aware streaming behavior is well-structured overall.

Aside from the edge cases noted for parse_range_header, the stream_file implementation cleanly:

  • Parses valid Range headers into (start, end) pairs.
  • Seeks to the correct offset and limits the stream to the requested length.
  • Returns 206 Partial Content with appropriate Content-Range, Content-Length, Accept-Ranges, and cache headers.
  • Falls back to full-file 200 OK streaming when the Range header is absent or invalid.

This is a good fit for video streaming clients.

Comment thread filepi-server/src/handlers/files.rs
Comment thread filepi-server/src/handlers/files.rs
- fix create folder to accept query param
@renjuashokan
renjuashokan force-pushed the feat/fix_stream_big_file branch from 064e3c4 to 13e3462 Compare December 3, 2025 03:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
filepi-server/src/main.rs (1)

64-82: CRITICAL: Body limit disabled for all API routes, not just upload.

The DefaultBodyLimit::disable() layer at line 74 applies to the entire api_routes Router, including the syncfusion endpoints (lines 75-80). This exposes all routes to unbounded request bodies, creating a DoS vector.

To apply the layer only to /uploadfile, wrap that single route in its own Router:

     // Build API routes
     let api_routes = Router::new()
         .route("/files", get(files::get_files))
         .route("/videos", get(files::get_videos))
         .route("/search", get(files::search))
         .route("/file/{*wildcard}", get(files::serve_file))
         .route("/stream/{*wildcard}", get(files::stream_file))
         .route("/thumbnail/{*wildcard}", get(files::get_thumbnail))
         .route("/createfolder", post(files::create_folder))
-        // Disable body limit for streaming upload - we handle chunks directly
-        .route("/uploadfile", post(files::upload_file))
-        .layer(DefaultBodyLimit::disable())
+        .merge(
+            // Disable body limit for streaming upload - we handle chunks directly
+            Router::new()
+                .route("/uploadfile", post(files::upload_file))
+                .layer(DefaultBodyLimit::disable())
+        )
         .route(
             "/syncfusion/fileoperations",
             post(handlers::syncfusion::file_operations),
♻️ Duplicate comments (2)
filepi-server/src/handlers/files.rs (2)

478-516: Handle edge cases: empty files and zero-byte suffix ranges.

The parse_range_header function doesn't guard against:

  1. Empty files (file_size == 0): line 495 computes file_size - 1 which underflows to u64::MAX.
  2. Zero-byte suffix (bytes=-0): line 494 produces start = file_size, while line 495 sets end = file_size - 1, so start > end.

Both cases cause content_length = end - start + 1 in stream_file (line 419) to overflow or produce invalid ranges.

Apply this diff:

 fn parse_range_header(range_str: &str, file_size: u64) -> Option<(u64, u64)> {
     // Expected format: "bytes=start-end" or "bytes=start-" or "bytes=-suffix"
+    if file_size == 0 {
+        return None;
+    }
     let range_str = range_str.trim();
 
     if !range_str.starts_with("bytes=") {
@@ -489,8 +492,12 @@
     if let Some((start_str, end_str)) = range_part.split_once('-') {
         if start_str.is_empty() {
             // Suffix range: "-500" means last 500 bytes
             if let Ok(suffix) = end_str.parse::<u64>() {
+                if suffix == 0 {
+                    return None;
+                }
                 let start = file_size.saturating_sub(suffix);
-                return Some((start, file_size - 1));
+                if start < file_size {
+                    return Some((start, file_size - 1));
+                }
             }

780-825: Use streaming SHA-512 for deduplication to avoid blocking I/O.

The deduplication check (line 786) calls compute_file_sha512, which synchronously reads the entire existing file into memory with fs::read. For large files, this blocks the async runtime and can cause memory pressure.

The PR added compute_file_sha512_streaming in hash_utilities.rs specifically to address this, but it's not used here.

Apply this diff to use the streaming version:

+use crate::handlers::hash_utilities::compute_file_sha512_streaming;

Then at the dedup check:

                         info!("Checking SHA-512 hash for deduplication...");
 
                         // Compute SHA-512 hash of existing file
-                        let existing_hash = compute_file_sha512(&target_path).map_err(|e| {
+                        let existing_hash = compute_file_sha512_streaming(&target_path).await.map_err(|e| {
                             error!("Failed to compute SHA-512 hash of existing file: {}", e);
                             AppError::InternalError(format!("Failed to compute file hash: {}", e))
                         })?;
🧹 Nitpick comments (2)
filepi-server/src/handlers/hash_utilities.rs (1)

14-33: Remove redundant import inside function.

The use sha2::{Digest, Sha512}; statement at line 16 is redundant since these types are already imported at the module level (line 1).

Apply this diff:

 pub async fn compute_file_sha512_streaming(path: &PathBuf) -> Result<String, std::io::Error> {
     use tokio::{fs::File, io::{AsyncReadExt, BufReader}};
-    use sha2::{Digest, Sha512};
 
     let file = File::open(path).await?;
filepi-server/src/handlers/files.rs (1)

905-911: Remove or uncomment the SHA-512 computation code.

Lines 905-911 contain commented-out code for computing the SHA-512 hash of newly uploaded files. If this functionality is needed, uncomment and use the streaming version (compute_file_sha512_streaming). If not, delete the commented block to keep the codebase clean.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 064e3c4 and 13e3462.

📒 Files selected for processing (4)
  • filepi-server/src/handlers/files.rs (7 hunks)
  • filepi-server/src/handlers/hash_utilities.rs (1 hunks)
  • filepi-server/src/main.rs (2 hunks)
  • filepi-server/src/models/mod.rs (0 hunks)
💤 Files with no reviewable changes (1)
  • filepi-server/src/models/mod.rs
🧰 Additional context used
🧬 Code graph analysis (2)
filepi-server/src/main.rs (1)
filepi-server/src/handlers/files.rs (1)
  • upload_file (648-940)
filepi-server/src/handlers/files.rs (2)
filepi-server/src/handlers/hash_utilities.rs (1)
  • compute_file_sha512 (5-11)
filepi-server/src/models/file_info.rs (1)
  • from_path (22-107)
🔇 Additional comments (2)
filepi-server/src/handlers/files.rs (2)

567-645: LGTM with improved validation and logging.

The refactored create_folder function correctly:

  • Validates inputs before processing
  • Canonicalizes paths and enforces root containment
  • Adds comprehensive debug/info logging for troubleshooting

647-940: Streaming upload implementation is well-structured.

The refactored upload handler correctly:

  • Validates required fields (location, user) before processing the file
  • Sanitizes filenames to prevent path traversal (line 725)
  • Enforces root directory containment via canonicalization
  • Streams chunks directly to disk without buffering
  • Supports deduplication when client provides SHA-512 hash

The comprehensive logging will aid debugging and monitoring.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
filepi-server/src/handlers/files.rs (1)

738-742: Filename sanitization incomplete - path traversal still possible.

While line 741 replaces path separators with underscores, it doesn't extract just the basename component. A malicious client could still send "../../etc/passwd" which becomes "__..__..etc_passwd" - still containing directory traversal attempts.

The past review comment suggested extracting the basename using Path::new().file_name(), but the current implementation only replaces characters. Although subsequent canonicalization provides defense-in-depth, the filename should be sanitized to contain only the basename component.

Apply this diff to properly extract the basename:

                 // Get filename from field metadata
                 let raw_filename = field.file_name().unwrap_or("unnamed");
                 debug!("Raw filename from client: '{}'", raw_filename);
 
-                filename = raw_filename.replace(['/', '\\', '\0'], "_");
+                // Extract just the filename component, removing any path elements
+                filename = std::path::Path::new(raw_filename)
+                    .file_name()
+                    .and_then(|n| n.to_str())
+                    .unwrap_or("unnamed")
+                    .replace(['\0'], "_"); // Still sanitize null bytes
                 debug!("✓ Sanitized filename: '{}'", filename);
🧹 Nitpick comments (3)
filepi-server/src/handlers/hash_utilities.rs (1)

27-49: Consider extracting common streaming logic.

Both compute_file_sha512_streaming and compute_file_sha1_streaming share identical streaming logic with only the hasher type differing. Consider extracting the common pattern to reduce duplication.

Example approach:

use sha2::Digest;

async fn compute_hash_streaming<D: Digest + Default>(path: &PathBuf) -> Result<String, std::io::Error> {
    use tokio::{
        fs::File,
        io::{AsyncReadExt, BufReader},
    };

    let file = File::open(path).await?;
    let mut reader = BufReader::new(file);
    let mut hasher = D::default();
    let mut buf = [0u8; 8192];

    loop {
        let n = reader.read(&mut buf).await?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }

    let result = hasher.finalize();
    Ok(format!("{:x}", result))
}

pub async fn compute_file_sha512_streaming(path: &PathBuf) -> Result<String, std::io::Error> {
    compute_hash_streaming::<sha2::Sha512>(path).await
}

pub async fn compute_file_sha1_streaming(path: &PathBuf) -> Result<String, std::io::Error> {
    compute_hash_streaming::<sha1::Sha1>(path).await
}
filepi-server/src/handlers/files.rs (2)

725-734: Field ordering requirement may surprise API clients.

The upload handler requires location and user fields to be sent before the file field in the multipart request. This strict ordering requirement could break clients that send fields in a different order.

Consider either:

  1. Documenting this requirement clearly in API documentation
  2. Making the handler more flexible by buffering field values and processing the file field whenever it arrives

950-952: Consider logging or processing remaining fields instead of breaking.

The handler breaks immediately after processing the file field, which means any subsequent fields in the multipart request will be silently ignored. Consider either:

  1. Logging a warning if there are additional fields
  2. Continuing to consume the remaining fields for completeness
                 // Break after processing file - it's the last field and stream is consumed
                 debug!("Breaking from field loop after file processing");
-                break;
+                // Note: Continue loop to log any additional fields, but file stream is consumed
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 13e3462 and 97e3e90.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • filepi-server/Cargo.toml (1 hunks)
  • filepi-server/src/handlers/files.rs (7 hunks)
  • filepi-server/src/handlers/hash_utilities.rs (1 hunks)
  • filepi-server/src/models/mod.rs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
filepi-server/src/handlers/files.rs (2)
filepi-server/src/handlers/hash_utilities.rs (2)
  • compute_file_sha1_streaming (27-49)
  • compute_file_sha512_streaming (3-25)
filepi-server/src/models/file_info.rs (1)
  • from_path (22-107)
🔇 Additional comments (7)
filepi-server/src/models/mod.rs (1)

49-50: LGTM!

The addition of the optional sha1 field follows the same pattern as the existing sha512 field and correctly uses skip_serializing_if to omit the field when None.

filepi-server/src/handlers/hash_utilities.rs (1)

3-25: LGTM! Streaming implementation prevents memory issues.

The streaming implementation correctly addresses the previous concern about loading entire files into memory. The async I/O with an 8192-byte buffer is appropriate for balancing throughput and memory usage.

filepi-server/src/handlers/files.rs (4)

368-476: LGTM! Range support implementation is solid.

The streaming file handler correctly:

  • Parses Range headers via the helper function
  • Returns 206 Partial Content with proper Content-Range headers for valid ranges
  • Seeks to the correct position and limits the stream appropriately
  • Falls back to full file streaming when no range or invalid range is provided
  • Includes proper Accept-Ranges header in all responses

567-645: LGTM! Enhanced logging improves debuggability.

The create_folder function maintains all necessary security checks (path canonicalization, root containment validation) while adding comprehensive debug and info logging that will help troubleshoot upload issues.


796-893: LGTM! Deduplication logic with streaming hash computation is well-implemented.

The deduplication flow correctly:

  • Prioritizes SHA-512 over SHA-1 for better security
  • Uses streaming hash computation to avoid memory issues
  • Returns early with appropriate response when file is deduplicated
  • Includes informative logging for debugging

897-942: LGTM! Streaming upload implementation is memory-efficient.

The chunk-by-chunk streaming correctly:

  • Processes chunks without buffering the entire file
  • Includes comprehensive error handling for each chunk
  • Logs progress for large files (every 100 chunks)
  • Properly flushes data to disk after streaming completes
filepi-server/Cargo.toml (1)

20-20: SHA-1 is cryptographically deprecated; clarify its intended use.

SHA-1 should be considered cryptographically broken and unsuitable for security-critical use, provided only for legacy interoperability purposes. Version 0.10.6 is the current stable release and has no known implementation vulnerabilities. However, NIST formally deprecated SHA-1 in 2011 and disallowed use for digital signatures in 2013; as of 2020, chosen-prefix attacks are practical.

Evaluate whether this dependency requires collision detection. If so, consider using the sha1-checked crate which provides collision detection and safe hash alternatives. If SHA-1 is for non-cryptographic purposes (e.g., legacy Git object compatibility), the current version is acceptable.

Comment thread filepi-server/src/handlers/files.rs
Comment on lines +973 to 1008
// Compute SHA-512 hash of newly uploaded file
// info!("Computing SHA-512 hash of uploaded file...");
// let new_file_hash = compute_file_sha512(&file_path).map_err(|e| {
// error!("Failed to compute SHA-512 hash of uploaded file: {}", e);
// AppError::InternalError(format!("Failed to compute file hash: {}", e))
// })?;
// info!("✓ New file SHA-512: {}...", &new_file_hash[..16]);

// Security: ensure the upload path is within root_dir
// Get the relative path from root_dir
let canonical_root = PathBuf::from(&config.root_dir)
.canonicalize()
.map_err(|e| {
error!("Failed to canonicalize root directory: {}", e);
AppError::InternalError("Invalid root directory configuration".to_string())
})?;

if !upload_dir.starts_with(&canonical_root) {
return Err(AppError::BadRequest(
"Invalid upload path: outside root directory".to_string(),
));
}

// Full path for the file
let file_path = upload_dir.join(&filename);

// Check if file already exists and SHA-512 hash is provided
if file_path.exists() {
if let Some(client_hash) = client_sha512 {
info!("File already exists, checking SHA-512 hash for deduplication");

// Compute SHA-512 hash of existing file
let existing_hash = compute_file_sha512(&file_path).map_err(|e| {
error!("Failed to compute SHA-512 hash of existing file: {}", e);
AppError::InternalError(format!("Failed to compute file hash: {}", e))
})?;

info!(
"Client SHA-512: {}..., Existing file SHA-512: {}...",
&client_hash[..16],
&existing_hash[..16]
);

// If hashes match, skip upload
if client_hash == existing_hash {
info!("SHA-512 match - skipping upload for file: {}", filename);

let relative_path = file_path
.strip_prefix(&canonical_root)
.unwrap_or(&file_path)
.to_string_lossy()
.to_string();

return Ok(Json(crate::models::UploadResponse {
message: "File already exists with identical content, upload skipped"
.to_string(),
filename,
location: relative_path,
uploaded_by: user.to_string(),
skipped: true,
sha512: Some(existing_hash),
}));
} else {
info!("SHA-512 mismatch - file will be replaced");
}
} else {
info!("No SHA-512 provided - file will be replaced");
}
}

info!("Saving file to location: {:?}", file_path);

// Write the file (will overwrite if exists)
let mut file = std::fs::File::create(&file_path).map_err(|e| {
error!("Failed to create file: {}", e);
AppError::InternalError(format!("Failed to create file: {}", e))
})?;

file.write_all(&form.file.contents).map_err(|e| {
error!("Failed to write file: {}", e);
AppError::InternalError(format!("Failed to write file: {}", e))
})?;

info!(
"File uploaded successfully: {} to path: {:?}",
filename, file_path
);

// Compute SHA-512 hash of newly uploaded file
let new_file_hash = compute_file_sha512(&file_path).map_err(|e| {
error!("Failed to compute SHA-512 hash of uploaded file: {}", e);
AppError::InternalError(format!("Failed to compute file hash: {}", e))
})?;

info!("New file SHA-512: {}...", &new_file_hash[..16]);

// Get the relative path from root_dir
let relative_path = file_path
.strip_prefix(&canonical_root)
.unwrap_or(&file_path)
.to_string_lossy()
.to_string();

info!(
"=== Upload completed successfully: '{}' at '{}' ===",
filename, relative_path
);

Ok(Json(crate::models::UploadResponse {
message: "File uploaded successfully".to_string(),
filename,
location: relative_path,
uploaded_by: user.to_string(),
uploaded_by: user,
skipped: false,
sha512: Some(new_file_hash),
sha512: None,
sha1: None,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent hash computation behavior.

Lines 973-979 show commented-out SHA-512 hash computation for newly uploaded files, and the response at lines 1006-1007 returns sha512: None and sha1: None. This creates an inconsistency:

  • Deduplication path (lines 830-840, 874-884): Returns the computed hash
  • Successful upload path: Returns no hash

This makes it impossible for clients to verify uploaded files or cache hashes for future deduplication.

Consider either:

  1. Uncomment and fix the hash computation to provide integrity verification for uploads
  2. Document why hashes are not computed for new uploads (e.g., performance reasons)
  3. Make it optional via a query parameter if hash computation is expensive

If hash computation is intentionally disabled for performance, update the comments to explain this decision.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
filepi-server/src/handlers/files.rs (1)

983-1019: Inconsistent hash behavior between deduplication and new uploads.

The deduplication path (lines 840-850, 884-894) returns computed hashes in the response, but newly uploaded files return sha512: None and sha1: None (lines 1016-1017). This prevents clients from:

  • Verifying upload integrity
  • Caching hashes for future deduplication requests
  • Detecting silent corruption

The commented-out code at lines 983-989 suggests this was intentional. Consider either:

  1. Computing hashes for new uploads to enable client-side verification
  2. Documenting why hash computation is disabled (e.g., performance cost)
  3. Making hash computation optional via a query parameter

Note: This issue was previously flagged in past review comments.

🧹 Nitpick comments (3)
filepi-server/src/handlers/files.rs (3)

472-472: Consider including filename in Content-Disposition header.

Line 472 sets Content-Disposition: inline without including the filename. This is inconsistent with serve_file (lines 356-361), which includes the filename. While not strictly required for inline disposition, including the filename can be helpful for clients that save the file.

Apply this diff to include the filename:

+    // Get filename for Content-Disposition header
+    let file_name = abs_path
+        .file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or("download");
+
     // Build response with streaming headers (inline, not attachment)
     Ok((
         StatusCode::OK,
         [
             (header::CONTENT_TYPE, mime_type),
             (header::CONTENT_LENGTH, file_size.to_string()),
             (header::ACCEPT_RANGES, "bytes".to_string()),
             (header::CACHE_CONTROL, "no-cache".to_string()),
-            (header::CONTENT_DISPOSITION, "inline".to_string()),
+            (header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", file_name)),
         ],
         body,
     ))

748-752: Consider extracting basename to fully prevent path injection.

The current sanitization replaces path separators with underscores, which is safe but preserves directory components. For example, "subdir/file.txt" becomes "subdir_file.txt".

Extracting just the filename component (as suggested in past reviews) would be more robust:

Apply this diff to extract the basename:

                 // Get filename from field metadata
                 let raw_filename = field.file_name().unwrap_or("unnamed");
                 debug!("Raw filename from client: '{}'", raw_filename);
 
-                filename = raw_filename.replace(['/', '\\', '\0'], "_");
+                filename = std::path::Path::new(raw_filename)
+                    .file_name()
+                    .and_then(|n| n.to_str())
+                    .unwrap_or("unnamed")
+                    .to_string();
                 debug!("✓ Sanitized filename: '{}'", filename);

This extracts only the final path component, so "subdir/file.txt""file.txt" and "../../etc/passwd""passwd".


735-744: Field ordering dependency is validated but not documented.

The code requires location and user fields to be sent before the file field in the multipart request. This constraint is validated but not documented in the function signature or comments.

Consider adding a doc comment to document this requirement:

+/// Streaming upload handler - processes file chunks without loading entire file into memory.
+///
+/// # Multipart field order
+/// The multipart request must include fields in this order:
+/// - `location`: Upload directory path (required)
+/// - `user`: Username (required)  
+/// - `sha512`: Optional SHA-512 hash for deduplication
+/// - `sha1`: Optional SHA-1 hash for deduplication
+/// - `file`: File data (required, must be last)
 pub async fn upload_file(
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 97e3e90 and 4a8d435.

📒 Files selected for processing (1)
  • filepi-server/src/handlers/files.rs (7 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
filepi-server/src/handlers/files.rs (2)
filepi-server/src/handlers/hash_utilities.rs (2)
  • compute_file_sha1_streaming (27-49)
  • compute_file_sha512_streaming (3-25)
filepi-server/src/models/file_info.rs (1)
  • from_path (22-107)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build Blazor Frontend
🔇 Additional comments (4)
filepi-server/src/handlers/files.rs (4)

478-526: LGTM! Edge cases properly handled.

The parse_range_header function now correctly handles empty files and zero-length suffixes, preventing underflow and invalid ranges. The three range format cases (suffix, open-ended, full range) are all implemented correctly with proper bounds checking.


577-655: API change from JSON body to query parameters.

The create_folder function now accepts parameters via query string (Query<CreateFolderRequest>) instead of JSON body (Json<CreateFolderRequest>). This is a breaking change for existing clients.

The implementation itself is solid with proper validation, canonicalization, and security checks. The added debug logging is helpful for troubleshooting.


814-822: LGTM! Async streaming hash computation implemented.

The deduplication logic now uses compute_file_sha512_streaming and compute_file_sha1_streaming (async streaming functions), which read files in chunks without blocking the async runtime or allocating large buffers. This addresses the concern from previous reviews about blocking I/O and memory efficiency for large files.


913-952: LGTM! Efficient streaming upload implementation.

The file upload correctly streams chunks directly to disk without buffering the entire file in memory. The implementation includes:

  • Proper async I/O with AsyncWriteExt
  • Chunk-by-chunk processing with progress logging
  • Error handling for each chunk operation
  • Explicit flush to ensure data is written to disk

This design efficiently handles large file uploads without memory pressure.

@renjuashokan
renjuashokan merged commit bbb272a into main Dec 20, 2025
8 checks passed
@renjuashokan
renjuashokan deleted the feat/fix_stream_big_file branch December 20, 2025 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant