mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
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.
112 lines
4.3 KiB
C#
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"
|
|
};
|
|
}
|
|
}
|