using Microsoft.AspNetCore.Hosting; using SeaHaven.Services.Helpers; namespace Api.SeaHavenIndustries.Helper { public interface IWorkOrderPrivateFileStorage { Task 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); } /// /// Stores board completion/media files under ContentRoot/App_Data (not wwwroot). /// DB values are relative keys: workorder-files/{workOrderId}/{guid}{ext} /// 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 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" }; } }