47. File Operations

File handling in APIs: multipart uploads, validation, streaming, and secure downloads

About this chapter

Handle file uploads and downloads safely by validating file types, streaming large files to avoid memory issues, and securing access to file resources.

  • File upload endpoints: Accepting multipart file uploads from clients
  • File validation: Checking file types, sizes, and preventing malicious uploads
  • Storage strategies: Where and how to store uploaded files securely
  • Streaming downloads: Returning large files efficiently without loading entirely in memory
  • Content-type headers: Proper MIME types and content disposition
  • Security considerations: Directory traversal prevention and access control

Learning outcomes:

  • Create file upload endpoints with IFormFile
  • Validate file types and sizes
  • Store uploads securely on disk or cloud storage
  • Stream file downloads efficiently
  • Return files with correct content-type headers
  • Prevent security vulnerabilities in file operations

47.1 Why File Operations Matter

Common scenarios:

  • Users upload CSV files to import data
  • Users upload images for profiles
  • API generates reports and returns PDF files
  • Admin uploads configuration files
  • Users download generated exports

Challenges:

  • Size: Files can be huge (1GB+ downloads)
  • Security: Validate file types, prevent directory traversal
  • Performance: Stream files to avoid loading entire file in memory
  • Headers: Correct content-type and disposition
  • Resume: Support pausing and resuming large uploads

47.2 File Upload Endpoints

Basic file upload:

// Controllers/FileController.cs
[ApiController]
[Route("api/[controller]")]
public class FilesController : ControllerBase
{
    private readonly ILogger<FilesController> _logger;
    private const string UploadDirectory = "/app/uploads";
    private const long MaxFileSize = 50 * 1024 * 1024;  // 50 MB

    public FilesController(ILogger<FilesController> logger)
    {
        _logger = logger;
    }

    [HttpPost("upload")]
    public async Task<IActionResult> UploadFile(IFormFile file)
    {
        // Validate file exists
        if (file == null || file.Length == 0)
        {
            return BadRequest("File is required");
        }

        // Validate file size
        if (file.Length > MaxFileSize)
        {
            return BadRequest($"File size exceeds {MaxFileSize / 1024 / 1024} MB limit");
        }

        // Validate file extension
        var allowedExtensions = new[] { ".csv", ".xlsx", ".json" };
        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        
        if (!allowedExtensions.Contains(extension))
        {
            return BadRequest($"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}");
        }

        // Validate MIME type
        var allowedMimeTypes = new[] { "text/csv", "application/json", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" };
        
        if (!allowedMimeTypes.Contains(file.ContentType))
        {
            return BadRequest("Invalid file content type");
        }

        // Generate unique filename
        var filename = $"{Guid.NewGuid()}{extension}";
        var filepath = Path.Combine(UploadDirectory, filename);

        try
        {
            // Create directory if doesn't exist
            Directory.CreateDirectory(UploadDirectory);

            // Save file
            using (var stream = System.IO.File.Create(filepath))
            {
                await file.CopyToAsync(stream);
            }

            _logger.LogInformation("File uploaded: {Filename}", filename);

            return Ok(new
            {
                filename = filename,
                originalName = file.FileName,
                size = file.Length,
                uploadedAt = DateTime.UtcNow
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "File upload failed");
            return StatusCode(500, "File upload failed");
        }
    }
}

47.3 Multipart Form Data

Upload with additional metadata:

[HttpPost("upload-with-metadata")]
public async Task<IActionResult> UploadWithMetadata(
    IFormFile file,
    [FromForm] string description,
    [FromForm] string category)
{
    if (file == null || file.Length == 0)
        return BadRequest("File is required");

    var metadata = new
    {
        filename = file.FileName,
        description,
        category,
        uploadedAt = DateTime.UtcNow
    };

    _logger.LogInformation("File upload with metadata: {@Metadata}", metadata);

    // Process file with metadata
    var filename = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
    var filepath = Path.Combine(UploadDirectory, filename);

    using (var stream = System.IO.File.Create(filepath))
    {
        await file.CopyToAsync(stream);
    }

    return Ok(new { success = true, filename, metadata });
}

Client (HTML form):

<form enctype="multipart/form-data">
    <input type="file" name="file" required />
    <input type="text" name="description" placeholder="Description" />
    <select name="category">
        <option value="reports">Reports</option>
        <option value="imports">Imports</option>
    </select>
    <button type="submit">Upload</button>
</form>

<script>
document.querySelector('form').addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const formData = new FormData(this);
    
    const response = await fetch('/api/files/upload-with-metadata', {
        method: 'POST',
        body: formData
    });
    
    const result = await response.json();
    console.log('Upload result:', result);
});
</script>

Client (JavaScript):

const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];

const formData = new FormData();
formData.append('file', file);
formData.append('description', 'My CSV import');
formData.append('category', 'imports');

const response = await fetch('/api/files/upload-with-metadata', {
    method: 'POST',
    body: formData
});

47.4 File Validation

Comprehensive validation:

// Services/FileValidationService.cs
public interface IFileValidationService
{
    ValidationResult ValidateFile(IFormFile file);
}

public class ValidationResult
{
    public bool IsValid { get; set; }
    public List<string> Errors { get; set; } = new();
}

public class FileValidationService : IFileValidationService
{
    private readonly ILogger<FileValidationService> _logger;
    private const long MaxFileSize = 50 * 1024 * 1024;

    private static readonly Dictionary<string, string[]> AllowedTypes = new()
    {
        { "csv", new[] { "text/csv", "text/plain" } },
        { "json", new[] { "application/json" } },
        { "xlsx", new[] { "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } },
        { "pdf", new[] { "application/pdf" } },
        { "jpg", new[] { "image/jpeg" } },
        { "png", new[] { "image/png" } }
    };

    public FileValidationService(ILogger<FileValidationService> logger)
    {
        _logger = logger;
    }

    public ValidationResult ValidateFile(IFormFile file)
    {
        var result = new ValidationResult { IsValid = true };

        // Check file exists
        if (file == null || file.Length == 0)
        {
            result.IsValid = false;
            result.Errors.Add("File is required");
            return result;
        }

        // Check filename
        if (string.IsNullOrWhiteSpace(file.FileName))
        {
            result.IsValid = false;
            result.Errors.Add("Invalid filename");
            return result;
        }

        // Check for path traversal
        var filename = Path.GetFileName(file.FileName);
        if (filename != file.FileName)
        {
            result.IsValid = false;
            result.Errors.Add("Path traversal detected");
            return result;
        }

        // Check file size
        if (file.Length > MaxFileSize)
        {
            result.IsValid = false;
            result.Errors.Add($"File exceeds {MaxFileSize / 1024 / 1024}MB limit");
        }

        // Check extension
        var extension = Path.GetExtension(file.FileName).TrimStart('.').ToLowerInvariant();
        if (!AllowedTypes.ContainsKey(extension))
        {
            result.IsValid = false;
            result.Errors.Add($"Extension .{extension} not allowed");
        }

        // Check MIME type
        if (!AllowedTypes[extension].Contains(file.ContentType))
        {
            result.IsValid = false;
            result.Errors.Add($"Invalid MIME type. Expected: {string.Join(", ", AllowedTypes[extension])}");
        }

        // Verify file signature (magic bytes)
        if (!VerifyFileSignature(file, extension))
        {
            result.IsValid = false;
            result.Errors.Add("File signature doesn't match extension");
        }

        if (result.Errors.Count > 0)
        {
            _logger.LogWarning("File validation failed: {@Errors}", result.Errors);
            result.IsValid = false;
        }

        return result;
    }

    private bool VerifyFileSignature(IFormFile file, string extension)
    {
        // Read first few bytes
        var headerBytes = new byte[8];
        using (var stream = file.OpenReadStream())
        {
            stream.Read(headerBytes, 0, 8);
        }

        // Verify magic bytes
        return extension switch
        {
            "pdf" => headerBytes[0] == 0x25 && headerBytes[1] == 0x50,  // %P
            "xlsx" => headerBytes[0] == 0x50 && headerBytes[1] == 0x4B,  // PK
            "png" => headerBytes[0] == 0x89 && headerBytes[1] == 0x50,  // .P
            "jpg" => headerBytes[0] == 0xFF && headerBytes[1] == 0xD8,  // FF D8
            _ => true  // Skip for text files
        };
    }
}

// Register in Program.cs
builder.Services.AddScoped<IFileValidationService, FileValidationService>();

// Use in controller
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file)
{
    var validationResult = _fileValidationService.ValidateFile(file);
    
    if (!validationResult.IsValid)
    {
        return BadRequest(new { errors = validationResult.Errors });
    }

    // Save file...
}

47.5 Streaming Large Files

Avoid loading entire file in memory:

[HttpPost("upload-large")]
public async Task<IActionResult> UploadLargeFile(IFormFile file)
{
    if (file == null || file.Length == 0)
        return BadRequest("File required");

    var filename = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
    var filepath = Path.Combine(UploadDirectory, filename);

    try
    {
        // Stream directly to disk (doesn't load entire file in memory)
        using (var sourceStream = file.OpenReadStream())
        using (var destinationStream = System.IO.File.Create(filepath))
        {
            await sourceStream.CopyToAsync(destinationStream, 81920);  // 80KB chunks
        }

        _logger.LogInformation("Large file uploaded: {Filename}, Size: {Size}", 
            filename, file.Length);

        return Ok(new { filename, size = file.Length });
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Large file upload failed");
        
        // Clean up partial file
        if (System.IO.File.Exists(filepath))
            System.IO.File.Delete(filepath);
        
        return StatusCode(500, "Upload failed");
    }
}

47.6 File Download

Download file with proper headers:

[HttpGet("download/{filename}")]
public IActionResult DownloadFile(string filename)
{
    // Validate filename (prevent directory traversal)
    var safeName = Path.GetFileName(filename);
    if (safeName != filename || string.IsNullOrWhiteSpace(safeName))
    {
        return BadRequest("Invalid filename");
    }

    var filepath = Path.Combine(UploadDirectory, safeName);

    // Check file exists
    if (!System.IO.File.Exists(filepath))
    {
        return NotFound("File not found");
    }

    try
    {
        // Read file into memory
        var fileBytes = System.IO.File.ReadAllBytes(filepath);

        // Return with proper headers
        return File(fileBytes, "application/octet-stream", filename);
        
        // Or specify content-type:
        // return File(fileBytes, "application/pdf", filename);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "File download failed");
        return StatusCode(500, "Download failed");
    }
}

Stream file download (for large files):

[HttpGet("download-stream/{filename}")]
public IActionResult DownloadFileStream(string filename)
{
    var safeName = Path.GetFileName(filename);
    if (safeName != filename)
        return BadRequest("Invalid filename");

    var filepath = Path.Combine(UploadDirectory, safeName);

    if (!System.IO.File.Exists(filepath))
        return NotFound("File not found");

    // Determine content type
    var contentType = Path.GetExtension(filename).ToLowerInvariant() switch
    {
        ".pdf" => "application/pdf",
        ".csv" => "text/csv",
        ".json" => "application/json",
        ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        _ => "application/octet-stream"
    };

    // Stream file (efficient for large files)
    var stream = System.IO.File.OpenRead(filepath);
    
    return File(stream, contentType, filename, enableRangeProcessing: true);
}

47.7 Resumable Uploads

Support pause and resume:

[HttpPost("upload-resumable")]
public async Task<IActionResult> UploadResumable(
    IFormFile file,
    [FromForm] int chunkIndex,
    [FromForm] int totalChunks,
    [FromForm] string uploadId)
{
    var chunkDirectory = Path.Combine(UploadDirectory, "chunks", uploadId);
    Directory.CreateDirectory(chunkDirectory);

    var chunkPath = Path.Combine(chunkDirectory, $"chunk_{chunkIndex}");

    try
    {
        // Save chunk
        using (var sourceStream = file.OpenReadStream())
        using (var fileStream = System.IO.File.Create(chunkPath))
        {
            await sourceStream.CopyToAsync(fileStream);
        }

        _logger.LogInformation("Chunk {ChunkIndex}/{TotalChunks} uploaded for upload {UploadId}", 
            chunkIndex + 1, totalChunks, uploadId);

        // Check if all chunks received
        var chunkFiles = Directory.GetFiles(chunkDirectory, "chunk_*");
        
        if (chunkFiles.Length == totalChunks)
        {
            // Merge chunks
            var finalPath = Path.Combine(UploadDirectory, $"{uploadId}.bin");
            
            using (var finalStream = System.IO.File.Create(finalPath))
            {
                for (int i = 0; i < totalChunks; i++)
                {
                    var chunkFile = Path.Combine(chunkDirectory, $"chunk_{i}");
                    using (var chunkStream = System.IO.File.OpenRead(chunkFile))
                    {
                        await chunkStream.CopyToAsync(finalStream);
                    }
                }
            }

            // Clean up chunk directory
            Directory.Delete(chunkDirectory, true);

            return Ok(new { uploadId, status = "complete", finalPath });
        }

        return Ok(new { uploadId, status = "chunk_received", chunkIndex });
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Resumable upload failed");
        return StatusCode(500, "Upload failed");
    }
}

Client-side resumable upload:

async function uploadResumable(file, chunkSize = 1024 * 1024) {  // 1MB chunks
    const uploadId = generateUUID();
    const totalChunks = Math.ceil(file.size / chunkSize);

    for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
        const start = chunkIndex * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('chunkIndex', chunkIndex);
        formData.append('totalChunks', totalChunks);
        formData.append('uploadId', uploadId);

        try {
            const response = await fetch('/api/files/upload-resumable', {
                method: 'POST',
                body: formData
            });

            const result = await response.json();
            console.log(`Chunk ${chunkIndex + 1}/${totalChunks} uploaded`);

            // Update progress
            const progress = ((chunkIndex + 1) / totalChunks) * 100;
            updateProgressBar(progress);

            if (result.status === 'complete') {
                console.log('Upload complete!');
                return result;
            }
        } catch (error) {
            console.error(`Chunk ${chunkIndex} failed:`, error);
            // Could retry with exponential backoff
        }
    }
}

47.8 Security Best Practices

1. Prevent directory traversal:

// ❌ WRONG
var filepath = Path.Combine(UploadDirectory, userProvidedFilename);

// ✓ CORRECT
var safeName = Path.GetFileName(userProvidedFilename);
var filepath = Path.Combine(UploadDirectory, safeName);

2. Store outside web root:

// ❌ WRONG - In web-accessible directory
private const string UploadDirectory = "/wwwroot/uploads";

// ✓ CORRECT - Outside web root
private const string UploadDirectory = "/app/uploads";

3. Validate MIME type:

if (!AllowedMimeTypes.Contains(file.ContentType))
{
    return BadRequest("Invalid file type");
}

4. Verify file signature:

// Check magic bytes match extension
if (!VerifyFileSignature(file, extension))
{
    return BadRequest("File signature doesn't match");
}

5. Scan for malware (optional):

dotnet add package ClamAV.Net
var clam = new ClamClient("localhost", 3310);
var result = await clam.ScanFileAsync(filepath);

if (result == ClamScanResult.Virus)
{
    System.IO.File.Delete(filepath);
    return BadRequest("File contains malware");
}

47.9 Testing File Operations

Test file upload:

// Tests/Controllers/FilesControllerTests.cs
public class FilesControllerTests
{
    [Fact]
    public async Task UploadFile_WithValidFile_ReturnsOk()
    {
        // Arrange
        var content = "test,data\n1,2\n3,4";
        var fileName = "test.csv";
        
        var file = new FormFile(
            new MemoryStream(Encoding.UTF8.GetBytes(content)),
            0,
            content.Length,
            "file",
            fileName)
        {
            Headers = new HeaderDictionary(),
            ContentType = "text/csv"
        };

        // Act
        var result = await _controller.UploadFile(file);

        // Assert
        var okResult = Assert.IsType<OkObjectResult>(result);
        Assert.NotNull(okResult.Value);
    }

    [Fact]
    public async Task UploadFile_WithOversizedFile_ReturnsBadRequest()
    {
        // Arrange
        var largeContent = new string('x', (50 * 1024 * 1024) + 1);  // 50MB + 1 byte
        
        var file = new FormFile(
            new MemoryStream(Encoding.UTF8.GetBytes(largeContent)),
            0,
            largeContent.Length,
            "file",
            "large.csv")
        {
            Headers = new HeaderDictionary(),
            ContentType = "text/csv"
        };

        // Act
        var result = await _controller.UploadFile(file);

        // Assert
        var badRequest = Assert.IsType<BadRequestObjectResult>(result);
        Assert.NotNull(badRequest.Value);
    }
}

47.10 What’s Next

You now have:

  • ✓ Basic file upload endpoints
  • ✓ Multipart form data with metadata
  • ✓ Comprehensive file validation
  • ✓ File signature verification
  • ✓ Streaming for large files
  • ✓ File download with proper headers
  • ✓ Range request support
  • ✓ Resumable uploads with chunks
  • ✓ Security best practices (directory traversal, MIME types, signatures)
  • ✓ Malware scanning
  • ✓ Testing file operations

Next: GraphQL Introduction—Querying and mutating data with GraphQL as alternative to REST.