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)— registersLocalStorageServiceforwwwroot-based file I/O. Useful for dev.AddHeroStorage(services, configuration)— readsStorage:Providereagerly at registration time."s3"registers a sharedIAmazonS3client +S3StorageService; anything else falls back toLocalStorageService. Wraps the chosen provider withQuotaMeteredStorageServicewhenQuotaOptions:Enabledis 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 underwwwroot/uploads/{owner-type}/{guid}_{sanitized-filename}and validates extension + size againstFileTypeMetadata.GetRules(fileType). Presigning is a dev-only token fallback:GenerateUploadUrlAsyncissues alocal://upload/{token}URL backed byLocalPresignTokenStore;GenerateDownloadUrlAsyncandBuildPublicUrlreturn server-relative/uploads/...paths (no signing needed).S3StorageService— usesAWSSDK.S3with a singletonIAmazonS3client. A customServiceUrl(MinIO etc.) switches to path-style addressing perForcePathStyle; 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 onRemoveAsync. 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).FileTypeenum —Image,Document,Pdf;FileTypeMetadatamaps 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 MinIO etc.),AccessKey/SecretKey(leave empty to use the AWS SDK credential chain),ForcePathStyle(only applies whenServiceUrlis 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 tofalseand only takes effect whenServiceUrlis 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
BuildPublicUrlfor non-expiring public URLs (and a bucket policy that grants public-read on that prefix). QuotaMeteredStorageServiceis scoped. It depends onIQuotaServicewhich 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 anythingBuildPublicUrlpoints at is served statically without policy enforcement. Don’t useLocalStorageServicein multi-tenant production — it exists for dev and tests. AddHeroStoragereads 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/rewireIStorageServicepost-registration — changingStorage:Providerlater does nothing.
Critical files
src/BuildingBlocks/Storage/Extensions.cssrc/BuildingBlocks/Storage/Services/IStorageService.cssrc/BuildingBlocks/Storage/Local/LocalStorageService.cssrc/BuildingBlocks/Storage/S3/S3StorageService.cssrc/BuildingBlocks/Storage/S3/S3StorageOptions.cs
Related
- Files module — the policy + lifecycle layer on top of this block.
- Quota — usage metering wrapper.
- Catalog module — product images flow through here.