diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index ef2ac67..c27ed3a 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -15,6 +15,7 @@ using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Helper; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -64,7 +65,8 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); controller.ControllerContext = new ControllerContext { @@ -138,7 +140,8 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); controllerWithBoard.ControllerContext = new ControllerContext { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs new file mode 100644 index 0000000..33a983f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs @@ -0,0 +1,88 @@ +using Api.SeaHavenIndustries.Helper; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Moq; +using SeaHaven.Services.Helpers; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderPrivateFileStorageTests +{ + private static (WorkOrderPrivateFileStorage Storage, string Root) CreateStorage() + { + var root = Path.Combine(Path.GetTempPath(), "wo-files-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var env = new Mock(); + env.Setup(e => e.ContentRootPath).Returns(root); + return (new WorkOrderPrivateFileStorage(env.Object), root); + } + + private static IFormFile FakeFile(string fileName, byte[]? bytes = null) + { + bytes ??= new byte[] { 1, 2, 3, 4 }; + var stream = new MemoryStream(bytes); + return new FormFile(stream, 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + } + + [Fact] + public async Task SaveAsync_UsesServerControlledName_OutsideWwwroot() + { + var (storage, root) = CreateStorage(); + try + { + var key = await storage.SaveAsync(42, FakeFile("../../evil.pdf")); + key.Should().StartWith("workorder-files/42/"); + key.Should().EndWith(".pdf"); + key.Should().NotContain(".."); + key.Should().NotContain("evil"); + + storage.TryResolvePhysicalPath(key, out var physical).Should().BeTrue(); + physical.Should().StartWith(Path.Combine(root, "App_Data", "workorder-files")); + File.Exists(physical).Should().BeTrue(); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task SaveAsync_RejectsDisallowedExtension() + { + var (storage, root) = CreateStorage(); + try + { + var act = async () => await storage.SaveAsync(1, FakeFile("payload.exe")); + await act.Should().ThrowAsync(); + Directory.Exists(Path.Combine(root, "App_Data", "workorder-files", "1")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void TryResolvePhysicalPath_RejectsPathEscape() + { + var (storage, root) = CreateStorage(); + try + { + storage.TryResolvePhysicalPath("workorder-files/../secret.txt", out _).Should().BeFalse(); + storage.TryResolvePhysicalPath("https://host/Assets/Documents/x.pdf", out _).Should().BeFalse(); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 23814e6..eb2e2ef 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -19,7 +19,9 @@ using static System.Runtime.InteropServices.JavaScript.JSType; using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.DataServices.Interfaces; +using Api.SeaHavenIndustries.Helper; namespace Api.SeaHavenIndustries.Controllers { @@ -53,6 +55,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWorkOrderCommentService _workOrderCommentService; private readonly IWorkOrderCompletionService _workOrderCompletionService; private readonly IWorkOrderMediaService _workOrderMediaService; + private readonly IWorkOrderPrivateFileStorage _privateFileStorage; public WorkOrderController( UserManager userManager, @@ -78,7 +81,8 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderDetailService workOrderDetailService, IWorkOrderCommentService workOrderCommentService, IWorkOrderCompletionService workOrderCompletionService, - IWorkOrderMediaService workOrderMediaService) + IWorkOrderMediaService workOrderMediaService, + IWorkOrderPrivateFileStorage privateFileStorage) { _userManager = userManager; _workOrderService = workOrderService; @@ -104,6 +108,7 @@ namespace Api.SeaHavenIndustries.Controllers _workOrderCommentService = workOrderCommentService; _workOrderCompletionService = workOrderCompletionService; _workOrderMediaService = workOrderMediaService; + _privateFileStorage = privateFileStorage; } [HttpGet("board")] @@ -418,34 +423,67 @@ namespace Api.SeaHavenIndustries.Controllers if (file == null || file.Length == 0) return BadRequest(new Response { Status = "Error", Message = "file is required." }); + if (!WorkOrderUploadFileRules.TryGetAllowedExtension(file.FileName, out _, out var fileError)) + return BadRequest(new Response { Status = "Error", Message = fileError }); + + string? storedKey = null; try { + var uploadDto = new WorkOrderCompletionDocUploadDto + { + SignOffName = signOffName, + SignOffSignature = signOffSignature, + WorkOrderVersion = workOrderVersion + }; + + // Validate domain state before persisting any bytes. + await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, uploadDto); + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await SaveFileAsync(file); + storedKey = await _privateFileStorage.SaveAsync(id, file); + var downloadUrl = _privateFileStorage.ToDownloadApiPath(id, "completion"); var result = await _workOrderCompletionService.UploadCompletionDocAsync( id, - new WorkOrderCompletionDocUploadDto - { - SignOffName = signOffName, - SignOffSignature = signOffSignature, - WorkOrderVersion = workOrderVersion - }, - fileUrl, + uploadDto, + storedKey, actorId); + result.SignOffAttachment = downloadUrl; return Ok(result); } catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") { + _privateFileStorage.TryDelete(storedKey); return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") { + _privateFileStorage.TryDelete(storedKey); return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } catch (WorkOrderBoardValidationException ex) { + _privateFileStorage.TryDelete(storedKey); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (InvalidOperationException ex) + { + _privateFileStorage.TryDelete(storedKey); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpGet("{id:int}/completion-doc/file")] + public async Task DownloadCompletionDoc(int id) + { + var stored = await _workOrderCompletionService.GetCompletionDocStoredPathAsync(id); + if (stored == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + + if (!_privateFileStorage.TryResolvePhysicalPath(stored, out var physicalPath)) + return NotFound(new Response { Status = "Error", Message = "Completion document not found." }); + + var contentType = GetContentType(physicalPath); + return PhysicalFile(physicalPath, contentType, Path.GetFileName(physicalPath)); } [HttpGet("{id:int}/media")] @@ -454,6 +492,16 @@ namespace Api.SeaHavenIndustries.Controllers var media = await _workOrderMediaService.GetMediaAsync(id); if (media == null) return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + + foreach (var item in media) + { + if (!string.IsNullOrWhiteSpace(item.Url) + && item.Url.StartsWith(WorkOrderPrivateFileStorage.RelativeRoot + "/", StringComparison.OrdinalIgnoreCase)) + { + item.Url = _privateFileStorage.ToDownloadApiPath(id, "media", item.Id); + } + } + return Ok(media); } @@ -467,21 +515,49 @@ namespace Api.SeaHavenIndustries.Controllers if (file == null || file.Length == 0) return BadRequest(new Response { Status = "Error", Message = "file is required." }); + if (!WorkOrderUploadFileRules.TryGetAllowedExtension(file.FileName, out _, out var fileError)) + return BadRequest(new Response { Status = "Error", Message = fileError }); + + string? storedKey = null; try { + await _workOrderMediaService.EnsureCanAddMediaAsync(id, category); + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await SaveFileAsync(file); - var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + storedKey = await _privateFileStorage.SaveAsync(id, file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, storedKey, actorId); + media.Url = _privateFileStorage.ToDownloadApiPath(id, "media", media.Id); return Ok(media); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") { + _privateFileStorage.TryDelete(storedKey); return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } catch (WorkOrderBoardValidationException ex) { + _privateFileStorage.TryDelete(storedKey); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (InvalidOperationException ex) + { + _privateFileStorage.TryDelete(storedKey); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpGet("{id:int}/media/{mediaId:int}/file")] + public async Task DownloadMedia(int id, int mediaId) + { + var stored = await _workOrderMediaService.GetMediaStoredPathAsync(id, mediaId); + if (stored == null) + return NotFound(new Response { Status = "Error", Message = "Media not found." }); + + if (!_privateFileStorage.TryResolvePhysicalPath(stored, out var physicalPath)) + return NotFound(new Response { Status = "Error", Message = "Media file not found." }); + + var contentType = GetContentType(physicalPath); + return PhysicalFile(physicalPath, contentType, Path.GetFileName(physicalPath)); } [HttpDelete("{id:int}/media/{mediaId:int}")] @@ -1731,6 +1807,24 @@ namespace Api.SeaHavenIndustries.Controllers return await _workOrderDataService.GenerateNextInternalWONumberAsync(); } + private static string GetContentType(string path) + { + return Path.GetExtension(path).ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".webp" => "image/webp", + ".gif" => "image/gif", + ".doc" => "application/msword", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls" => "application/vnd.ms-excel", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".txt" => "text/plain", + _ => "application/octet-stream" + }; + } + private async Task SaveFileAsync(IFormFile file) { var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"'); diff --git a/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs b/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs new file mode 100644 index 0000000..33a8c7b --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs @@ -0,0 +1,112 @@ +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" + }; + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 411405e..a305b04 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -44,6 +44,7 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(sp => { var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); diff --git a/SeaHaven.Services/Helpers/WorkOrderUploadFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderUploadFileRules.cs new file mode 100644 index 0000000..e9fe352 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderUploadFileRules.cs @@ -0,0 +1,53 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderUploadFileRules + { + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".pdf", + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".txt" + }; + + public static bool TryGetAllowedExtension(string? clientFileName, out string extension, out string? error) + { + extension = string.Empty; + error = null; + + if (string.IsNullOrWhiteSpace(clientFileName)) + { + error = "file name is required."; + return false; + } + + // Reject path segments from the client; only the final name matters for extension. + var name = Path.GetFileName(clientFileName.Replace('\\', '/')); + if (string.IsNullOrWhiteSpace(name) || name is "." or "..") + { + error = "Invalid file name."; + return false; + } + + var ext = Path.GetExtension(name); + if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext)) + { + error = $"File type '{ext}' is not allowed."; + return false; + } + + extension = ext.ToLowerInvariant(); + return true; + } + + public static string BuildServerFileName(string extension) + => $"{Guid.NewGuid():N}{extension}"; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs index ccea868..6e3ae88 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -40,27 +40,28 @@ namespace SeaHaven.Services.Implementation return row == null ? null : WorkOrderDetailService.MapTemplate(row); } + public async Task EnsureCanUploadCompletionDocAsync(int workOrderId, WorkOrderCompletionDocUploadDto request) + { + var workOrder = await LoadMutableWorkOrderAsync(workOrderId); + ValidateUploadEligibility(workOrder, request.WorkOrderVersion); + } + + public async Task GetCompletionDocStoredPathAsync(int workOrderId) + { + var workOrder = await _context.workOrders + .AsNoTracking() + .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + return workOrder?.SignOffAttachment; + } + public async Task UploadCompletionDocAsync( int workOrderId, WorkOrderCompletionDocUploadDto request, string fileUrl, string? actorId) { - var workOrder = await _context.workOrders - .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); - - if (workOrder == null) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); - - var workOrderVersion = ParseRowVersion(request.WorkOrderVersion); - if (workOrderVersion == null) - throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); - - if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion)) - throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); + var workOrder = await LoadMutableWorkOrderAsync(workOrderId); + var workOrderVersion = ValidateUploadEligibility(workOrder, request.WorkOrderVersion); _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion; @@ -160,6 +161,32 @@ namespace SeaHaven.Services.Implementation } } + private async Task LoadMutableWorkOrderAsync(int workOrderId) + { + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + return workOrder; + } + + private static byte[] ValidateUploadEligibility(WorkOrder workOrder, string? workOrderVersionBase64) + { + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var workOrderVersion = ParseRowVersion(workOrderVersionBase64); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion)) + throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); + + return workOrderVersion; + } + private static byte[]? ParseRowVersion(string? base64) { if (string.IsNullOrWhiteSpace(base64)) diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 8de3380..1354309 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -33,28 +33,44 @@ namespace SeaHaven.Services.Implementation return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } + public async Task EnsureCanAddMediaAsync(int workOrderId, WorkOrderMediaCategory category) + { + await LoadMutableWorkOrderForMediaAsync(workOrderId, category); + } + + public async Task GetMediaStoredPathAsync(int workOrderId, int mediaId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + if (mediaId == -1) + { + var wo = await _context.workOrders.AsNoTracking().FirstOrDefaultAsync(w => w.Id == workOrderId); + return wo?.BeforPhotoAttachment; + } + + if (mediaId == -2) + { + var wo = await _context.workOrders.AsNoTracking().FirstOrDefaultAsync(w => w.Id == workOrderId); + return wo?.AfterPhotoAttachment; + } + + if (mediaId <= 0) + return null; + + var attachment = await _context.workOrderAttachments + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true); + return attachment?.Attachments; + } + public async Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId) { - if (!await _detailData.ExistsAsync(workOrderId)) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - - var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); - if (workOrder == null) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - - if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) - throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); - - if (category == WorkOrderMediaCategory.Completion) - { - throw new WorkOrderBoardValidationException( - "UseCompletionDocEndpoint", - "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); - } + var workOrder = await LoadMutableWorkOrderForMediaAsync(workOrderId, category); if (category == WorkOrderMediaCategory.Before) { @@ -130,5 +146,27 @@ namespace SeaHaven.Services.Implementation attachment.DeleterUserId = actorId; await _context.SaveChangesAsync(); } + + private async Task LoadMutableWorkOrderForMediaAsync(int workOrderId, WorkOrderMediaCategory category) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + if (category == WorkOrderMediaCategory.Completion) + { + throw new WorkOrderBoardValidationException( + "UseCompletionDocEndpoint", + "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); + } + + return workOrder; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs index ec4eebf..64223f6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -37,9 +37,10 @@ namespace SeaHaven.Services.Implementation var skipped = 0; var failed = 0; + // Log only system-formatted dates (yyyy-MM-dd) — avoid free-form user-derived strings in logs. _logger.LogInformation( - "WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}", - correlationId, sourceWeekStart, sourceWeekEnd); + "WeekRolled job started. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, SourceWeekEnd={SourceWeekEnd:yyyy-MM-dd}", + sourceWeekStart, sourceWeekEnd); // Always fetch the first page of remaining candidates. Processed rows write ledger // entries that exclude them from the next query; offset paging would skip unprocessed rows. @@ -78,8 +79,8 @@ namespace SeaHaven.Services.Implementation { failed++; _logger.LogError(ex, - "WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}", - workOrderId, correlationId); + "WeekRolled failed for WorkOrderId={WorkOrderId}, SourceWeekStart={SourceWeekStart:yyyy-MM-dd}", + workOrderId, sourceWeekStart); } } @@ -101,8 +102,8 @@ namespace SeaHaven.Services.Implementation }; _logger.LogInformation( - "WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", - correlationId, processed, skipped, failed, result.DurationMs); + "WeekRolled job completed. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", + sourceWeekStart, processed, skipped, failed, result.DurationMs); return result; } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs index 333e906..0f13f17 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -7,11 +7,13 @@ namespace SeaHaven.Services.Interfaces { Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); Task GetTemplateByIdAsync(int id); + Task EnsureCanUploadCompletionDocAsync(int workOrderId, WorkOrderCompletionDocUploadDto request); Task UploadCompletionDocAsync( int workOrderId, WorkOrderCompletionDocUploadDto request, string fileUrl, string? actorId); + Task GetCompletionDocStoredPathAsync(int workOrderId); Task CreateTemplateAsync(CompletionDocTemplateCreateDto request); Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); Task DeleteTemplateAsync(int id); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index 015cc83..141dc81 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -6,7 +6,9 @@ namespace SeaHaven.Services.Interfaces public interface IWorkOrderMediaService { Task?> GetMediaAsync(int workOrderId); + Task EnsureCanAddMediaAsync(int workOrderId, WorkOrderMediaCategory category); Task AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); + Task GetMediaStoredPathAsync(int workOrderId, int mediaId); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderUploadFileRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderUploadFileRulesTests.cs new file mode 100644 index 0000000..04db7e6 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderUploadFileRulesTests.cs @@ -0,0 +1,116 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using Xunit; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderUploadFileRulesTests +{ + [Theory] + [InlineData("report.pdf", ".pdf")] + [InlineData(@"C:\temp\photo.PNG", ".png")] + [InlineData("../evil.jpg", ".jpg")] + public void TryGetAllowedExtension_AcceptsSafeTypes(string name, string expectedExt) + { + var ok = WorkOrderUploadFileRules.TryGetAllowedExtension(name, out var ext, out var error); + Assert.True(ok, error); + Assert.Equal(expectedExt, ext); + } + + [Theory] + [InlineData("payload.exe")] + [InlineData("noext")] + [InlineData("")] + [InlineData("..")] + public void TryGetAllowedExtension_RejectsUnsafeTypes(string name) + { + var ok = WorkOrderUploadFileRules.TryGetAllowedExtension(name, out _, out var error); + Assert.False(ok); + Assert.False(string.IsNullOrWhiteSpace(error)); + } + + [Fact] + public void BuildServerFileName_IsGuidPlusExtension_NoClientName() + { + var name = WorkOrderUploadFileRules.BuildServerFileName(".pdf"); + Assert.StartsWith(name[..32], name); + Assert.EndsWith(".pdf", name); + Assert.DoesNotContain("..", name); + Assert.DoesNotContain("/", name); + Assert.DoesNotContain("\\", name); + } +} + +public class WorkOrderUploadValidationOrderTests +{ + private static (ApplicationDbContext Context, WorkOrderCompletionService Completion, WorkOrderMediaService Media) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var templateData = new CompletionDocTemplateDataService(context); + var detailData = new WorkOrderDetailDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var completion = new WorkOrderCompletionService(context, templateData, detailData, audit); + var media = new WorkOrderMediaService(context, detailData); + return (context, completion, media); + } + + [Fact] + public async Task EnsureCanUploadCompletionDoc_NotFound_ThrowsBeforeAnyPersist() + { + var (_, completion, _) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + completion.EnsureCanUploadCompletionDocAsync( + 999, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + })); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task EnsureCanUploadCompletionDoc_ReadOnly_Throws() + { + var (context, completion, _) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + completion.EnsureCanUploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + })); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task EnsureCanAddMedia_NotFound_Throws() + { + var (_, _, media) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + media.EnsureCanAddMediaAsync(999, WorkOrderMediaCategory.Before)); + + Assert.Equal("NotFound", ex.Code); + } +}