Files Module Integration
The PearDrop Files module provides file storage abstraction, access control, and queryable read models for managing uploaded files and documents across your application.
Overview
The Files module manages:
- File uploads: Store files with metadata (name, size, upload date, kind)
- File access: Permission-based access control (who can read/delete files)
- File retrieval: Query files by owner, kind, or custom criteria
- Storage abstraction: Works with Azure Blob Storage, local filesystem, or custom providers
Key patterns:
- Read models expose
IFilesReadModelsinterface (no DbContext leakage) - File access protected by authorization requirements
- Keyed DI for multiple file access types within same application
- Radzen UI components for upload/download workflows
Server-Side Registration
IFilesReadModels Contract
The Files module exposes a read-only interface for querying files:
public interface IFilesReadModels : IModuleReadModels
{
/// <summary>
/// Get queryable access to all files.
/// Authority filtering applied automatically.
/// </summary>
IReadModelQueryable<FileProjection> Files { get; }
/// <summary>
/// Get a single file by ID.
/// Returns null if file not found or user lacks access.
/// </summary>
Task<FileProjection?> GetFileByIdAsync(
Guid fileId,
CancellationToken cancellationToken);
/// <summary>
/// Get all files uploaded by a specific user.
/// </summary>
Task<IEnumerable<FileProjection>> GetFilesByOwnerAsync(
Guid ownerId,
CancellationToken cancellationToken);
/// <summary>
/// Get files filtered by type/kind.
/// </summary>
Task<IEnumerable<FileProjection>> GetFilesByKindAsync(
string kind,
CancellationToken cancellationToken);
}
Injecting Files Read Models:
public sealed class GetUserFilesQueryHandler(
IFilesReadModels filesReadModels,
IHttpContextAccessor httpContextAccessor)
{
public async Task<QueryResult<IEnumerable<FileDto>>> Handle(
GetUserFilesQuery request,
CancellationToken ct)
{
var userId = httpContextAccessor.HttpContext?
.User.FindFirst(ClaimTypes.NameIdentifier)?
.Value;
if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var parsedUserId))
{
return QueryResult<IEnumerable<FileDto>>.Failed("User not authenticated");
}
// Get all files uploaded by current user
var files = await filesReadModels.GetFilesByOwnerAsync(parsedUserId, ct);
var fileDtos = files.Select(f => new FileDto(
f.Id,
f.Name,
f.Size,
f.Kind,
f.UploadedAt
)).ToList();
return QueryResult<IEnumerable<FileDto>>.Succeeded(fileDtos);
}
}
File Upload Authorization
UploadFileCommandAuthorizer
Protect file upload operations:
public sealed class UploadFileCommandAuthorizer :
AbstractIgnorableRequestAuthorizer<UploadFileCommand>
{
public override IEnumerable<IAuthorizationRequirement> GetRequirements(
UploadFileCommand request)
{
return new[]
{
new MustBeAuthenticatedRequirement(),
new MustBeActiveUserRequirement()
};
}
}
Command definition:
public sealed record UploadFileCommand(
string FileName,
long FileSize,
Stream FileStream,
string? Kind = null) : ICommand;
File Size Validation
public sealed class UploadFileCommandValidator :
AbstractValidator<UploadFileCommand>
{
private readonly IFileConstants fileConstants;
public UploadFileCommandValidator(IFileConstants fileConstants)
{
this.fileConstants = fileConstants;
RuleFor(x => x.FileName)
.NotEmpty().MaximumLength(255)
.Matches(@"^[a-zA-Z0-9\-_.()]+$",
RegexOptions.Compiled,
"File name contains invalid characters");
RuleFor(x => x.FileSize)
.LessThanOrEqualTo(fileConstants.MaxUploadSizeInBytes)
.WithMessage($"File exceeds maximum size of {fileConstants.MaxFileNameInMB}MB");
RuleFor(x => x.Kind)
.Must(k => k == null || fileConstants.AllowedKinds.Contains(k))
.WithMessage("File kind is not allowed");
}
}
File Access Control
DeleteFileCommandAuthorizer
Ensure only file owner (or admin) can delete:
public sealed class DeleteFileCommandAuthorizer :
AbstractIgnorableRequestAuthorizer<DeleteFileCommand>
{
private readonly IFilesReadModels filesReadModels;
public DeleteFileCommandAuthorizer(IFilesReadModels filesReadModels)
{
this.filesReadModels = filesReadModels;
}
public override async Task<AuthorizationResult> AuthorizeAsync(
DeleteFileCommand request,
ClaimsPrincipal? user,
IAuthorizationService authService,
CancellationToken cancellationToken)
{
var userId = user.GetUserId();
// Find the file
var file = await this.filesReadModels.GetFileByIdAsync(
request.FileId,
cancellationToken);
if (file == null)
{
// Return Failed (not Success) — don't leak that file doesn't exist
return AuthorizationResult.Failed();
}
// Only owner can delete (or admin in the handler)
return file.OwnerId == userId
? AuthorizationResult.Success()
: AuthorizationResult.Failed();
}
}
File Storage Abstraction
IFileAccessor Pattern
Files module abstracts storage providers:
public interface IFileAccessor
{
/// <summary>
/// Upload a file stream to storage.
/// Returns file storage path/reference.
/// </summary>
Task<string> UploadAsync(
Stream fileStream,
string fileName,
string? contentType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Download a file stream from storage.
/// </summary>
Task<Stream> DownloadAsync(
string fileReference,
CancellationToken cancellationToken = default);
/// <summary>
/// Delete a file from storage.
/// </summary>
Task DeleteAsync(
string fileReference,
CancellationToken cancellationToken = default);
/// <summary>
/// Check if a file exists in storage.
/// </summary>
Task<bool> ExistsAsync(
string fileReference,
CancellationToken cancellationToken = default);
}
Keyed DI for Multiple File Types
For applications managing different file categories:
Registration:
services.AddKeyedScoped<IFileAccessor, AzureBlobFileAccessor>(
"documents");
services.AddKeyedScoped<IFileAccessor, LocalFileSystemAccessor>(
"temp");
Usage in handler:
public sealed class UploadDocumentCommandHandler(
[FromKeyedServices("documents")] IFileAccessor documentAccessor)
{
public async Task<CommandResult<UploadDocumentCommandResult>> Handle(
UploadDocumentCommand request,
CancellationToken ct)
{
var fileReference = await documentAccessor.UploadAsync(
request.FileStream,
request.FileName,
"application/pdf",
ct);
// ... store FileProjection with reference
return CommandResult<UploadDocumentCommandResult>.Succeeded(...);
}
}
Querying Files with Read Models
Get All Files by Kind
public sealed class GetReportFilesQueryHandler(
IFilesReadModels filesReadModels)
{
public async Task<QueryResult<IEnumerable<ReportFileDto>>> Handle(
GetReportFilesQuery request,
CancellationToken ct)
{
var files = await filesReadModels.GetFilesByKindAsync(
"report",
ct);
var dtos = files.Select(f => new ReportFileDto(
f.Id,
f.Name,
f.Size,
f.UploadedAt
)).ToList();
return QueryResult<IEnumerable<ReportFileDto>>.Succeeded(dtos);
}
}
Filter with LINQ
public sealed class GetRecentFilesQueryHandler(
IFilesReadModels filesReadModels)
{
public async Task<QueryResult<IEnumerable<FileDto>>> Handle(
GetRecentFilesQuery request,
CancellationToken ct)
{
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
var files = await filesReadModels.Files
.Where(f => f.UploadedAt >= thirtyDaysAgo)
.OrderByDescending(f => f.UploadedAt)
.Take(50)
.Select(f => new FileDto(f.Id, f.Name, f.Size))
.ToListAsync(ct);
return QueryResult<IEnumerable<FileDto>>.Succeeded(files);
}
}
Blazor File Upload Components
FileUploadComponent Pattern
@* Components/FileUpload.razor *@
@implements IAsyncDisposable
@inject ICommandRunner CommandRunner
@inject IToastService ToastService
<div class="file-upload-container">
<InputFile OnChange="HandleFileSelected" />
@if (IsUploading)
{
<div class="progress">
<div class="progress-bar" style="width: @UploadProgress%">
@UploadProgress%
</div>
</div>
}
</div>
@code {
[Parameter]
public string? Kind { get; set; }
[Parameter]
public EventCallback<UploadSuccessDto> OnUploadSuccess { get; set; }
private bool IsUploading { get; set; }
private int UploadProgress { get; set; }
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
var file = e.File;
if (file.Size > 10 * 1024 * 1024) // 10MB
{
await this.ToastService.Error("File exceeds 10MB limit");
return;
}
this.IsUploading = true;
try
{
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var command = new UploadFileCommand(file.Name, file.Size, stream, this.Kind);
var result = await this.CommandRunner.ExecuteAsync(command);
if (result.IsSuccess)
{
await this.OnUploadSuccess.InvokeAsync(
new UploadSuccessDto(result.Data.FileId, file.Name));
await this.ToastService.Success("File uploaded successfully");
}
else
{
await this.ToastService.Error("Upload failed: " + result.ErrorMessage);
}
}
finally
{
this.IsUploading = false;
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
// Cleanup
}
}
File List Component
@* Components/FileList.razor *@
@inject IQueryRunner QueryRunner
<div class="file-list">
@foreach (var file in Files)
{
<div class="file-item">
<span>@file.Name</span>
<small>@FormatBytes(file.Size) • @file.UploadedAt.ToShortDateString()</small>
<button @onclick="() => DownloadFile(file.Id)" class="btn btn-sm btn-primary">
Download
</button>
<button @onclick="() => DeleteFile(file.Id)" class="btn btn-sm btn-danger">
Delete
</button>
</div>
}
</div>
@code {
private List<FileDto> Files { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var result = await this.QueryRunner.ExecuteAsync(new GetMyFilesQuery());
if (result.IsSuccess)
{
this.Files = result.Data.ToList();
}
}
private async Task DownloadFile(Guid fileId)
{
// Implement download logic
}
private async Task DeleteFile(Guid fileId)
{
var result = await this.CommandRunner.ExecuteAsync(
new DeleteFileCommand(fileId));
if (result.IsSuccess)
{
this.Files.RemoveAll(f => f.Id == fileId);
await this.InvokeAsync(this.StateHasChanged);
}
}
private string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}
Integration Checklist
When using Files module in your application:
- Register Files module:
builder.Services.AddPearDropFiles(config) - Register Files read models:
builder.Services.AddFilesReadModels() - Configure file storage provider (Azure, local, custom)
- Create file upload command handler
- Create
UploadFileCommandAuthorizer(auth + active user) - Create
DeleteFileCommandAuthorizer(owner check + auth) - Implement file queries using
IFilesReadModels - Create Blazor file upload/download components
- Set file upload size limits in appsettings.json
- Set allowed file kinds/extensions in module config
- Add file cleanup/retention policies if needed
Common Patterns
Profile Picture Upload with Cleanup
public sealed class UpdateProfilePictureCommandHandler(
[FromKeyedServices("profilePics")] IFileAccessor fileAccessor,
IRepositoryFactory<User> repositoryFactory,
IFilesReadModels filesReadModels)
{
protected override async Task<CommandResult<UpdateProfilePictureCommandResult>>
HandleInternalWithRepository(
UpdateProfilePictureCommand request,
CancellationToken ct)
{
var user = await this.Repository.FindOne(
new ByIdSpecification<User>(request.UserId), ct);
if (user.HasNoValue)
{
return CommandResult<UpdateProfilePictureCommandResult>.Failed(
ErrorCodes.NotFound, "User not found");
}
// Delete old picture if exists
if (user.Value.ProfilePictureReference != null)
{
await fileAccessor.DeleteAsync(user.Value.ProfilePictureReference, ct);
}
// Upload new picture
var newImageReference = await fileAccessor.UploadAsync(
request.ImageStream,
$"{request.UserId}_profile.jpg",
"image/jpeg",
ct);
// Update user
user.Value.SetProfilePicture(newImageReference);
this.Repository.Update(user.Value);
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(ct);
return result.IsSuccess
? CommandResult<UpdateProfilePictureCommandResult>.Succeeded(
new UpdateProfilePictureCommandResult(newImageReference))
: CommandResult<UpdateProfilePictureCommandResult>.Failed(result.Error!);
}
}
Related Topics
- Security & Authorization Patterns — File access authorization
- Framework Core Integration — Storage provider registration
- Multi-Tenancy — File isolation across tenants