Skip to content
fullstackhero

Reference

Files module

Presigned-URL file lifecycle with pluggable per-OwnerType access policies, soft delete + retention purge, optional scanner hook, and S3 / MinIO storage.

views 0 Last updated

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 upload, read, and delete. Storage is MinIO / S3 via presigned URLs — the API never proxies bytes.

What ships in v10

  • Presigned upload flowPOST /files/upload-url mints a presigned PUT URL with a 15-minute TTL (configurable). Client uploads directly to MinIO / S3. POST /files/{id}/finalize flips the file from PendingUpload to Available.
  • Pluggable access policiesIFileAccessPolicy interface registered per OwnerType. Catalog ships ProductFileAccessPolicy, Chat ships ChatChannelFileAccessPolicy, the Files module itself ships defaults for MyFiles and User.
  • Per-category validation — extensions whitelist + size cap per category (Image 10 MB, Document 25 MB, Archive 50 MB), configured in appsettings.
  • File scanner hookIFileScanner interface with NoOpFileScanner as the default. Implement it to plug in ClamAV / S3 antivirus / VirusTotal; quarantined files transition to Quarantined instead of Available.
  • Soft delete + retention — deleted files go to trash; PurgeDeletedFilesJob (Hangfire daily 03:30 UTC) hard-deletes after 30 days.
  • Orphan cleanupPurgeOrphanedFilesJob (hourly) deletes PendingUpload rows whose upload deadline passed without a finalize call.
  • FileFinalizedIntegrationEvent — published when a file becomes Available; the Catalog product-image flow and Chat attachment flow consume this.

Architecture at a glance

src/Modules/Files/
├── Modules.Files/ ~500 LoC
│ ├── FilesModule.cs IModule entry — order 350
│ ├── Domain/FileAsset.cs AggregateRoot, state machine
│ ├── Data/FilesDbContext.cs Schema: files
│ ├── Services/
│ │ ├── FileAccessPolicyRegistry.cs OwnerType → IFileAccessPolicy lookup
│ │ ├── DefaultUploaderOnlyPolicy.cs For MyFiles + User
│ │ └── NoOpFileScanner.cs Default IFileScanner
│ ├── Background/
│ │ ├── PurgeOrphanedFilesJob.cs Hangfire hourly
│ │ └── PurgeDeletedFilesJob.cs Hangfire 03:30 daily
│ └── Features/v1/ 8 features
└── Modules.Files.Contracts/ ~50 LoC, plus IFileAccessPolicy

The lifecycle

FileAsset is the aggregate. Three states, transitions one-way:

finalize
PendingUpload ────────────────────► Available
│ scan = Quarantined
└──────────────────────────► Quarantined

Upload-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.

src/Modules/Files/Modules.Files/Domain/FileAsset.cs
public sealed class FileAsset : AggregateRoot, ISoftDeletable
{
public FileAssetStatus Status { get; private set; } // PendingUpload | Available | Quarantined
public DateTime UploadDeadline { get; private set; }
public static FileAsset CreatePending(
Guid tenantId,
string ownerType,
Guid? ownerId,
string fileName,
string contentType,
long sizeBytes,
FileVisibility visibility,
TimeSpan uploadTtl) { /* ... */ }
public void MarkAvailable(long actualSize)
{
EnsureStatus(FileAssetStatus.PendingUpload);
Status = FileAssetStatus.Available;
ActualSize = actualSize;
RaiseDomainEvent(new FileFinalizedDomainEvent(Id, OwnerType, OwnerId));
}
public void MarkQuarantined(string reason) { /* ... */ }
}

Access policies

IFileAccessPolicy is the seam every consuming module implements:

src/Modules/Files/Modules.Files.Contracts/IFileAccessPolicy.cs
public interface IFileAccessPolicy
{
string OwnerType { get; }
ValueTask<bool> CanUploadAsync(Guid? ownerId, ICurrentUser user, CancellationToken ct);
ValueTask<bool> CanReadAsync(FileAsset file, ICurrentUser user, CancellationToken ct);
ValueTask<bool> CanDeleteAsync(FileAsset file, ICurrentUser user, CancellationToken ct);
}

Catalog’s ProductFileAccessPolicy checks that the caller has Catalog.Products.Update and that the product exists. Chat’s ChatChannelFileAccessPolicy checks that the caller is a member of the channel.

FileAccessPolicyRegistry looks up the right policy by OwnerType. Missing policy → request rejected. This is a deliberate fail-closed default.

Public API

TypePurpose
RequestUploadUrlCommand(ownerType, ownerId?, fileName, contentType, sizeBytes, visibility, category)Mints a presigned PUT URL and a pending FileAsset
FinalizeUploadCommand(fileId, actualSize)Flips to Available; raises domain event
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
ListTrashedFilesQuery(paging)Paginated trash

The PresignedUploadResponse returned by RequestUploadUrl includes the URL, the headers the client must send with the PUT, and the file id. The download response is just URL + content-disposition.

Endpoints

VerbRouteWhat it does
POST/api/v1/files/upload-urlMint presigned PUT URL (idempotent)
POST/api/v1/files/{id}/finalizeFlip to Available, trigger scan
GET/api/v1/files/{id}File metadata (not content)
GET/api/v1/files/{id}/urlMint presigned GET URL
DELETE/api/v1/files/{id}Soft delete
POST/api/v1/files/{id}/restoreRestore from trash
GET/api/v1/files/minePaginated caller-scoped list
GET/api/v1/files/trashPaginated trash

Configuration

appsettings.json
{
"Files": {
"UploadUrlTtlMinutes": 15,
"DownloadUrlTtlMinutes": 60,
"OrphanRetentionMinutes": 60,
"SoftDeleteRetentionDays": 30,
"Categories": {
"Image": { "Extensions": ["png","jpg","jpeg","webp","gif","svg"], "MaxBytes": 10485760 },
"Document": { "Extensions": ["pdf","docx","xlsx","txt","md"], "MaxBytes": 26214400 },
"Archive": { "Extensions": ["zip","tar.gz","tgz"], "MaxBytes": 52428800 }
}
}
}

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(IContractDbContext db) : IFileAccessPolicy
{
public string OwnerType => "Contract";
public async ValueTask<bool> CanUploadAsync(Guid? ownerId, ICurrentUser user, CancellationToken ct)
{
if (ownerId is null) return false;
var contract = await db.Contracts.FindAsync([ownerId], ct).ConfigureAwait(false);
return contract is not null && contract.OwnerUserId == user.GetUserId();
}
public ValueTask<bool> CanReadAsync(FileAsset file, ICurrentUser user, CancellationToken ct) { /* ... */ }
public ValueTask<bool> CanDeleteAsync(FileAsset file, ICurrentUser user, CancellationToken ct) { /* ... */ }
}
// register in your module's ConfigureServices
services.AddScoped<IFileAccessPolicy, ContractDocumentAccessPolicy>();

Plug a virus scanner

Implement IFileScanner.ScanAsync(stream, ct) and register your implementation in DI to replace NoOpFileScanner. FinalizeUploadCommandHandler calls the scanner during finalize; quarantined files transition to Quarantined instead of Available.

Listen for file finalization in another module

public sealed class FileFinalizedNotifyHandler(/* ... */)
: IIntegrationEventHandler<FileFinalizedIntegrationEvent>
{
public async ValueTask HandleAsync(FileFinalizedIntegrationEvent evt, CancellationToken ct)
{
if (evt.OwnerType != "Product") return;
// attach the file to the product domain
}
}

The Catalog module uses this exact pattern to associate product images.

Tests

  • Domain + service tests at src/Tests/Files.Tests/:
    • FileAssetTests — state transitions
    • DefaultUploaderOnlyPolicyTests — policy enforcement
    • FileAccessPolicyRegistryTests — registry lookup
    • StorageKeyBuilderTests — tenant + category path generation
  • Integration tests at src/Tests/Integration.Tests/Tests/Files/ cover the full presigned URL round-trip against Testcontainers MinIO.