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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RepletoryLib.FileStorage.Local" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RepletoryLib.FileStorage.Local" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="RepletoryLib.FileStorage.Local" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RepletoryLib.FileStorage.Local --version 1.0.0
                    
#r "nuget: RepletoryLib.FileStorage.Local, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RepletoryLib.FileStorage.Local@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RepletoryLib.FileStorage.Local&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=RepletoryLib.FileStorage.Local&version=1.0.0
                    
Install as a Cake Tool

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.

NuGet .NET 10 License: MIT


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 -- Full IFileStorageService implementation 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 .meta file
  • 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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