feat(storage): add IFileStorage + local-disk implementation
Adds IFileStorage abstraction and LocalDiskFileStorage for receipt file storage with path-traversal protection, and registers it in DI. Includes 3 TDD-verified xUnit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,8 @@ builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||
builder.Services.AddScoped<IMinistryService, MinistryService>();
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
|
||||
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger / MVC
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ROLAC.API.Services.Storage;
|
||||
|
||||
public interface IFileStorage
|
||||
{
|
||||
Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default);
|
||||
Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default);
|
||||
Task DeleteAsync(string relativePath, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ROLAC.API.Services.Storage;
|
||||
|
||||
public class LocalDiskFileStorage : IFileStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public LocalDiskFileStorage(IConfiguration config)
|
||||
{
|
||||
var configured = config["Storage:LocalRoot"] ?? "App_Data/storage";
|
||||
_root = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.Combine(Directory.GetCurrentDirectory(), configured);
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
private string Resolve(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
throw new ArgumentException("relativePath is required.", nameof(relativePath));
|
||||
|
||||
var normalized = relativePath.Replace('\\', '/').TrimStart('/');
|
||||
var full = Path.GetFullPath(Path.Combine(_root, normalized));
|
||||
var rootFull = Path.GetFullPath(_root) + Path.DirectorySeparatorChar;
|
||||
if (!full.StartsWith(rootFull, StringComparison.Ordinal))
|
||||
throw new ArgumentException("Path escapes storage root.", nameof(relativePath));
|
||||
return full;
|
||||
}
|
||||
|
||||
public async Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
var full = Resolve(relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
await using var file = File.Create(full);
|
||||
await content.CopyToAsync(file, ct);
|
||||
return relativePath.Replace('\\', '/').TrimStart('/');
|
||||
}
|
||||
|
||||
public Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
var full = Resolve(relativePath);
|
||||
Stream? result = File.Exists(full) ? File.OpenRead(full) : null;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
var full = Resolve(relativePath);
|
||||
if (File.Exists(full)) File.Delete(full);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,8 @@
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:4200", "https://localhost:4200" ]
|
||||
},
|
||||
"Storage": {
|
||||
"LocalRoot": "App_Data/storage"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user