shoc-backend/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs
Arthur Bassi 719a5b05fd fix(work-orders): validate uploads before persist and store privately
Board completion/media uploads now check domain state first, use server-controlled filenames with an allowlist, store under App_Data, and serve via authenticated download endpoints. Also harden WeekRolled log arguments against CodeQL log-injection findings.
2026-07-21 09:25:33 -03:00

112 lines
4.3 KiB
C#

using Microsoft.AspNetCore.Hosting;
using SeaHaven.Services.Helpers;
namespace Api.SeaHavenIndustries.Helper
{
public interface IWorkOrderPrivateFileStorage
{
Task<string> SaveAsync(int workOrderId, IFormFile file, CancellationToken cancellationToken = default);
bool TryResolvePhysicalPath(string? storedValue, out string physicalPath);
void TryDelete(string? storedValue);
string ToDownloadApiPath(int workOrderId, string kind, int? mediaId = null);
}
/// <summary>
/// Stores board completion/media files under ContentRoot/App_Data (not wwwroot).
/// DB values are relative keys: workorder-files/{workOrderId}/{guid}{ext}
/// </summary>
public sealed class WorkOrderPrivateFileStorage : IWorkOrderPrivateFileStorage
{
public const string RelativeRoot = "workorder-files";
private readonly IWebHostEnvironment _env;
public WorkOrderPrivateFileStorage(IWebHostEnvironment env)
{
_env = env;
}
private string RootPath => Path.GetFullPath(Path.Combine(_env.ContentRootPath, "App_Data", RelativeRoot));
public async Task<string> SaveAsync(int workOrderId, IFormFile file, CancellationToken cancellationToken = default)
{
var clientName = file.FileName;
if (string.IsNullOrWhiteSpace(clientName) && !string.IsNullOrWhiteSpace(file.ContentDisposition))
{
try
{
clientName = System.Net.Http.Headers.ContentDispositionHeaderValue
.Parse(file.ContentDisposition).FileName?.Trim('"');
}
catch
{
clientName = null;
}
}
if (!WorkOrderUploadFileRules.TryGetAllowedExtension(clientName, out var extension, out var error))
throw new InvalidOperationException(error ?? "Invalid file.");
var serverName = WorkOrderUploadFileRules.BuildServerFileName(extension);
var relativeKey = $"{RelativeRoot}/{workOrderId}/{serverName}".Replace('\\', '/');
var directory = Path.Combine(RootPath, workOrderId.ToString());
var fullPath = Path.GetFullPath(Path.Combine(directory, serverName));
if (!fullPath.StartsWith(RootPath, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Resolved path escapes storage root.");
Directory.CreateDirectory(directory);
await using (var stream = System.IO.File.Create(fullPath))
{
await file.CopyToAsync(stream, cancellationToken);
}
return relativeKey;
}
public bool TryResolvePhysicalPath(string? storedValue, out string physicalPath)
{
physicalPath = string.Empty;
if (string.IsNullOrWhiteSpace(storedValue))
return false;
// Accept relative keys; reject absolute URLs / wwwroot legacy paths here.
var normalized = storedValue.Replace('\\', '/').TrimStart('/');
if (!normalized.StartsWith(RelativeRoot + "/", StringComparison.OrdinalIgnoreCase))
return false;
var relative = normalized[(RelativeRoot.Length + 1)..];
var candidate = Path.GetFullPath(Path.Combine(RootPath, relative));
if (!candidate.StartsWith(RootPath, StringComparison.OrdinalIgnoreCase))
return false;
if (!System.IO.File.Exists(candidate))
return false;
physicalPath = candidate;
return true;
}
public void TryDelete(string? storedValue)
{
if (!TryResolvePhysicalPath(storedValue, out var path))
return;
try
{
System.IO.File.Delete(path);
}
catch
{
// best-effort cleanup
}
}
public string ToDownloadApiPath(int workOrderId, string kind, int? mediaId = null)
=> kind switch
{
"completion" => $"/api/workorders/{workOrderId}/completion-doc/file",
"media" when mediaId.HasValue => $"/api/workorders/{workOrderId}/media/{mediaId.Value}/file",
_ => $"/api/workorders/{workOrderId}/media/file"
};
}
}