Skip to main content

Overview

The Storage building block provides file storage abstractions with support for local file system and Amazon S3-compatible storage (AWS S3, MinIO, DigitalOcean Spaces, etc.).
Switch between local and cloud storage without changing your code — just update configuration.

Key Components

IStorageService

Main abstraction for file operations:
IStorageService.cs
using FSH.Framework.Shared.Storage;
using FSH.Framework.Storage.DTOs;

namespace FSH.Framework.Storage.Services;

public interface IStorageService
{
    Task<string> UploadAsync<T>(
        FileUploadRequest request,
        FileType fileType,
        CancellationToken cancellationToken = default) where T : class;

    Task<FileDownloadResponse?> DownloadAsync(
        string path,
        CancellationToken cancellationToken = default);

    Task<bool> ExistsAsync(
        string path,
        CancellationToken cancellationToken = default);

    Task RemoveAsync(string path, CancellationToken cancellationToken = default);
}

FileUploadRequest

Request model for file uploads:
FileUploadRequest.cs
namespace FSH.Framework.Shared.Storage;

public sealed class FileUploadRequest
{
    public string Name { get; set; } = default!;
    public string Extension { get; set; } = default!;
    public byte[] Data { get; set; } = default!;
}

FileType

Supported file type categories:
FileType.cs
namespace FSH.Framework.Storage;

public enum FileType
{
    Image,
    Document,
    Video,
    Audio,
    Other
}

FileDownloadResponse

Response model for file downloads:
FileDownloadResponse.cs
namespace FSH.Framework.Storage.DTOs;

public sealed class FileDownloadResponse
{
    public string FileName { get; set; } = default!;
    public string ContentType { get; set; } = default!;
    public byte[] Data { get; set; } = default!;
}

Registration

using FSH.Framework.Storage;

builder.Services.AddHeroLocalFileStorage();

Usage Examples

Upload File

UploadProductImageHandler.cs
using FSH.Framework.Storage;
using FSH.Framework.Storage.Services;
using FSH.Framework.Shared.Storage;

public sealed class UploadProductImageHandler 
    : ICommandHandler<UploadProductImageCommand, string>
{
    private readonly IStorageService _storage;
    private readonly CatalogDbContext _db;

    public async ValueTask<string> Handle(
        UploadProductImageCommand cmd,
        CancellationToken ct)
    {
        var product = await _db.Products.FindAsync([cmd.ProductId], ct)
            ?? throw new NotFoundException($"Product {cmd.ProductId} not found.");

        var uploadRequest = new FileUploadRequest
        {
            Name = $"product-{cmd.ProductId}",
            Extension = Path.GetExtension(cmd.FileName),
            Data = cmd.FileData
        };

        // Upload and get the file path
        var filePath = await _storage.UploadAsync<Product>(
            uploadRequest,
            FileType.Image,
            ct);

        // Update product with image path
        product.UpdateImagePath(filePath);
        await _db.SaveChangesAsync(ct);

        return filePath;
    }
}

Download File

DownloadInvoiceEndpoint.cs
using FSH.Framework.Storage.Services;
using Microsoft.AspNetCore.Http;

public static class DownloadInvoiceEndpoint
{
    public static RouteHandlerBuilder MapDownloadInvoiceEndpoint(
        this IEndpointRouteBuilder endpoints)
    {
        return endpoints.MapGet("/invoices/{id:guid}/download",
            async (Guid id, IStorageService storage, InvoiceDbContext db, CancellationToken ct) =>
            {
                var invoice = await db.Invoices.FindAsync([id], ct)
                    ?? throw new NotFoundException($"Invoice {id} not found.");

                if (string.IsNullOrWhiteSpace(invoice.FilePath))
                {
                    throw new NotFoundException("Invoice file not found.");
                }

                var fileResponse = await storage.DownloadAsync(invoice.FilePath, ct);
                if (fileResponse is null)
                {
                    throw new NotFoundException("File not found in storage.");
                }

                return Results.File(
                    fileResponse.Data,
                    fileResponse.ContentType,
                    fileResponse.FileName);
            })
            .WithName("DownloadInvoice")
            .WithSummary("Download invoice PDF");
    }
}

Check File Exists

ValidateAttachmentHandler.cs
using FSH.Framework.Storage.Services;

public sealed class ValidateAttachmentHandler 
    : IQueryHandler<ValidateAttachmentQuery, bool>
{
    private readonly IStorageService _storage;

    public async ValueTask<bool> Handle(
        ValidateAttachmentQuery query,
        CancellationToken ct)
    {
        return await _storage.ExistsAsync(query.FilePath, ct);
    }
}

Delete File

DeleteProductHandler.cs
using FSH.Framework.Storage.Services;

public sealed class DeleteProductHandler : ICommandHandler<DeleteProductCommand>
{
    private readonly CatalogDbContext _db;
    private readonly IStorageService _storage;

    public async ValueTask<Unit> Handle(DeleteProductCommand cmd, CancellationToken ct)
    {
        var product = await _db.Products.FindAsync([cmd.Id], ct)
            ?? throw new NotFoundException($"Product {cmd.Id} not found.");

        // Delete associated image
        if (!string.IsNullOrWhiteSpace(product.ImagePath))
        {
            await _storage.RemoveAsync(product.ImagePath, ct);
        }

        _db.Products.Remove(product);
        await _db.SaveChangesAsync(ct);

        return Unit.Value;
    }
}

Upload from Endpoint

UploadFileEndpoint.cs
using FSH.Framework.Storage;
using FSH.Framework.Storage.Services;
using FSH.Framework.Shared.Storage;
using Microsoft.AspNetCore.Http;

public static class UploadFileEndpoint
{
    public static RouteHandlerBuilder MapUploadFileEndpoint(
        this IEndpointRouteBuilder endpoints)
    {
        return endpoints.MapPost("/upload",
            async (IFormFile file, IStorageService storage, CancellationToken ct) =>
            {
                if (file.Length == 0)
                {
                    return Results.BadRequest("No file uploaded.");
                }

                using var memoryStream = new MemoryStream();
                await file.CopyToAsync(memoryStream, ct);

                var uploadRequest = new FileUploadRequest
                {
                    Name = Path.GetFileNameWithoutExtension(file.FileName),
                    Extension = Path.GetExtension(file.FileName),
                    Data = memoryStream.ToArray()
                };

                var filePath = await storage.UploadAsync<object>(
                    uploadRequest,
                    FileType.Document,
                    ct);

                return Results.Ok(new { FilePath = filePath });
            })
            .WithName("UploadFile")
            .WithSummary("Upload a file")
            .DisableAntiforgery();  // Required for file uploads
    }
}

Provider Configuration

Local File Storage

Stores files in the application’s Files directory:
LocalStorageService.cs
// Files stored at: {AppRoot}/Files/{FileType}/{EntityType}/{FileName}
// Example: /Files/Images/Product/product-123.jpg
1

Configure Provider

appsettings.json
{
  "Storage": {
    "Provider": "local"
  }
}
2

Serve Static Files

Program.cs
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(
        Path.Combine(Directory.GetCurrentDirectory(), "Files")),
    RequestPath = "/files"
});
3

Access Files

Files are accessible at: https://yourapp.com/files/Images/Product/product-123.jpg

Amazon S3

1

Install AWS SDK

dotnet add package AWSSDK.S3
2

Create S3 Bucket

Create a bucket in AWS S3 console or via CLI:
aws s3 mb s3://my-app-files --region us-east-1
3

Configure Credentials

Set AWS credentials via environment variables or AWS CLI:
export AWS_ACCESS_KEY_ID=your-access-key
export AWS_SECRET_ACCESS_KEY=your-secret-key
4

Update Configuration

appsettings.json
{
  "Storage": {
    "Provider": "s3",
    "S3": {
      "Bucket": "my-app-files",
      "Region": "us-east-1"
    }
  }
}

MinIO (Self-Hosted S3)

MinIO is S3-compatible and can be used for local development:
docker-compose.yml
services:
  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data

volumes:
  minio-data:
appsettings.Development.json
{
  "Storage": {
    "Provider": "s3",
    "S3": {
      "Bucket": "dev-files",
      "ServiceUrl": "http://localhost:9000",  // MinIO endpoint
      "ForcePathStyle": true  // Required for MinIO
    }
  }
}
# Create bucket in MinIO
mc alias set minio http://localhost:9000 minioadmin minioadmin
mc mb minio/dev-files

File Path Structure

Files are organized by type and entity:
Local Storage:
/Files/{FileType}/{EntityType}/{FileName}

Examples:
/Files/Images/Product/product-123-abc.jpg
/Files/Documents/Invoice/invoice-456-def.pdf
/Files/Videos/Course/course-789-ghi.mp4

S3 Storage:
{Bucket}/{FileType}/{EntityType}/{FileName}

Examples:
s3://my-app-files/Images/Product/product-123-abc.jpg
s3://my-app-files/Documents/Invoice/invoice-456-def.pdf

Best Practices

1

Validate File Types

Always validate file extensions and MIME types before uploading.
2

Limit File Sizes

Enforce maximum file size limits (e.g., 10MB for images, 100MB for videos).
3

Use Unique Names

Append GUIDs or timestamps to prevent filename conflicts.
4

Store Metadata

Save file paths, sizes, and MIME types in your database.
5

Handle Cleanup

Delete orphaned files when entities are deleted.
6

Use CDN for Production

Serve files through a CDN (CloudFront, CloudFlare) for better performance.

Security Considerations

1. Validate File Content

FileValidator.cs
public static class FileValidator
{
    private static readonly string[] AllowedImageExtensions = { ".jpg", ".jpeg", ".png", ".gif" };
    private static readonly long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB

    public static bool IsValidImage(string fileName, byte[] data)
    {
        var extension = Path.GetExtension(fileName).ToLowerInvariant();
        if (!AllowedImageExtensions.Contains(extension))
        {
            return false;
        }

        if (data.Length > MaxFileSizeBytes)
        {
            return false;
        }

        // Validate image header (magic bytes)
        return IsValidImageHeader(data);
    }

    private static bool IsValidImageHeader(byte[] data)
    {
        if (data.Length < 4) return false;

        // JPEG: FF D8 FF
        if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF)
            return true;

        // PNG: 89 50 4E 47
        if (data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47)
            return true;

        return false;
    }
}

2. Sanitize File Names

var sanitizedName = Path.GetFileNameWithoutExtension(fileName)
    .Replace(" ", "-")
    .Replace("_", "-")
    .ToLowerInvariant();

var uniqueName = $"{sanitizedName}-{Guid.NewGuid():N}{extension}";

3. Scan for Malware

Integrate with antivirus services (ClamAV, VirusTotal) for uploaded files.

Testing

Mock Storage Service

StorageServiceTests.cs
using FSH.Framework.Storage.Services;
using NSubstitute;

public class ProductServiceTests
{
    [Fact]
    public async Task Should_Upload_Product_Image()
    {
        // Arrange
        var mockStorage = Substitute.For<IStorageService>();
        mockStorage.UploadAsync<Product>(Arg.Any<FileUploadRequest>(), Arg.Any<FileType>(), Arg.Any<CancellationToken>())
            .Returns("images/product/test.jpg");

        var handler = new UploadProductImageHandler(mockStorage, _db);

        // Act
        var result = await handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.Equal("images/product/test.jpg", result);
    }
}

Troubleshooting

Local Storage: Permission Denied

Ensure the application has write permissions to the Files directory:
chmod -R 755 Files/

S3: Access Denied

  • Verify AWS credentials are configured
  • Check IAM permissions for S3 bucket
  • Ensure bucket policy allows uploads

MinIO: Connection Refused

  • Verify MinIO is running: docker ps
  • Check endpoint URL in configuration
  • Ensure bucket exists: mc ls minio/

Package Reference

YourModule.csproj
<ItemGroup>
  <ProjectReference Include="..\..\BuildingBlocks\Storage\FSH.Framework.Storage.csproj" />
</ItemGroup>

Jobs Building Block

Process large files in background jobs

Web Building Block

Upload files via Minimal APIs

AWS S3 Documentation

Official AWS S3 documentation

MinIO Documentation

Self-hosted S3-compatible storage

Build docs developers (and LLMs) love