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)- 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 for every real S3 call. A customServiceUrl(RustFS, MinIO, etc.) switches to path-style addressing perForcePathStyle; presigned PUT/GET URLs come from the SDK’s request signer, through a second, keyedIAmazonS3(S3StorageService.PresignClientKey) aimed atPresignServiceUrlwhen it is set and atServiceUrlotherwise. 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 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 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;PublicBaseUrlonly 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,ServiceUrlorPresignServiceUrl).PresignServiceUrlmust 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 tofalseand only takes effect whenServiceUrlis 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
Hostheader, 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 useshttp://rustfs:9000), setPresignServiceUrlto the public one: presigned PUT and GET URLs are signed for it, their scheme follows it (httporhttps), and every other S3 call keeps usingServiceUrl. The host refuses to start when it is set but is not an absolutehttp(s)URL; left empty, presigning usesServiceUrlexactly as before. A reverse proxy in front of the public endpoint must forward theHostheader unchanged, or the store answers403 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
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. A factory that re-registers the S3 stack this way also has to register the keyed presign client (S3StorageService.PresignClientKey, asFshWebApplicationFactorydoes), orS3StorageServicefails to resolve.
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.