Skip to content
fullstackhero

Reference

Storage building block

File storage abstraction - local filesystem or S3-compatible (AWS S3, RustFS, MinIO, R2...) - 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 and any S3-compatible store - the kit runs RustFS locally) - 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 for every real S3 call. A custom ServiceUrl (RustFS, MinIO, etc.) switches to path-style addressing per ForcePathStyle; presigned PUT/GET URLs come from the SDK’s request signer, through a second, keyed IAmazonS3 (S3StorageService.PresignClientKey) aimed at PresignServiceUrl when it is set and at ServiceUrl otherwise. Signing itself is offline; the only network call that client can make is fetching ambient credentials when no keys are set.
  • 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

  • FileUploadRequest - FileName, ContentType, Data.
  • FileDownloadResponse - Stream, ContentType, FileName, ContentLength?.
  • PresignedUploadUrl - Url, RequiredHeaders (headers the browser must send verbatim, e.g. Content-Type), ExpiresAt.
  • StoredObjectMetadata - SizeBytes, ContentType, LastModified, ETag (from HEAD).
  • FileType enum - Image, Document, Pdf; FileTypeMetadata maps each to an extension whitelist + max size.

Options

  • S3StorageOptions - Bucket, Region, Prefix, PublicRead (default true), PublicBaseUrl (for non-expiring public URLs), ServiceUrl (custom endpoint for RustFS, MinIO, etc.), PresignServiceUrl (optional host that presigned upload/download URLs are signed for, when browsers reach the store on a different address than the API does; PublicBaseUrl only rewrites the plain public-read URLs, never a presigned one), AccessKey / SecretKey (leave empty to use the AWS SDK credential chain), ForcePathStyle (applies to each client with a custom endpoint, ServiceUrl or PresignServiceUrl). PresignServiceUrl must be an absolute http(s) URL with no path, checked at startup: a path would be signed into every URL and break behind a proxy.

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://rustfs:9000", // omit for AWS S3 default
"PresignServiceUrl": "https://s3.example.com", // host browsers reach; omit when ServiceUrl is already public
"Region": "us-east-1",
"ForcePathStyle": true, // required for RustFS / MinIO
"AccessKey": "rustfsadmin",
"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

  • Self-hosted S3 stores (RustFS, MinIO) need ForcePathStyle = true. Virtual-hosted-style addressing puts the bucket name in the subdomain, which they can’t service without DNS gymnastics. The option defaults to false and only takes effect when ServiceUrl is set - set it explicitly for RustFS, MinIO, and other self-hosted S3-compatible services.
  • A presigned URL only works on the host it was signed for. SigV4 signs the Host header, so the URL always points at the endpoint of the client that signed it. When the API reaches the store on an internal address a browser cannot resolve (the Docker Compose stack uses http://rustfs:9000), set PresignServiceUrl to the public one: presigned PUT and GET URLs are signed for it, their scheme follows it (http or https), and every other S3 call keeps using ServiceUrl. The host refuses to start when it is set but is not an absolute http(s) URL; left empty, presigning uses ServiceUrl exactly as before. A reverse proxy in front of the public endpoint must forward the Host header unchanged, or the store answers 403 SignatureDoesNotMatch, and the store’s CORS has to allow the front-end origins, because the browser PUTs to it cross-origin.
  • 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. A factory that re-registers the S3 stack this way also has to register the keyed presign client (S3StorageService.PresignClientKey, as FshWebApplicationFactory does), or S3StorageService fails to resolve.

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