The Files module is the kit’s shared object-storage layer. Any other module that needs file uploads (Catalog product images, Chat attachments, user avatars) goes through it. Files are owned by an OwnerType (e.g. Product, ChatChannel, User) and an OwnerId; per-OwnerType IFileAccessPolicy implementations decide who can attach, read, delete, and change visibility. Storage is MinIO / S3 via presigned URLs — the API never proxies bytes. (The Storage building block also ships a local-disk provider with emulated presign tokens for environments without object storage.)
What ships in v10
- Presigned upload flow —
POST /files/upload-urlmints a presigned PUT URL with a 15-minute TTL (configurable). Client uploads directly to MinIO / S3.POST /files/{id}/finalizeflips the file fromPendingUploadtoAvailable. - Pluggable access policies —
IFileAccessPolicyinterface registered perOwnerType. Catalog shipsProductFileAccessPolicy, Chat shipsChatChannelFileAccessPolicy, the Files module itself registersDefaultUploaderOnlyPolicyfor the built-inMyFilesandUserowner types. - Per-category validation — extension whitelist + size cap per category (
Image10 MB,Document25 MB,Archive50 MB), configured inappsettings. - Visibility — files are
PublicorPrivate;PATCH /files/{id}/visibilityflips it after upload (policy-gated, only onAvailablefiles).GET /files/sharedlists the tenant’s public free-standing files (MyFiles/Userowner types) for a “Shared in tenant” surface. - File scanner hook —
IFileScanner.ScanAsync(storageKey)withNoOpFileScanneras the default (alwaysClean). Implement it to plug in ClamAV / GuardDuty / VirusTotal; anInfectedscan result transitions the file toQuarantinedinstead ofAvailable. - Soft delete + retention — deleted files go to trash;
PurgeDeletedFilesJob(Hangfire, daily 03:30 UTC) hard-deletes after 30 days. The tenant dashboard’s Trash page restores from here (permission-gated tab). - Orphan cleanup —
PurgeOrphanedFilesJob(hourly) deletesPendingUploadrows whose upload deadline passed without a finalize call. - Storage quota metering — finalize records the uploaded bytes against the tenant’s
StorageBytesquota. FileFinalizedIntegrationEvent— published when a file is finalized (AvailableorQuarantined), carrying owner type/id, content type, size, and final status. No built-in consumer ships today — Catalog and Chat attach files via explicit commands carrying thefileAssetId+ URL — but it’s the hook for search indexing, notifications, and the like.- 5 permissions —
Files.Upload,DeleteOwn,DeleteAny,ViewTrash,Restore.
Architecture at a glance
src/Modules/Files/├── Modules.Files/ ~1,600 LoC│ ├── FilesModule.cs IModule entry — order 350│ ├── FilesOptions.cs Bound from the "Files" appsettings section│ ├── Domain/FileAsset.cs AggregateRoot, state machine, ISoftDeletable│ ├── Data/FilesDbContext.cs Schema: files│ ├── Authorization/│ │ └── DefaultUploaderOnlyPolicy.cs For MyFiles + User│ ├── Services/│ │ ├── FileAccessPolicyRegistry.cs OwnerType → IFileAccessPolicy lookup│ │ ├── StorageKeyBuilder.cs Tenant + category path generation│ │ └── NoOpFileScanner.cs Default IFileScanner│ ├── Jobs/│ │ ├── PurgeOrphanedFilesJob.cs Hangfire hourly│ │ └── PurgeDeletedFilesJob.cs Hangfire 03:30 daily│ └── Features/v1/ 10 features└── Modules.Files.Contracts/ ~250 LoC, incl. IFileAccessPolicyThe lifecycle
FileAsset is the aggregate. Three states, transitions one-way:
finalizePendingUpload ────────────────────► Available │ │ scan = Infected └──────────────────────────► QuarantinedUpload-deadline expiry transitions PendingUpload to hard-deleted via the orphan-purge job. Available and Quarantined files are soft-deletable; the daily purge hard-deletes after the retention window.
public sealed class FileAsset : AggregateRoot<Guid>, ISoftDeletable{ public FileAssetStatus Status { get; private set; } // PendingUpload | Available | Quarantined public DateTimeOffset? UploadDeadline { get; private set; }
public static FileAsset CreatePending( Guid id, string ownerType, Guid? ownerId, string originalFileName, string sanitizedFileName, string contentType, long declaredSizeBytes, string storageKey, Visibility visibility, string createdByUserId, DateTimeOffset uploadDeadline) { /* ... */ }
public void MarkAvailable(long actualSize, ScanStatus scanResult) { // throws 409 unless Status == PendingUpload SizeBytes = actualSize; ScanStatus = scanResult; Status = scanResult == ScanStatus.Infected ? FileAssetStatus.Quarantined : FileAssetStatus.Available; UploadDeadline = null;
AddDomainEvent(DomainEvent.Create((id, ts) => new FileFinalizedDomainEvent(Id, OwnerType, OwnerId, Status, id, ts))); }
public void ChangeVisibility(Visibility next) { /* 409 unless Available */ }}Finalize HEADs the uploaded object, rejects uploads larger than the declared size (plus a 1 % slack), runs the scanner, and records the bytes against the tenant’s storage quota.
Access policies
IFileAccessPolicy is the seam every consuming module implements:
public interface IFileAccessPolicy{ string OwnerType { get; } Task<bool> CanAttachAsync(Guid? ownerId, string currentUserId, CancellationToken cancellationToken); Task<bool> CanReadAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken); Task<bool> CanDeleteAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken);
// defaults to the CanDelete rule; override to forbid visibility flips entirely Task<bool> CanChangeVisibilityAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken) => CanDeleteAsync(context, currentUserId, cancellationToken);}Policies receive a FileAccessContext record (file id, owner type/id, uploader, visibility) and a primitive currentUserId rather than a ClaimsPrincipal, so the contract stays free of ASP.NET Core types. Tenant scoping is enforced by BaseDbContext, not delegated to policies.
Catalog’s ProductFileAccessPolicy allows any authenticated user to attach (the durable gate is the product-update permission when the image is attached to the product), open read (product images are public), and uploader-only delete. Chat’s ChatChannelFileAccessPolicy requires channel membership to attach and read; delete is uploader-only.
FileAccessPolicyRegistry looks up the right policy by OwnerType. Missing policy → request rejected. This is a deliberate fail-closed default.
Public API
| Type | Purpose |
|---|---|
RequestUploadUrlCommand(ownerType, ownerId?, fileName, contentType, sizeBytes, visibility, category) | Mints a presigned PUT URL and a pending FileAsset |
FinalizeUploadCommand(fileAssetId) | HEADs the object, scans, flips to Available/Quarantined |
ChangeFileVisibilityCommand(fileAssetId, visibility) | Flip Public ↔ Private (policy-gated) |
DeleteFileCommand(fileId) | Soft delete |
RestoreFileCommand(fileId) | Undelete |
GetFileMetadataQuery(fileId) | Single file’s metadata |
GetFileDownloadUrlQuery(fileId) | Mints a presigned GET URL |
ListMyFilesQuery(paging) | Paginated caller-scoped list |
ListSharedFilesQuery(paging) | Public tenant-wide files (MyFiles / User owner types) |
ListTrashedFilesQuery(paging) | Paginated trash |
The PresignedUploadResponse returned by RequestUploadUrl carries FileAssetId, UploadUrl, the RequiredHeaders the client must send with the PUT, and ExpiresAt. The download response is a presigned GET URL.
Endpoints
| Verb | Route | Permission | What it does |
|---|---|---|---|
| POST | /api/v1/files/upload-url | Files.Upload | Mint presigned PUT URL |
| POST | /api/v1/files/{id}/finalize | — (policy) | Flip to Available, trigger scan |
| GET | /api/v1/files/{id} | — (policy) | File metadata (not content) |
| GET | /api/v1/files/{id}/url | — (policy) | Mint presigned GET URL |
| PATCH | /api/v1/files/{id}/visibility | Files.Upload | Flip Public ↔ Private |
| DELETE | /api/v1/files/{id} | Files.DeleteOwn | Soft delete |
| POST | /api/v1/files/{id}/restore | Files.Restore | Restore from trash |
| GET | /api/v1/files/mine | Files.Upload | Paginated caller-scoped list |
| GET | /api/v1/files/shared | Files.Upload | Public tenant-wide files |
| GET | /api/v1/files/trash | Files.ViewTrash | Paginated trash |
Endpoints marked ”— (policy)” require authentication only; the per-OwnerType IFileAccessPolicy makes the call.
Configuration
{ "Files": { "UploadUrlTtlMinutes": 15, "DownloadUrlTtlMinutes": 60, "OrphanRetentionMinutes": 60, "SoftDeleteRetentionDays": 30, "Categories": { "Image": { "AllowedExtensions": [".jpg",".jpeg",".png",".webp",".gif",".ico"], "MaxBytes": 10485760 }, "Document": { "AllowedExtensions": [".pdf",".docx",".xlsx",".pptx",".txt",".csv"], "MaxBytes": 26214400 }, "Archive": { "AllowedExtensions": [".zip"], "MaxBytes": 52428800 } } }}The section binds to FilesOptions (Modules.Files/FilesOptions.cs); category names are matched case-insensitively.
The S3 / MinIO target is configured in the Storage building block — the Files module is the policy + lifecycle layer on top.
How to extend
Add a policy for a new owner type
public sealed class ContractDocumentAccessPolicy(ContractsDbContext db) : IFileAccessPolicy{ public string OwnerType => "Contract";
public async Task<bool> CanAttachAsync(Guid? ownerId, string currentUserId, CancellationToken cancellationToken) { if (ownerId is null) return false; var contract = await db.Contracts.FindAsync([ownerId.Value], cancellationToken).ConfigureAwait(false); return contract is not null && string.Equals(contract.OwnerUserId, currentUserId, StringComparison.Ordinal); }
public Task<bool> CanReadAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken) { /* ... */ } public Task<bool> CanDeleteAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken) { /* ... */ }}
// register in your module's ConfigureServicesservices.AddScoped<IFileAccessPolicy, ContractDocumentAccessPolicy>();Plug a virus scanner
Implement IFileScanner.ScanAsync(storageKey, ct) (return ScanStatus.Clean or Infected) and replace the NoOpFileScanner registration in DI. FinalizeUploadCommandHandler calls the scanner during finalize; an Infected result transitions the file to Quarantined instead of Available.
Listen for file finalization in another module
public sealed class FileFinalizedNotifyHandler(/* ... */) : IIntegrationEventHandler<FileFinalizedIntegrationEvent>{ public async Task HandleAsync(FileFinalizedIntegrationEvent evt, CancellationToken ct = default) { if (evt.OwnerType != "Product") return; // react to the upload completing — index it, notify someone, etc. }}No module ships a consumer today — Catalog and Chat attach files by passing the fileAssetId + public URL through their own commands (AddProductImageCommand, SendMessageCommand attachments). The event is the seam for anything that should react to an upload completing.
Tests
- Domain + service tests at
src/Tests/Files.Tests/:FileAssetTests— state transitionsDefaultUploaderOnlyPolicyTests— policy enforcementFileAccessPolicyRegistryTests— registry lookupStorageKeyBuilderTests— tenant + category path generation
- Integration tests at
src/Tests/Integration.Tests/Tests/Files/(ten files) cover the presigned round-trip (RequestAndFinalizeUploadTests,StorageFlowTests), upload validation, finalize edge cases, visibility + sharing, soft delete + restore, the purge jobs, and tenant isolation against Testcontainers MinIO.
Related
- Storage building block — the S3 / MinIO abstraction this module sits on top of.
- Catalog module — product images use the policy hook.
- Chat module — channel attachments use the policy hook.