diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 73e5a69..2d85749 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -29,9 +29,9 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("{id:int}/media")] - public async Task GetMedia(int id) + public async Task GetMedia(int id, CancellationToken cancellationToken) { - var media = await _workOrderMediaService.GetMediaAsync(id); + var media = await _workOrderMediaService.GetMediaAsync(id, cancellationToken); if (media == null) return NotFound(new Response { Status = "Error", Message = "Work order not found." }); return Ok(media); @@ -42,45 +42,74 @@ namespace Api.SeaHavenIndustries.Controllers public async Task AddMedia( int id, [FromForm] WorkOrderMediaCategory? category, - [FromForm] IFormFile file) + [FromForm] IFormFile file, + CancellationToken cancellationToken) { if (file == null || file.Length == 0) return BadRequest(new Response { Status = "Error", Message = "file is required." }); + string? fileUrl = null; try { WorkOrderMediaFileRules.EnsureAllowed(file); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await _fileStorage.SaveFileAsync(file); - var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + await _workOrderMediaService.EnsureCanMutateMediaAsync(id, actorId, cancellationToken); + + fileUrl = await _fileStorage.SaveFileAsync(file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId, cancellationToken); return Ok(media); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") { + TryCompensateUpload(fileUrl); return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + TryCompensateUpload(fileUrl); + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) { + TryCompensateUpload(fileUrl); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch + { + TryCompensateUpload(fileUrl); + throw; + } } [HttpPatch("{id:int}/media/{mediaId:int}")] public async Task UpdateMediaCategory( int id, int mediaId, - [FromForm] WorkOrderMediaCategory category) + [FromForm] WorkOrderMediaCategory category, + [FromForm] string? workOrderVersion = null, + CancellationToken cancellationToken = default) { try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var media = await _workOrderMediaService.UpdateMediaCategoryAsync(id, mediaId, category, actorId); + var media = await _workOrderMediaService.UpdateMediaCategoryAsync( + id, mediaId, category, workOrderVersion, actorId, cancellationToken); return Ok(media); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") { return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); @@ -88,22 +117,41 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpDelete("{id:int}/media/{mediaId:int}")] - public async Task DeleteMedia(int id, int mediaId) + public async Task DeleteMedia( + int id, + int mediaId, + [FromQuery] string? workOrderVersion = null, + CancellationToken cancellationToken = default) { try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, actorId, cancellationToken); return NoContent(); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") { return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } } + + private void TryCompensateUpload(string? fileUrl) + { + if (!string.IsNullOrWhiteSpace(fileUrl)) + _fileStorage.TryDelete(fileUrl); + } } } diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index 308dd86..adcc357 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -23,8 +23,7 @@ namespace Api.SeaHavenIndustries.Infrastructure var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uploadPath = Path.Combine("Assets", "Documents"); - var webRoot = _webHostEnvironment.WebRootPath - ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); + var webRoot = ResolveWebRoot(); var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); @@ -39,6 +38,41 @@ namespace Api.SeaHavenIndustries.Infrastructure var domain = $"{request.Scheme}://{request.Host}"; return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; } + + public bool TryDelete(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + return false; + + try + { + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return false; + + var relativePath = uri.AbsolutePath.TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..", StringComparison.Ordinal) + || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!System.IO.File.Exists(fullPath)) + return false; + + System.IO.File.Delete(fullPath); + return true; + } + catch + { + return false; + } + } + + private string ResolveWebRoot() + => _webHostEnvironment.WebRootPath + ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); } public class DispatchEmailAdapter : IDispatchEmailPort diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index d21a804..a6e481e 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -24,6 +24,18 @@ namespace SeaHaven.DataServices.Implementation public void TrackAttachment(WorkOrderAttachments attachment) => _context.workOrderAttachments.Add(attachment); + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) + => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; + + public void MarkWorkOrderModified(WorkOrder workOrder) + { + // Force a WO update so the RowVersion concurrency token is enforced when only + // attachment rows change (category-only Extra mutations). + var entry = _context.Entry(workOrder); + if (entry.State == EntityState.Unchanged) + entry.Property(w => w.Attachments).IsModified = true; + } + public Task SaveAsync(CancellationToken cancellationToken) => _context.SaveChangesAsync(cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index db1d07e..468d7a7 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); + void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); + void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index ce7b7ae..97f7744 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -71,27 +71,27 @@ namespace SeaHaven.Services.Helpers { var canonical = Canonicalize(field) ?? field; return canonical switch - { - WoNumber => "InternalWONumber", - WorkOrderType => "WorkOrderType", - SiteCode => "SiteCode", - LifecycleStatus => "LifecycleStatus", - AssignTo => "AssignTo", - DueDate => "DueDate", - ScheduledDate => "ScheduledDate", - TargetWeek => "TargetWeek", - ScheduleWeekOnly => "ScheduleWeekOnly", - VendorId => "VendorId", - ApptDate => "ApptDate", - ApptTime => "ApptTime", - DocStatus => "DocStatus", - CompletedDate => "CompletedDate", - Pm => "Trade", - ServiceNotes => "ServiceNotes", - ExtraServices => "ExtraServices", - FlagColor => "FlagColor", - _ => canonical - }; + { + WoNumber => "InternalWONumber", + WorkOrderType => "WorkOrderType", + SiteCode => "SiteCode", + LifecycleStatus => "LifecycleStatus", + AssignTo => "AssignTo", + DueDate => "DueDate", + ScheduledDate => "ScheduledDate", + TargetWeek => "TargetWeek", + ScheduleWeekOnly => "ScheduleWeekOnly", + VendorId => "VendorId", + ApptDate => "ApptDate", + ApptTime => "ApptTime", + DocStatus => "DocStatus", + CompletedDate => "CompletedDate", + Pm => "Trade", + ServiceNotes => "ServiceNotes", + ExtraServices => "ExtraServices", + FlagColor => "FlagColor", + _ => canonical + }; } } } diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index e500edc..bc03e6c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -13,29 +13,53 @@ namespace SeaHaven.Services.Helpers "video/quicktime" }; - private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".jpg", - ".jpeg", - ".png", - ".mp4", - ".mov" - }; + private static readonly Dictionary> ExtensionsByContentType = + new(StringComparer.OrdinalIgnoreCase) + { + ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, + ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, + ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, + ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" } + }; public static bool IsAllowed(IFormFile file) { - if (file == null) + if (file == null || file.Length <= 0) return false; - var contentTypeOk = !string.IsNullOrWhiteSpace(file.ContentType) - && AllowedContentTypes.Contains(file.ContentType.Trim()); + var contentType = (file.ContentType ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) + return false; - var extension = Path.GetExtension(file.FileName ?? ""); - var extensionOk = !string.IsNullOrWhiteSpace(extension) - && AllowedExtensions.Contains(extension); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + if (string.IsNullOrWhiteSpace(extension) + || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) + || !allowedExtensions.Contains(extension)) + { + return false; + } - // Accept when either MIME or extension matches the advertised contract (JPG/PNG/MP4/MOV). - return contentTypeOk || extensionOk; + try + { + using var stream = file.OpenReadStream(); + var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64); + if (headerLength == 0) + return false; + + var header = new byte[headerLength]; + var read = stream.Read(header, 0, header.Length); + if (read <= 0) + return false; + + if (read < header.Length) + Array.Resize(ref header, read); + + return MatchesSignature(contentType, header); + } + catch + { + return false; + } } public static void EnsureAllowed(IFormFile file) @@ -47,5 +71,43 @@ namespace SeaHaven.Services.Helpers "Supported media types are JPG, PNG, MP4, and MOV."); } } + + internal static bool MatchesSignature(string contentType, byte[] bytes) + { + if (bytes.Length == 0) + return false; + + if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 + && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; + } + + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)) + { + return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + } + + if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase)) + { + return HasFtypBox(bytes); + } + + return false; + } + + private static bool HasFtypBox(byte[] bytes) + { + if (bytes.Length < 12) + return false; + + // ISO BMFF: [size:4][ftyp:4][major_brand:4]... + return bytes[4] == (byte)'f' + && bytes[5] == (byte)'t' + && bytes[6] == (byte)'y' + && bytes[7] == (byte)'p'; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 098d5d7..7c7cf9d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -12,14 +12,21 @@ namespace SeaHaven.Services.Implementation { private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderDetailDataService _detailData; + private readonly IWorkOrderAuditService _auditService; - public WorkOrderMediaService(IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData) + public WorkOrderMediaService( + IWorkOrderMediaDataService mediaData, + IWorkOrderDetailDataService detailData, + IWorkOrderAuditService auditService) { _mediaData = mediaData; _detailData = detailData; + _auditService = auditService; } - public async Task?> GetMediaAsync(int workOrderId) + public async Task?> GetMediaAsync( + int workOrderId, + CancellationToken cancellationToken = default) { if (!await _detailData.ExistsAsync(workOrderId)) return null; @@ -32,23 +39,26 @@ namespace SeaHaven.Services.Implementation return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } + public async Task EnsureCanMutateMediaAsync( + int workOrderId, + string? actorId, + CancellationToken cancellationToken = default) + { + EnsureAuthenticatedActor(actorId); + await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + } + public async Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory? category, string fileUrl, - string? actorId) + string? actorId, + CancellationToken cancellationToken = default) { + EnsureAuthenticatedActor(actorId); + var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; - - if (!await _detailData.ExistsAsync(workOrderId)) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - - var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); - 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 workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); if (resolvedCategory == WorkOrderMediaCategory.Completion) { @@ -59,8 +69,11 @@ namespace SeaHaven.Services.Implementation if (resolvedCategory == WorkOrderMediaCategory.Before) { + var oldBefore = workOrder.BeforPhotoAttachment; workOrder.BeforPhotoAttachment = fileUrl; - await _mediaData.SaveAsync(CancellationToken.None); + await _auditService.StageFieldChangedAsync( + workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId); + await _mediaData.SaveAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -1, @@ -72,8 +85,11 @@ namespace SeaHaven.Services.Implementation if (resolvedCategory == WorkOrderMediaCategory.After) { + var oldAfter = workOrder.AfterPhotoAttachment; workOrder.AfterPhotoAttachment = fileUrl; - await _mediaData.SaveAsync(CancellationToken.None); + await _auditService.StageFieldChangedAsync( + workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId); + await _mediaData.SaveAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -2, @@ -93,7 +109,13 @@ namespace SeaHaven.Services.Implementation }; _mediaData.TrackAttachment(attachment); - await _mediaData.SaveAsync(CancellationToken.None); + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + null, + FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()), + actorId); + await _mediaData.SaveAsync(cancellationToken); return new WorkOrderMediaFileDto { @@ -109,8 +131,12 @@ namespace SeaHaven.Services.Implementation int workOrderId, int mediaId, WorkOrderMediaCategory category, - string? actorId) + string? workOrderVersion, + string? actorId, + CancellationToken cancellationToken = default) { + EnsureAuthenticatedActor(actorId); + if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint."); @@ -121,20 +147,15 @@ namespace SeaHaven.Services.Implementation "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } - if (!await _detailData.ExistsAsync(workOrderId)) - throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + ApplyExpectedVersion(workOrder, workOrderVersion); - var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); - 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 attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None); + var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); + var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString(); + if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) { var url = attachment.Attachments ?? ""; @@ -146,7 +167,14 @@ namespace SeaHaven.Services.Implementation attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; - await _mediaData.SaveAsync(CancellationToken.None); + + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + FormatMediaAuditValue(mediaId, priorCategory), + FormatMediaAuditValue(mediaId, category.ToString()), + actorId); + await _mediaData.SaveAsync(cancellationToken); return new WorkOrderMediaFileDto { @@ -158,7 +186,14 @@ namespace SeaHaven.Services.Implementation } attachment.Category = category; - await _mediaData.SaveAsync(CancellationToken.None); + _mediaData.MarkWorkOrderModified(workOrder); + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + FormatMediaAuditValue(mediaId, priorCategory), + FormatMediaAuditValue(mediaId, category.ToString()), + actorId); + await _mediaData.SaveAsync(cancellationToken); return new WorkOrderMediaFileDto { @@ -170,30 +205,89 @@ namespace SeaHaven.Services.Implementation }; } - public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) + public async Task DeleteMediaAsync( + int workOrderId, + int mediaId, + string? workOrderVersion, + string? actorId, + CancellationToken cancellationToken = default) { + EnsureAuthenticatedActor(actorId); + if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); + var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken); + ApplyExpectedVersion(workOrder, workOrderVersion); + + var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); + if (attachment == null) + throw new WorkOrderBoardValidationException("NotFound", "Media not found."); + + var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString(); + attachment.IsDeleted = true; + attachment.DeletionTime = DateTime.UtcNow; + attachment.DeleterUserId = actorId; + _mediaData.MarkWorkOrderModified(workOrder); + + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + FormatMediaAuditValue(mediaId, priorCategory), + FormatMediaAuditValue(mediaId, "Deleted"), + actorId); + await _mediaData.SaveAsync(cancellationToken); + } + + private async Task GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) + { if (!await _detailData.ExistsAsync(workOrderId)) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); - var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); + var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken); 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 attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None); - - if (attachment == null) - throw new WorkOrderBoardValidationException("NotFound", "Media not found."); - - attachment.IsDeleted = true; - attachment.DeletionTime = DateTime.UtcNow; - attachment.DeleterUserId = actorId; - await _mediaData.SaveAsync(CancellationToken.None); + return workOrder; } + + private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion) + { + var expected = ParseRowVersion(workOrderVersion); + if (expected == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(expected)) + throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); + + _mediaData.SetExpectedWorkOrderVersion(workOrder, expected); + } + + private static void EnsureAuthenticatedActor(string? actorId) + { + if (string.IsNullOrWhiteSpace(actorId)) + throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to mutate work order media."); + } + + private static byte[]? ParseRowVersion(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return null; + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format."); + } + } + + private static string FormatMediaAuditValue(int? mediaId, string category) + => mediaId.HasValue ? $"{mediaId.Value}:{category}" : category; } } diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs index aae1383..10c1718 100644 --- a/SeaHaven.Services/Interfaces/IServicePorts.cs +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -18,6 +18,12 @@ namespace SeaHaven.Services.Interfaces public interface IFileStoragePort { Task SaveFileAsync(IFormFile file); + + /// + /// Best-effort delete of a previously saved file URL. Returns false when the file + /// cannot be resolved or removed; never throws for missing paths. + /// + bool TryDelete(string fileUrl); } /// diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index a40b422..fe1bbe1 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -5,17 +5,33 @@ namespace SeaHaven.Services.Interfaces { public interface IWorkOrderMediaService { - Task?> GetMediaAsync(int workOrderId); + Task?> GetMediaAsync(int workOrderId, CancellationToken cancellationToken = default); + + /// + /// Authorizes the caller and validates the work order is mutable before any blob storage write. + /// + Task EnsureCanMutateMediaAsync(int workOrderId, string? actorId, CancellationToken cancellationToken = default); + Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory? category, string fileUrl, - string? actorId); + string? actorId, + CancellationToken cancellationToken = default); + Task UpdateMediaCategoryAsync( int workOrderId, int mediaId, WorkOrderMediaCategory category, - string? actorId); - Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); + string? workOrderVersion, + string? actorId, + CancellationToken cancellationToken = default); + + Task DeleteMediaAsync( + int workOrderId, + int mediaId, + string? workOrderVersion, + string? actorId, + CancellationToken cancellationToken = default); } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 8029516..087d084 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,5 +1,7 @@ +using System.Text; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.Services.DTOs; @@ -569,13 +571,28 @@ public class WorkOrderCommentServiceTests public class WorkOrderMediaServiceTests { - [Fact] - public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() + private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); + var service = new WorkOrderMediaService( + new WorkOrderMediaDataService(context), + new WorkOrderDetailDataService(context), + audit); + return (context, service); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Fact] + public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() + { + var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, @@ -590,9 +607,6 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var detailData = new WorkOrderDetailDataService(context); - var mediaData = new WorkOrderMediaDataService(context); - var service = new WorkOrderMediaService(mediaData, detailData); var media = await service.GetMediaAsync(1); Assert.NotNull(media); @@ -605,14 +619,12 @@ public class WorkOrderMediaServiceTests [Fact] public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - var context = new ApplicationDbContext(options); + var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, - LifecycleStatus = LifecycleStatus.Completed + LifecycleStatus = LifecycleStatus.Completed, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); context.workOrderAttachments.Add(new WorkOrderAttachments { @@ -623,9 +635,8 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); var ex = await Assert.ThrowsAsync(() => - service.DeleteMediaAsync(1, 10, "actor-1")); + service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), "actor-1")); Assert.Equal("ReadOnly", ex.Code); } @@ -633,18 +644,15 @@ public class WorkOrderMediaServiceTests [Fact] public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - var context = new ApplicationDbContext(options); + var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, - LifecycleStatus = LifecycleStatus.Scheduled + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); - var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); var ex = await Assert.ThrowsAsync(() => service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1")); @@ -654,37 +662,61 @@ public class WorkOrderMediaServiceTests [Fact] public async Task AddMedia_WithoutCategory_PersistsAsUncategorizedExtra() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - var context = new ApplicationDbContext(options); + var (context, service) = CreateSut(); context.workOrders.Add(new WorkOrder { Id = 1, - LifecycleStatus = LifecycleStatus.Scheduled + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }); await context.SaveChangesAsync(); - var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", "actor-1"); Assert.True(media.Id > 0); Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); Assert.Null(context.workOrderAttachments.Single().Category); + Assert.Contains(context.WorkOrderAuditLogs, a => a.FieldName == "MediaCategory"); + } + + [Fact] + public async Task AddMedia_MissingActor_ThrowsForbidden() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", null)); + + Assert.Equal("Forbidden", ex.Code); + } + + [Fact] + public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound() + { + var (_, service) = CreateSut(); + var ex = await Assert.ThrowsAsync(() => + service.EnsureCanMutateMediaAsync(99, "actor-1")); + Assert.Equal("NotFound", ex.Code); } [Fact] public async Task UpdateMediaCategory_SetsBeforeFromExtraAttachment() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - var context = new ApplicationDbContext(options); - context.workOrders.Add(new WorkOrder + var (context, service) = CreateSut(); + var wo = new WorkOrder { Id = 1, - LifecycleStatus = LifecycleStatus.Scheduled - }); + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); context.workOrderAttachments.Add(new WorkOrderAttachments { Id = 10, @@ -694,12 +726,159 @@ public class WorkOrderMediaServiceTests }); await context.SaveChangesAsync(); - var service = new WorkOrderMediaService(new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context)); - var media = await service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, "actor-1"); + var media = await service.UpdateMediaCategoryAsync( + 1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1"); Assert.Equal(-1, media.Id); Assert.Equal(WorkOrderMediaCategory.Before, media.Category); Assert.Equal("https://example.com/photo.jpg", context.workOrders.Single().BeforPhotoAttachment); Assert.True(context.workOrderAttachments.Single().IsDeleted); + Assert.Contains(context.WorkOrderAuditLogs, a => + a.FieldName == "MediaCategory" && a.NewValue == "10:Before"); + } + + [Fact] + public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/photo.jpg", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + var ex = await Assert.ThrowsAsync(() => + service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, "actor-1")); + + Assert.Equal("ConcurrencyConflict", ex.Code); + } + + [Fact] + public async Task UpdateMediaCategory_CrossWorkOrderMedia_ThrowsNotFound() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrders.Add(new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 2, + Attachments = "https://example.com/photo.jpg", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var version = ToVersion(context.workOrders.Single(w => w.Id == 1)); + var ex = await Assert.ThrowsAsync(() => + service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task UpdateMediaCategory_DeletedMedia_ThrowsNotFound() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/photo.jpg", + Category = WorkOrderMediaCategory.Extra, + IsDeleted = true + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } +} + +public class WorkOrderMediaFileRulesTests +{ + private static FormFile FormFile(byte[] bytes, string fileName, string contentType) + => new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + + [Fact] + public void IsAllowed_ValidJpeg_ReturnsTrue() + { + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; + Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg"))); + } + + [Fact] + public void IsAllowed_SpoofedExtension_ReturnsFalse() + { + var html = Encoding.UTF8.GetBytes("not an image"); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", "image/jpeg"))); + } + + [Fact] + public void IsAllowed_SpoofedMime_ReturnsFalse() + { + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.exe", "image/jpeg"))); + } + + [Fact] + public void IsAllowed_MimeOrExtensionAlone_ReturnsFalse() + { + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.bin", "application/octet-stream"))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg"))); + } + + [Fact] + public void IsAllowed_ValidMp4Ftyp_ReturnsTrue() + { + // size(4) + ftyp(4) + isom(4) + var mp4 = new byte[] + { + 0x00, 0x00, 0x00, 0x18, + (byte)'f', (byte)'t', (byte)'y', (byte)'p', + (byte)'i', (byte)'s', (byte)'o', (byte)'m' + }; + Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(mp4, "clip.mp4", "video/mp4"))); + } + + [Fact] + public void EnsureAllowed_MalformedBytes_ThrowsUnsupportedMediaType() + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png"))); + Assert.Equal("UnsupportedMediaType", ex.Code); } }