Skip to content
fullstackhero

Reference

Storage building block

File storage abstraction — local filesystem or S3-compatible (MinIO / AWS S3) — with presigned URLs, tenant isolation, and optional quota metering.

views 0 Last updated

The Storage block is the kit’s blob-storage abstraction. One interface (IStorageService), two implementations — LocalStorageService (filesystem) and S3StorageService (AWS S3 + MinIO compatible) — with presigned URL support and an optional QuotaMeteredStorageService decorator that meters per-tenant usage against the Quota block.

What it ships

Extensions

  • AddHeroLocalFileStorage(services) — registers LocalStorageService for wwwroot-based file I/O. Useful for dev.
  • AddHeroStorage(services, configuration) — reads Storage:Provider eagerly at registration time. "s3" registers a shared IAmazonS3 client + S3StorageService; anything else falls back to LocalStorageService. Wraps the chosen provider with QuotaMeteredStorageService when QuotaOptions:Enabled is true (also read at registration).

Interface

public interface IStorageService
{
// Returns the stored object's relative path/key
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<long> GetSizeAsync(string path, CancellationToken cancellationToken = default); // 0 if missing
Task RemoveAsync(string path, CancellationToken cancellationToken = default);
Task<PresignedUploadUrl> GenerateUploadUrlAsync(
string storageKey, string contentType, long maxBytes, TimeSpan ttl,
CancellationToken cancellationToken = default);
Task<Uri> GenerateDownloadUrlAsync(
string storageKey, TimeSpan ttl, string? responseContentDisposition = null,
CancellationToken cancellationToken = default);
Task<StoredObjectMetadata?> HeadObjectAsync(string storageKey, CancellationToken cancellationToken = default);
string BuildPublicUrl(string storageKey); // durable non-expiring URL (or server-relative path on local)
}

Implementations

  • LocalStorageService — stores under wwwroot/uploads/{owner-type}/{guid}_{sanitized-filename} and validates extension + size against FileTypeMetadata.GetRules(fileType). Presigning is a dev-only token fallback: GenerateUploadUrlAsync issues a local://upload/{token} URL backed by LocalPresignTokenStore; GenerateDownloadUrlAsync and BuildPublicUrl return server-relative /uploads/... paths (no signing needed).
  • S3StorageService — uses AWSSDK.S3 with a singleton IAmazonS3 client. A custom ServiceUrl (MinIO etc.) switches to path-style addressing per ForcePathStyle; presigned PUT/GET URLs come from the SDK’s request signer.
  • QuotaMeteredStorageService — decorator. CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, ct) on upload (throws 507 when exceeded, rolls back the charge if the write fails); refunds the object’s size on RemoveAsync. Requests with no resolved tenant pass through unmetered.

Request / response

  • FileUploadRequestFileName, ContentType, Data.
  • FileDownloadResponseStream, ContentType, FileName, ContentLength?.
  • PresignedUploadUrlUrl, RequiredHeaders (headers the browser must send verbatim, e.g. Content-Type), ExpiresAt.
  • StoredObjectMetadataSizeBytes, ContentType, LastModified, ETag (from HEAD).
  • FileType enum — Image, Document, Pdf; FileTypeMetadata maps each to an extension whitelist + max size.

Options

  • S3StorageOptionsBucket, Region, Prefix, PublicRead (default true), PublicBaseUrl (for non-expiring public URLs), ServiceUrl (custom endpoint for MinIO etc.), AccessKey / SecretKey (leave empty to use the AWS SDK credential chain), ForcePathStyle (only applies when ServiceUrl is set).

How modules consume Storage

The Files module is the canonical consumer: its request-upload-url endpoint mints presigned PUT URLs, the finalize handler verifies size + content type via HeadObjectAsync before a row leaves PendingUpload, and purge jobs clear orphans.

Direct usage (without the Files module) looks like:

public sealed class UploadAvatarHandler(IStorageService storage, ICurrentUser current)
: ICommandHandler<UploadAvatarCommand, string> // returns the public URL
{
public async ValueTask<string> Handle(UploadAvatarCommand cmd, CancellationToken ct)
{
var storageKey = await storage.UploadAsync<UserAvatar>(
new FileUploadRequest
{
FileName = $"{current.GetUserId()}-avatar.png",
ContentType = "image/png",
Data = cmd.Data,
},
FileType.Image,
ct).ConfigureAwait(false);
return storage.BuildPublicUrl(storageKey);
}
}

The generic T names the owning type — local storage uses it as the folder segment (uploads/useravatar/...).

Configuration

{
"Storage": {
"Provider": "s3", // or "local"
"S3": {
"Bucket": "fsh-uploads",
"ServiceUrl": "http://minio:9000", // omit for AWS S3 default
"Region": "us-east-1",
"ForcePathStyle": true, // required for MinIO
"AccessKey": "minioadmin",
"SecretKey": "set-via-secrets",
"PublicBaseUrl": "https://cdn.example.com" // for non-expiring public URLs
}
}
}

How to extend

Add Azure Blob Storage

Implement IStorageService against the Azure SDK and register it in place of the S3 implementation:

services.AddSingleton<IStorageService, AzureBlobStorageService>();

Wrap with the quota decorator if you want:

services.Decorate<IStorageService, QuotaMeteredStorageService>();

(The kit doesn’t ship Scrutor; copy its Decorate pattern or register manually with IConfigureOptions.)

Use AWS IAM role for credentials

Omit AccessKey and SecretKey from S3StorageOptions; the AWS SDK falls through to the instance-profile / EKS-pod-identity / web-identity chain automatically.

Scan files post-upload

The Files module ships an IFileScanner hook. If you’re using IStorageService directly without the Files module, kick off the scan in the same handler that finalises the upload.

Gotchas

  • MinIO needs ForcePathStyle = true. Virtual-hosted-style addressing puts the bucket name in the subdomain, which MinIO can’t service without DNS gymnastics. The option defaults to false and only takes effect when ServiceUrl is set — set it explicitly for MinIO and other self-hosted S3-compatible services.
  • Presigned URLs have a TTL. Once it expires, the URL is dead. Use BuildPublicUrl for non-expiring public URLs (and a bucket policy that grants public-read on that prefix).
  • QuotaMeteredStorageService is scoped. It depends on IQuotaService which is scoped per request. Don’t resolve it from a singleton or a hosted service without creating a scope.
  • Local storage paths are not tenant-segmented. Files land under wwwroot/uploads/{owner-type}/, and anything BuildPublicUrl points at is served statically without policy enforcement. Don’t use LocalStorageService in multi-tenant production — it exists for dev and tests.
  • AddHeroStorage reads configuration eagerly. The provider choice and the quota toggle are evaluated once, at registration. Integration tests that swap config after host build must re-register/rewire IStorageService post-registration — changing Storage:Provider later does nothing.

Critical files

  • src/BuildingBlocks/Storage/Extensions.cs
  • src/BuildingBlocks/Storage/Services/IStorageService.cs
  • src/BuildingBlocks/Storage/Local/LocalStorageService.cs
  • src/BuildingBlocks/Storage/S3/S3StorageService.cs
  • src/BuildingBlocks/Storage/S3/S3StorageOptions.cs
  • Files module — the policy + lifecycle layer on top of this block.
  • Quota — usage metering wrapper.
  • Catalog module — product images flow through here.