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); // swallows store errors
// Server-side copy, overwriting the destination; the source stays. Throws on failure (incl. a missing source) Task CopyAsync(string sourceKey, string destinationKey, 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).CopyAsyncis a file copy that throwsFileNotFoundExceptionwhen the source is missing. 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.UploadAsync<T>writes underpublic/uploads/{owner-type}/..., because its result is always used as a public URL;CopyAsyncis an S3CopyObjectwithin the bucket (content type and metadata carry over). 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).CopyAsynccharges the copied object’s size without a quota check, so a move (copy, then delete) is net-zero and never fails because the tenant is at its limit.RemoveAsyncrefunds the object’s size only when the object is gone afterwards: providers swallow delete errors, so a failed delete keeps both the bytes and the charge. 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.
Key layout and visibility
Visibility is part of the object key. The first segment is a visibility root (StorageVisibilityRoot.Public = public/, StorageVisibilityRoot.Private = private/), and every stack the kit ships grants anonymous s3:GetObject on public/* only. An object is publicly readable exactly when its key starts with public/; everything else needs a presigned URL.
| Writer | Key |
|---|---|
Files module (StorageKeyBuilder) | {public|private}/tenants/{tenantId}/{owner-type}/{yyyy}/{MM}/{fileAssetId:N}/{sanitized-filename} |
S3StorageService.UploadAsync<T> | public/uploads/{owner-type}/{guid}_{filename} |
| Keys written before #1422 | tenants/... or uploads/..., with no root, so presigned-only |
StorageKeyBuilder is the one place that decides a Files key; don’t build one by hand. Changing a file’s visibility moves the object to the other root - see Files module.
Anonymous read per stack
| Stack | What grants anonymous read |
|---|---|
| Aspire | rustfs-init puts a bucket policy allowing s3:GetObject on fsh-uploads/public/*, re-applied on every run. The API gets Storage__S3__PublicBaseUrl = {rustfs S3 endpoint}/fsh-uploads. |
| Docker Compose | rustfs-init puts the same policy on fsh/public/* on every up. The API gets Storage__S3__PublicBaseUrl=${FSH_S3_PUBLIC_URL}/fsh. |
| AWS (Terraform) | app_s3_public_read_prefix (default public/) scopes both the anonymous policy (only when app_s3_enable_public_read is true) and the CloudFront read. The API gets Storage__S3__PublicBaseUrl=https://{cloudfront domain} when app_s3_enable_cloudfront is on. |
PublicBaseUrl
BuildPublicUrl(key) returns {PublicBaseUrl}/{key} when PublicBaseUrl is set. Without it, a client with a ServiceUrl falls back to {ServiceUrl}/{Bucket}/{key}, and plain AWS to the bucket’s s3.amazonaws.com URL. Set it to the base a browser reaches that maps to the bucket root: for a path-style store that includes the bucket name (https://s3.example.com/fsh), for CloudFront it is the distribution’s domain. The Compose stack talks to RustFS as http://rustfs:9000, so without PublicBaseUrl it would hand out public URLs no browser can resolve.
Storage:S3:Prefix
With a prefix set, every key is written as {Prefix}/{key}, so public objects land under {Prefix}/public/.... Two rules follow:
- The bucket policy (or
app_s3_public_read_prefix) must cover{Prefix}/public/*, notpublic/*. - The prefix must not be
publicorprivate. A key that already starts with the prefix is not prefixed again, so withPrefix = publicaprivate/...key is stored aspublic/private/...- anonymously readable. Nothing validates this at startup.
Local provider
LocalStorageService serves everything under wwwroot statically. It does not enforce private/ at all: a private file is as readable as a public one to anyone who has the path. It is for dev and tests only.
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 and becomes the folder segment (public/uploads/useravatar/... on S3, uploads/useravatar/... on local storage).
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" // browser-reachable base for public/* objects } }}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; they only work for keys underpublic/(or{Prefix}/public/), because that is all the bucket policy opens. CopyAsyncthrows,RemoveAsyncdoesn’t. Move an object as copy, then update the reference, then delete, and confirm the delete withExistsAsyncwhen it matters:RemoveAsynclogs and swallows store errors.- Docker Compose publishes the S3 port on all interfaces. Public files are served straight from
FSH_S3_PUBLIC_URL, so the store has to be reachable, but only through your proxy: firewallFSH_S3_PORT, or bind therustfsport mapping to loopback ("127.0.0.1:${FSH_S3_PORT:-9000}:9000") when the proxy runs on the same host. 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, and
private/is not enforced.UploadAsyncfiles land underwwwroot/uploads/{owner-type}/, and everything underwwwroot-private/keys included - 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/StorageVisibilityRoot.cssrc/BuildingBlocks/Storage/QuotaMeteredStorageService.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.