Core API File Upload and Download

The BookStore API needs to support book cover images. This topic adds endpoints to upload image files to the server and download them on demand. File handling in ASP.NET Core uses IFormFile for uploads and file stream responses for downloads.

Understanding File Upload in Web API

When a client uploads a file, it uses a multipart/form-data request. This is different from a regular JSON request — it sends the file as binary data along with additional form fields.

POST /api/books/1/cover
Content-Type: multipart/form-data; boundary=----FormBoundary

------FormBoundary
Content-Disposition: form-data; name="file"; filename="clean-code.jpg"
Content-Type: image/jpeg

[binary image data]
------FormBoundary--

Step 1 – Create the Upload Folder

Images are stored in a dedicated folder inside the project. Add this folder to .gitignore to avoid committing uploaded files to source control.

BookStoreAPI/
├── Uploads/
│   └── BookCovers/          ← uploaded images go here

Step 2 – Update the Book Model

// Models/Book.cs
public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string Category { get; set; } = string.Empty;
    public bool IsAvailable { get; set; }
    public DateTime CreatedDate { get; set; }
    public string? CoverImagePath { get; set; }    // ← NEW: stores file path
}

Run a new migration after adding the field:

dotnet ef migrations add AddCoverImagePath
dotnet ef database update

Step 3 – Create the File Service

// Services/FileService.cs
namespace BookStoreAPI.Services
{
    public interface IFileService
    {
        Task<string> SaveFileAsync(IFormFile file, string folder);
        void DeleteFile(string filePath);
        bool FileExists(string filePath);
    }

    public class FileService : IFileService
    {
        private readonly IWebHostEnvironment _env;
        private readonly ILogger<FileService> _logger;

        // Allowed file types for book covers
        private readonly string[] _allowedExtensions = { ".jpg", ".jpeg", ".png", ".webp" };
        private const long MaxFileSizeBytes = 5 * 1024 * 1024;   // 5 MB

        public FileService(IWebHostEnvironment env, ILogger<FileService> logger)
        {
            _env = env;
            _logger = logger;
        }

        public async Task<string> SaveFileAsync(IFormFile file, string folder)
        {
            // Validate file size
            if (file.Length > MaxFileSizeBytes)
                throw new ArgumentException("File size exceeds the 5 MB limit.");

            // Validate file extension
            var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
            if (!_allowedExtensions.Contains(extension))
                throw new ArgumentException($"File type '{extension}' is not allowed. Allowed: {string.Join(", ", _allowedExtensions)}");

            // Build the save path
            var uploadFolder = Path.Combine(_env.ContentRootPath, "Uploads", folder);
            Directory.CreateDirectory(uploadFolder);   // create if it doesn't exist

            // Generate a unique filename to prevent collisions
            var uniqueFileName = $"{Guid.NewGuid()}{extension}";
            var filePath = Path.Combine(uploadFolder, uniqueFileName);

            // Save the file
            using var stream = new FileStream(filePath, FileMode.Create);
            await file.CopyToAsync(stream);

            _logger.LogInformation("File saved: {FilePath}", filePath);

            // Return the relative path for storing in the database
            return Path.Combine("Uploads", folder, uniqueFileName);
        }

        public void DeleteFile(string filePath)
        {
            var fullPath = Path.Combine(_env.ContentRootPath, filePath);
            if (File.Exists(fullPath))
            {
                File.Delete(fullPath);
                _logger.LogInformation("File deleted: {FilePath}", fullPath);
            }
        }

        public bool FileExists(string filePath)
        {
            var fullPath = Path.Combine(_env.ContentRootPath, filePath);
            return File.Exists(fullPath);
        }
    }
}

Step 4 – Register the File Service

// Program.cs
builder.Services.AddScoped<IFileService, FileService>();

Step 5 – Add File Upload Endpoint to BooksController

// Controllers/V2/BooksController.cs
using Microsoft.AspNetCore.Http;

// POST /api/v2/books/1/cover — upload a book cover image
[HttpPost("{id:int}/cover")]
[Authorize(Roles = "Admin")]
[RequestSizeLimit(5_242_880)]   // 5 MB request size limit
public async Task<IActionResult> UploadCover(int id, IFormFile file)
{
    if (file == null || file.Length == 0)
        return BadRequest("No file was uploaded.");

    var book = await _bookService.GetByIdAsync(id);
    if (book == null) return NotFound($"Book with Id {id} was not found.");

    // Delete old cover if it exists
    if (!string.IsNullOrEmpty(book.CoverImagePath))
        _fileService.DeleteFile(book.CoverImagePath);

    // Save the new cover
    var savedPath = await _fileService.SaveFileAsync(file, "BookCovers");

    // Update the book record with the new image path
    await _bookService.UpdateCoverImageAsync(id, savedPath);

    return Ok(new
    {
        message = "Cover image uploaded successfully.",
        path = savedPath
    });
}

// GET /api/v2/books/1/cover — download a book cover image
[HttpGet("{id:int}/cover")]
[AllowAnonymous]
public async Task<IActionResult> GetCover(int id)
{
    var book = await _bookService.GetByIdAsync(id);
    if (book == null) return NotFound("Book not found.");

    if (string.IsNullOrEmpty(book.CoverImagePath) || !_fileService.FileExists(book.CoverImagePath))
        return NotFound("Cover image not found for this book.");

    var fullPath = Path.Combine(_env.ContentRootPath, book.CoverImagePath);
    var extension = Path.GetExtension(fullPath).ToLowerInvariant();

    var contentType = extension switch
    {
        ".jpg" or ".jpeg" => "image/jpeg",
        ".png" => "image/png",
        ".webp" => "image/webp",
        _ => "application/octet-stream"
    };

    var fileBytes = await System.IO.File.ReadAllBytesAsync(fullPath);
    return File(fileBytes, contentType, $"book-{id}-cover{extension}");
}

// DELETE /api/v2/books/1/cover — remove a book cover image
[HttpDelete("{id:int}/cover")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteCover(int id)
{
    var book = await _bookService.GetByIdAsync(id);
    if (book == null) return NotFound("Book not found.");

    if (string.IsNullOrEmpty(book.CoverImagePath))
        return NotFound("No cover image to delete.");

    _fileService.DeleteFile(book.CoverImagePath);
    await _bookService.UpdateCoverImageAsync(id, null);

    return NoContent();
}

Testing File Upload in Postman

Method: POST
URL:    https://localhost:7001/api/v2/books/1/cover
Authorization: Bearer {admin-jwt-token}
Body:   form-data
  Key:  file   (type: File)
  Value: [select clean-code.jpg from your computer]

Response: 200 OK
{
  "message": "Cover image uploaded successfully.",
  "path": "Uploads\\BookCovers\\3f7a4b12-...jpg"
}

Security Considerations for File Uploads

RiskProtection
Malicious file types (.exe, .php)Whitelist allowed extensions only
Oversized uploads (DoS attack)Enforce MaxFileSizeBytes limit
Path traversal attackUse Guid as filename — never use the original filename as-is
Executable files disguised as imagesValidate MIME type, not just extension
Serving uploaded files as web pagesStore files outside the wwwroot folder

Enabling Static File Access

To serve uploaded files directly via a URL (without the download endpoint), configure the static files middleware:

// Program.cs — serve files from the Uploads folder
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(
        Path.Combine(builder.Environment.ContentRootPath, "Uploads")),
    RequestPath = "/uploads"
});

// Files are now accessible at:
// GET https://localhost:7001/uploads/BookCovers/3f7a4b12-....jpg

Key Points

  • File uploads use IFormFile in the action method and require multipart/form-data content type from the client.
  • Always validate file size and extension before saving — never trust the client's filename or content type header alone.
  • Store files with a Guid-based filename to prevent path traversal attacks and file name conflicts.
  • Store only the relative path in the database — reconstruct the full path at runtime using the environment's content root.
  • For downloads, return the file with File(bytes, contentType, fileName) which sets the correct Content-Type and Content-Disposition headers.

Leave a Comment