RepletoryLib.FileStorage.Local
1.0.0
dotnet add package RepletoryLib.FileStorage.Local --version 1.0.0
NuGet\Install-Package RepletoryLib.FileStorage.Local -Version 1.0.0
<PackageReference Include="RepletoryLib.FileStorage.Local" Version="1.0.0" />
<PackageVersion Include="RepletoryLib.FileStorage.Local" Version="1.0.0" />
<PackageReference Include="RepletoryLib.FileStorage.Local" />
paket add RepletoryLib.FileStorage.Local --version 1.0.0
#r "nuget: RepletoryLib.FileStorage.Local, 1.0.0"
#:package RepletoryLib.FileStorage.Local@1.0.0
#addin nuget:?package=RepletoryLib.FileStorage.Local&version=1.0.0
#tool nuget:?package=RepletoryLib.FileStorage.Local&version=1.0.0
RepletoryLib.FileStorage.Local
Local filesystem file storage implementation with JSON sidecar metadata, multi-tenant path strategies, and full CRUD operations.
Part of the RepletoryLib ecosystem -- standalone, reusable .NET 10 libraries with zero business logic.
Overview
RepletoryLib.FileStorage.Local provides a local filesystem implementation of IFileStorageService from the FileStorage.Abstractions package. It stores files under a configurable base directory with automatic directory creation, JSON sidecar files for custom metadata (content type, visibility, custom key-value pairs), and support for upload, download, delete, copy, move, metadata retrieval, and paginated listing.
This provider is ideal for local development, testing, and scenarios where cloud storage is not required. It implements the same IFileStorageService interface as the AWS S3 provider, so you can swap between providers without changing application code.
Key Features
LocalFileStorageService-- FullIFileStorageServiceimplementation backed by the local filesystem- Upload -- Single and bulk uploads with automatic directory creation and async file I/O
- Download -- Stream-based and byte array downloads with async buffered reads
- Delete -- Single and bulk deletes with automatic sidecar cleanup
- Copy & Move -- File copy and move with sidecar metadata propagation
- JSON sidecar metadata -- Stores original file name, content type, visibility, and custom metadata alongside each file as a
.metafile LocalFilePathStrategy-- Token-based path generation with{tenant},{year},{month},{day},{filename}- Paginated listing -- File enumeration with prefix filtering, integer-offset pagination, and continuation tokens
- Content type inference -- 30+ MIME type mappings from file extensions
- Configurable base path and URL prefix -- Serve files via static file middleware or custom endpoints
Installation
dotnet add package RepletoryLib.FileStorage.Local
Or add to your .csproj:
<PackageReference Include="RepletoryLib.FileStorage.Local" Version="1.0.0" />
Note: RepletoryLib packages are published to a local BaGet feed. See the main repository README for feed configuration.
Dependencies
| Package | Type |
|---|---|
RepletoryLib.FileStorage.Abstractions |
RepletoryLib |
Microsoft.Extensions.Logging.Abstractions |
NuGet (10.0.0) |
Microsoft.Extensions.Options.ConfigurationExtensions |
NuGet (10.0.0) |
Microsoft.Extensions.DependencyInjection.Abstractions |
NuGet (10.0.0) |
Prerequisites
- Write access to the configured base directory on the local filesystem
- The base directory is created automatically if it does not exist during upload
Quick Start
using RepletoryLib.FileStorage.Local;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRepletoryLocalFileStorage(builder.Configuration);
{
"FileStorage": {
"MaxFileSizeBytes": 104857600,
"AllowedExtensions": [],
"DefaultVisibility": "Private",
"PathTemplate": "{tenant}/{year}/{month}/{filename}",
"PreserveOriginalFileName": true,
"Local": {
"BasePath": "./uploads",
"BaseUrl": "/files"
}
}
}
Configuration
LocalStorageOptions
| Property | Type | Default | Description |
|---|---|---|---|
BasePath |
string |
"./uploads" |
Root directory for file storage on the local filesystem |
BaseUrl |
string |
"/files" |
URL prefix used when constructing file URLs |
Section name: "FileStorage:Local"
FileStorageOptions (shared)
| Property | Type | Default | Description |
|---|---|---|---|
MaxFileSizeBytes |
long |
104857600 (100 MB) |
Maximum allowed file size in bytes |
AllowedExtensions |
List<string> |
[] (all allowed) |
Allowed file extensions |
DefaultVisibility |
FileVisibility |
Private |
Default access visibility for uploads |
PathTemplate |
string |
"{tenant}/{year}/{month}/{filename}" |
Token-based path template for file organization |
PreserveOriginalFileName |
bool |
true |
When false, generates GUID-based file names |
Section name: "FileStorage"
Serving Files with Static File Middleware
var app = builder.Build();
// Serve uploaded files at the configured BaseUrl
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.GetFullPath("./uploads")),
RequestPath = "/files"
});
Usage Examples
Upload a File
using RepletoryLib.FileStorage.Abstractions.Enums;
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
public class DocumentService
{
private readonly IFileStorageService _storage;
public DocumentService(IFileStorageService storage) => _storage = storage;
public async Task<FileUploadResult> UploadAsync(Stream content, string fileName)
{
var request = new FileUploadRequest
{
Content = content,
FileName = fileName,
Folder = "documents",
Visibility = FileVisibility.Private,
Metadata = new Dictionary<string, string>
{
["source"] = "web-upload",
["category"] = "invoices"
}
};
var result = await _storage.UploadAsync(request);
if (!result.Success)
{
throw new InvalidOperationException($"Upload failed: {result.Error}");
}
// result.FileKey = "documents/default/2026/03/invoice.pdf"
// result.Url = "/files/documents/default/2026/03/invoice.pdf"
// Physical path = ./uploads/documents/default/2026/03/invoice.pdf
// Sidecar file = ./uploads/documents/default/2026/03/invoice.pdf.meta
return result;
}
}
Bulk Upload
public async Task<BulkUploadResult> UploadBatchAsync(List<IFormFile> files)
{
var requests = files.Select(f => new FileUploadRequest
{
Content = f.OpenReadStream(),
FileName = f.FileName,
ContentType = f.ContentType,
Folder = "batch-imports"
});
var result = await _storage.UploadBulkAsync(requests);
_logger.LogInformation(
"Batch upload: {Success} succeeded, {Failed} failed",
result.SuccessCount, result.FailureCount);
return result;
}
Download Files
// Download as stream (caller must dispose)
public async Task<Stream> DownloadAsync(string fileKey)
{
// Throws FileNotFoundException if file does not exist
return await _storage.DownloadAsync(fileKey);
}
// Download as byte array
public async Task<byte[]> DownloadBytesAsync(string fileKey)
{
return await _storage.DownloadBytesAsync(fileKey);
}
Copy, Move, and Delete
// Copy a file (also copies the .meta sidecar)
await _storage.CopyAsync(
"documents/default/2026/03/report.pdf",
"backups/default/2026/03/report.pdf");
// Move a file (also moves the .meta sidecar)
await _storage.MoveAsync(
"temp/draft.docx",
"documents/default/2026/03/final.docx");
// Delete a single file (also deletes the .meta sidecar)
await _storage.DeleteAsync("temp/draft.docx");
// Bulk delete
await _storage.DeleteBulkAsync(new[]
{
"temp/file1.pdf",
"temp/file2.pdf",
"temp/file3.pdf"
});
Get Metadata
var metadata = await _storage.GetMetadataAsync("documents/default/2026/03/report.pdf");
Console.WriteLine($"File: {metadata.FileName}"); // "report.pdf" (from sidecar)
Console.WriteLine($"Size: {metadata.SizeBytes} bytes"); // 1048576
Console.WriteLine($"Type: {metadata.ContentType}"); // "application/pdf" (from sidecar)
Console.WriteLine($"Created: {metadata.CreatedAt}"); // filesystem creation time (UTC)
Console.WriteLine($"Modified: {metadata.LastModifiedAt}"); // filesystem last write time (UTC)
Console.WriteLine($"Visibility: {metadata.Visibility}"); // Private (from sidecar)
Console.WriteLine($"Custom: {metadata.Metadata["category"]}"); // "invoices"
List Files with Pagination
var request = new FileListRequest
{
Prefix = "documents/default/2026/",
MaxResults = 50
};
var result = await _storage.ListAsync(request);
foreach (var file in result.Files)
{
Console.WriteLine($"{file.FileKey} - {file.SizeBytes} bytes");
}
// Continue paginating (uses integer-offset continuation tokens)
while (result.IsTruncated)
{
request.ContinuationToken = result.ContinuationToken;
result = await _storage.ListAsync(request);
foreach (var file in result.Files)
{
Console.WriteLine($"{file.FileKey} - {file.SizeBytes} bytes");
}
}
Get Public URL
var url = await _storage.GetPublicUrlAsync("avatars/default/2026/03/photo.jpg");
// "/files/avatars/default/2026/03/photo.jpg"
Check Existence
bool exists = await _storage.ExistsAsync("documents/default/2026/03/report.pdf");
if (!exists)
{
throw new FileNotFoundException("Document not found");
}
Presigned URLs (Not Supported)
// Local storage does NOT support presigned URLs.
// Calling GetPresignedUrlAsync throws NotSupportedException.
try
{
await _storage.GetPresignedUrlAsync(new PresignedUrlRequest
{
FileKey = "documents/report.pdf"
});
}
catch (NotSupportedException ex)
{
// "Local file storage does not support presigned URLs."
// Use GetPublicUrlAsync or serve files via static file middleware instead.
}
Swapping Providers (Development vs. Production)
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
{
// Local filesystem for development
builder.Services.AddRepletoryLocalFileStorage(builder.Configuration);
}
else
{
// AWS S3 for production
builder.Services.AddRepletoryAwsFileStorage(builder.Configuration);
}
// Application code uses IFileStorageService -- no changes needed
API Reference
ServiceCollectionExtensions
| Method | Returns | Description |
|---|---|---|
AddRepletoryLocalFileStorage(configuration) |
IServiceCollection |
Registers IFilePathStrategy (singleton) and IFileStorageService (scoped) for local filesystem storage |
LocalFileStorageService (implements IFileStorageService)
| Method | Returns | Description |
|---|---|---|
UploadAsync(request, ct) |
Task<FileUploadResult> |
Upload a file to local storage with sidecar metadata |
UploadBulkAsync(requests, ct) |
Task<BulkUploadResult> |
Upload multiple files with partial-success semantics |
DownloadAsync(fileKey, ct) |
Task<Stream> |
Download a file as a buffered read stream |
DownloadBytesAsync(fileKey, ct) |
Task<byte[]> |
Download a file as a byte array |
DeleteAsync(fileKey, ct) |
Task |
Delete a file and its .meta sidecar |
DeleteBulkAsync(fileKeys, ct) |
Task |
Delete multiple files and their sidecars |
ExistsAsync(fileKey, ct) |
Task<bool> |
Check if a file exists on disk |
CopyAsync(sourceKey, destinationKey, ct) |
Task |
Copy a file and its sidecar to a new location |
MoveAsync(sourceKey, destinationKey, ct) |
Task |
Move a file and its sidecar to a new location |
GetMetadataAsync(fileKey, ct) |
Task<FileMetadata> |
Get file metadata from filesystem info and sidecar |
ListAsync(request, ct) |
Task<FileListResult> |
List files with prefix filtering and offset-based pagination |
GetPresignedUrlAsync(request, ct) |
-- | Not supported -- throws NotSupportedException |
GetPublicUrlAsync(fileKey, ct) |
Task<string> |
Build a URL from BaseUrl + file key |
LocalFilePathStrategy (implements IFilePathStrategy)
| Method | Returns | Description |
|---|---|---|
GeneratePath(context) |
string |
Generate a local storage path by replacing tokens in the path template |
Registered Services
| Interface | Implementation | Lifetime |
|---|---|---|
IFilePathStrategy |
LocalFilePathStrategy |
Singleton |
IFileStorageService |
LocalFileStorageService |
Scoped |
Sidecar Metadata (.meta files)
Each uploaded file is accompanied by a JSON sidecar file with the .meta extension:
{
"FileName": "report.pdf",
"ContentType": "application/pdf",
"Visibility": 1,
"CustomMetadata": {
"source": "web-upload",
"category": "invoices"
}
}
| Field | Type | Description |
|---|---|---|
FileName |
string |
Original file name |
ContentType |
string |
MIME content type |
Visibility |
int |
0 = Public, 1 = Private |
CustomMetadata |
object |
Custom key-value pairs from FileUploadRequest.Metadata |
Integration with Other RepletoryLib Packages
| Package | Relationship |
|---|---|
RepletoryLib.FileStorage.Abstractions |
Direct dependency -- IFileStorageService, IFilePathStrategy, all models and options |
RepletoryLib.FileStorage.Aws |
Alternative provider -- swap to S3 for production deployments |
RepletoryLib.Common |
Transitive dependency via Abstractions |
RepletoryLib.Api |
Use IFileStorageService in API controllers for file upload/download endpoints |
Testing
using RepletoryLib.FileStorage.Abstractions.Interfaces;
using RepletoryLib.FileStorage.Abstractions.Models;
using RepletoryLib.FileStorage.Local;
using RepletoryLib.FileStorage.Local.Options;
using RepletoryLib.FileStorage.Abstractions.Options;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging.Abstractions;
public class LocalFileStorageServiceTests : IDisposable
{
private readonly string _testDir;
private readonly IFileStorageService _storage;
public LocalFileStorageServiceTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"storage-test-{Guid.NewGuid()}");
Directory.CreateDirectory(_testDir);
var localOptions = Options.Create(new LocalStorageOptions
{
BasePath = _testDir,
BaseUrl = "/test-files"
});
var storageOptions = Options.Create(new FileStorageOptions
{
PathTemplate = "{filename}",
PreserveOriginalFileName = true
});
var pathStrategy = new LocalFilePathStrategy(storageOptions);
_storage = new LocalFileStorageService(
pathStrategy, localOptions, storageOptions,
NullLogger<LocalFileStorageService>.Instance);
}
[Fact]
public async Task UploadAsync_creates_file_on_disk()
{
// Arrange
var content = "Hello, local storage!"u8.ToArray();
using var stream = new MemoryStream(content);
// Act
var result = await _storage.UploadAsync(new FileUploadRequest
{
Content = stream,
FileName = "test.txt"
});
// Assert
result.Success.Should().BeTrue();
result.FileKey.Should().Be("test.txt");
result.Url.Should().Be("/test-files/test.txt");
File.Exists(Path.Combine(_testDir, "test.txt")).Should().BeTrue();
}
[Fact]
public async Task DownloadBytesAsync_returns_file_content()
{
// Arrange
var content = "file content"u8.ToArray();
using var stream = new MemoryStream(content);
await _storage.UploadAsync(new FileUploadRequest
{
Content = stream,
FileName = "download-test.txt"
});
// Act
var bytes = await _storage.DownloadBytesAsync("download-test.txt");
// Assert
bytes.Should().BeEquivalentTo(content);
}
[Fact]
public async Task DeleteAsync_removes_file_and_sidecar()
{
// Arrange
using var stream = new MemoryStream("data"u8.ToArray());
await _storage.UploadAsync(new FileUploadRequest
{
Content = stream,
FileName = "delete-test.txt"
});
// Act
await _storage.DeleteAsync("delete-test.txt");
// Assert
var exists = await _storage.ExistsAsync("delete-test.txt");
exists.Should().BeFalse();
File.Exists(Path.Combine(_testDir, "delete-test.txt.meta")).Should().BeFalse();
}
public void Dispose()
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
}
Troubleshooting
| Issue | Solution |
|---|---|
UnauthorizedAccessException on upload |
Ensure the application process has write permissions to the configured BasePath directory |
FileNotFoundException on download |
Verify the fileKey matches the key returned by UploadAsync; check that the file has not been deleted |
NotSupportedException on GetPresignedUrlAsync |
Local storage does not support presigned URLs; use GetPublicUrlAsync or serve files via static file middleware |
| Metadata missing or incomplete | Ensure the .meta sidecar file exists alongside the stored file; it is created automatically on upload |
.meta files appear in file listings |
Sidecar files are automatically excluded from ListAsync results by filtering out .meta extensions |
| Files not found after path template change | Existing files retain their original keys; changing PathTemplate only affects new uploads |
| URL returns 404 | Ensure static file middleware is configured with the correct BasePath and RequestPath matching BaseUrl |
Content type is application/octet-stream |
Set ContentType explicitly on FileUploadRequest, or ensure the file has a recognized extension |
License
This project is licensed under the MIT License.
Copyright (c) 2024-2026 Repletory.
For complete documentation, infrastructure setup, and configuration reference, see the RepletoryLib main repository.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
- RepletoryLib.FileStorage.Abstractions (>= 1.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 74 | 3/2/2026 |