From dc251c42d42dff0a1d5da8cadb8d6b24c623de77 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:52:44 -0300 Subject: [PATCH 1/7] fix(media): apply SH-116 media contract and lift the 1 MB proxy body cap The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every media upload over ~1 MB got an nginx 413 before reaching the API. Ship a .platform nginx override (120M) in the bundle and assert it in the bundle contract. Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order, with stable generic rejection messages. The request ceiling (110 MB) sits between the per-kind caps and the proxy so oversize files get the generic message. The vendor portal accepts the same photo/video types and caps. --- .../nginx/conf.d/01_upload_body_size.conf | 6 + .../VendorPortalDocumentTests.cs | 110 +++++++ .../WorkOrderMediaControllerTests.cs | 167 ++++++++++- .../Controllers/VendorPortalController.cs | 3 +- .../Controllers/WorkOrderMediaController.cs | 11 +- .../WorkOrderMediaDataService.cs | 12 + .../Interfaces/IWorkOrderMediaDataService.cs | 5 + .../Helpers/WorkOrderMediaContract.cs | 84 ++++++ .../Helpers/WorkOrderMediaFileRules.cs | 28 +- .../Implementation/VendorPortalService.cs | 86 +++--- .../Implementation/WorkOrderMediaService.cs | 38 +++ .../WorkOrderPhase6Tests.cs | 283 ++++++++++++++++++ scripts/check_app_terraform_isolation.py | 2 +- scripts/package-elastic-beanstalk.sh | 5 + scripts/test_check_app_terraform_isolation.py | 11 + scripts/validate-elastic-beanstalk-bundle.sh | 8 +- 16 files changed, 807 insertions(+), 52 deletions(-) create mode 100644 .platform/nginx/conf.d/01_upload_body_size.conf create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaContract.cs diff --git a/.platform/nginx/conf.d/01_upload_body_size.conf b/.platform/nginx/conf.d/01_upload_body_size.conf new file mode 100644 index 0000000..a7bd889 --- /dev/null +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -0,0 +1,6 @@ +# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m, +# which returned 413 for every media upload over ~1 MB before the request reached +# the API. The cap sits above the API's own request limit +# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get +# the API's generic per-kind message instead of an nginx error page. +client_max_body_size 120M; diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 0d8f6e3..83d9d1d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -363,6 +363,116 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(1); } + [Fact] + public void UploadCompletionDocument_AdvertisesContractRequestLimit() + { + var attribute = Assert.Single( + typeof(VendorPortalController) + .GetMethod(nameof(VendorPortalController.UploadCompletionDocument))! + .CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + bytes.Value.Should().Be(110_000_000L); + } + + private static byte[] MediaBytes(int size, params byte[] header) + { + var bytes = new byte[size]; + header.AsSpan().CopyTo(bytes); + return bytes; + } + + private static byte[] FtypVideoBytes(int size) => MediaBytes( + size, 0x00, 0x00, 0x00, 0x20, (byte)'f', (byte)'t', (byte)'y', (byte)'p', + (byte)'i', (byte)'s', (byte)'o', (byte)'m'); + + [Fact] + public async Task UploadCompletionDocument_AcceptsSixtyMegabyteVideo() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(60_000_000), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ContentType.Should().Be("video/mp4"); + document.SizeBytes.Should().Be(60_000_000L); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMovWithEmptyContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("video/quicktime"); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsIosTranscodedJpegLabelledHeic() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(4_096, 0xFF, 0xD8, 0xFF, 0xE0), "IMG_2044.jpg", "image/heic")); + + result.Should().BeOfType(); + (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(100_000_001), "site-clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsPhotoOverTenMegabytes() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(10_000_001, 0xFF, 0xD8, 0xFF, 0xE0), "photo.jpg", "image/jpeg")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_RejectsUnsupportedVideoContainer() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a video at all"u8.ToArray(), "clip.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index a29b332..32c70df 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests } [Fact] - public void AddMedia_HasTwoHundredMegabyteRequestLimit() + public void AddMedia_HasContractRequestLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.Single( @@ -39,24 +40,26 @@ public class WorkOrderMediaControllerTests candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); var bytes = Assert.Single(attribute.ConstructorArguments); - Assert.Equal(200_000_000L, bytes.Value); + Assert.Equal(110_000_000L, bytes.Value); } [Fact] - public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit() + public void AddMedia_HasContractMultipartBodyLimit() { var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); var attribute = Assert.IsType(Assert.Single( method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true))); - Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit); + Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit); } [Fact] - public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity() + public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity() { var file = new Mock(); - file.SetupGet(candidate => candidate.Length).Returns(200_000_001); + file.SetupGet(candidate => candidate.Length).Returns(100_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4"); + file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4"); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); @@ -65,10 +68,160 @@ public class WorkOrderMediaControllerTests var response = Assert.IsType(result); var error = Assert.IsType(response.Value); Assert.Equal("FileTooLarge", error.Code); - Assert.Equal("The uploaded file must not exceed 200 MB.", error.Message); + Assert.Equal("Videos must be 100 MB or smaller.", error.Message); storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(10_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); + file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + file.SetupGet(candidate => candidate.FileName).Returns("report.pdf"); + file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Documents must be 50 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + + private static FormFile VideoFormFile(int size, string fileName, string contentType) + { + var bytes = new byte[size]; + // ISO BMFF ftyp box so the media type rules accept the payload as MP4/MOV. + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'i'; + bytes[9] = (byte)'s'; + bytes[10] = (byte)'o'; + bytes[11] = (byte)'m'; + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static (Mock Service, Mock Storage) SetupSuccessfulAddMedia() + { + var service = new Mock(MockBehavior.Strict); + service + .Setup(candidate => candidate.EnsureCanMutateMediaAsync( + 1, It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(Task.CompletedTask); + service + .Setup(candidate => candidate.AddMediaAsync( + 1, null, "https://storage.test/stored", It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" }); + var storage = new Mock(MockBehavior.Strict); + storage + .Setup(candidate => candidate.SaveFileAsync(It.IsAny())) + .ReturnsAsync("https://storage.test/stored"); + return (service, storage); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "video/mp4"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var ok = Assert.IsType(result); + Assert.Equal(5, Assert.IsType(ok.Value).Id); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mov", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMovWithEmptyContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_SixtyMegabyteMp4WithOctetStreamContentType_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = VideoFormFile(60_000_000, "site-clip.mp4", "application/octet-stream"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + [Fact] + public async Task AddMedia_HeicPhoto_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var bytes = new byte[512]; + bytes[4] = (byte)'f'; + bytes[5] = (byte)'t'; + bytes[6] = (byte)'y'; + bytes[7] = (byte)'p'; + bytes[8] = (byte)'h'; + bytes[9] = (byte)'e'; + bytes[10] = (byte)'i'; + bytes[11] = (byte)'c'; + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "capture.heic") + { + Headers = new HeaderDictionary(), + ContentType = "image/heic" + }; + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + [Fact] public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 39dd90b..1e7e59c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -294,7 +295,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("dispatches/{id:int}/completion-documents")] [HttpPost("dispatches/{id:int}/documents")] - [RequestSizeLimit(10_000_000)] + [RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)] public async Task UploadCompletionDocument( int id, [FromForm] IFormFile file, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 17b3ba3..4f854ef 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderMediaController : Controller { - public const long MaxUploadBytes = 200_000_000; + public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes; private readonly IWorkOrderMediaService _workOrderMediaService; private readonly IFileStoragePort _fileStorage; @@ -88,11 +88,12 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - if (file.Length > MaxUploadBytes) + // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + var sizeMessage = WorkOrderMediaContract.ValidateSize( + file.ContentType, file.FileName, file.Length); + if (sizeMessage != null) { - throw new WorkOrderBoardValidationException( - "FileTooLarge", - "The uploaded file must not exceed 200 MB."); + throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); } WorkOrderMediaFileRules.EnsureAllowed(file, category); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index fd05a45..9ad9dbe 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -56,6 +56,18 @@ namespace SeaHaven.DataServices.Implementation public void TrackAttachment(WorkOrderAttachments attachment) => _context.workOrderAttachments.Add(attachment); + public async Task> ListActiveAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken) + { + var urls = await _context.workOrderAttachments + .AsNoTracking() + .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true && a.Attachments != null) + .Select(a => a.Attachments!) + .ToListAsync(cancellationToken); + return urls; + } + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 57cbbf3..30d0005 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -22,6 +22,11 @@ namespace SeaHaven.DataServices.Interfaces Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); + + /// Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts). + Task> ListActiveAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs new file mode 100644 index 0000000..243dcc7 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -0,0 +1,84 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), + /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work + /// order, across the dispatcher media modal, the completion-doc media tab and the vendor + /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only + /// checkable client-side (the server cannot probe it cheaply), so it is enforced in the + /// browser and documented here as part of the same contract. + /// + public static class WorkOrderMediaContract + { + public const long MaxPhotoBytes = 10_000_000; + public const long MaxVideoBytes = 100_000_000; + public const long MaxDocumentBytes = 50_000_000; + public const int MaxPhotosPerWorkOrder = 10; + public const int MaxVideosPerWorkOrder = 3; + public const int MaxVideoDurationSeconds = 90; + + /// + /// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits + /// above plus multipart overhead so an oversize file reaches the + /// per-kind check and gets its generic message instead of a framework 413. The EB nginx + /// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value. + /// + public const long MaxUploadRequestBytes = 110_000_000; + + public enum UploadKind + { + Photo, + Video, + Document, + Unknown + } + + public static UploadKind ResolveKind(string? contentType, string? fileName) + { + var type = (contentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(fileName ?? string.Empty); + + if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".mov", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Video; + + if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".heic", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Photo; + + if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) + || type.Equals("application/msword", StringComparison.OrdinalIgnoreCase) + || type.Equals( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + StringComparison.OrdinalIgnoreCase) + || extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) + return UploadKind.Document; + + return UploadKind.Unknown; + } + + /// Stable, generic per-kind size message; never echoes file metadata. + public static string? ValidateSize(long length, UploadKind kind) + { + return kind switch + { + UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.", + UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.", + UploadKind.Document when length > MaxDocumentBytes => + "Documents must be 50 MB or smaller.", + UploadKind.Unknown when length > MaxVideoBytes => + "Videos must be 100 MB or smaller.", + _ => null + }; + } + + public static string? ValidateSize(string? contentType, string? fileName, long length) + => ValidateSize(length, ResolveKind(contentType, fileName)); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index aa6439a..ee02d9b 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -12,6 +12,7 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/jpg", "image/png", + "image/heic", "video/mp4", "video/quicktime", "application/pdf", @@ -24,6 +25,7 @@ namespace SeaHaven.Services.Helpers { ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, + ["image/heic"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".heic" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, @@ -42,7 +44,11 @@ namespace SeaHaven.Services.Helpers // the accepted set of files. private static string? ResolveContentType(string declaredType, string extension) { - if (AllowedContentTypes.Contains(declaredType)) + // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic as + // its type, so a declared image/heic is only authoritative on a real .heic file. + var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); + if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) return declaredType; foreach (var (contentType, extensions) in ExtensionsByContentType) @@ -139,6 +145,11 @@ namespace SeaHaven.Services.Helpers && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; } + if (contentType.Equals("image/heic", StringComparison.OrdinalIgnoreCase)) + { + return IsHeifBrand(bytes); + } + if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; @@ -206,5 +217,20 @@ namespace SeaHaven.Services.Helpers && bytes[6] == (byte)'y' && bytes[7] == (byte)'p'; } + + private static bool IsHeifBrand(byte[] bytes) + { + if (!HasFtypBox(bytes)) + return false; + + // HEIC/HEIF containers identify by the ftyp major brand (bytes 8..11). + var brand = System.Text.Encoding.ASCII.GetString(bytes, 8, 4); + return brand switch + { + "heic" or "heix" or "hevc" or "hevx" or "heim" or "heis" or "hevm" or "hevs" + or "mif1" or "msf1" => true, + _ => false + }; + } } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 5fc5140..cbe8192 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -12,9 +13,36 @@ namespace SeaHaven.Services.Implementation { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { - "application/pdf", "image/jpeg", "image/jpg", "image/png" + "application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic", + "video/mp4", "video/quicktime" }; + // The browser's File.type is unreliable on mobile (empty or application/octet-stream), + // so an extension pairing against the same allowlist resolves the real content type. + private static string? ResolveUploadContentType(IFormFile file) + { + var declaredType = (file.ContentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic. + var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); + if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) + return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) + ? "image/jpeg" + : declaredType; + + return extension.ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".heic" => "image/heic", + ".mp4" => "video/mp4", + ".mov" => "video/quicktime", + _ => null + }; + } + private const int MaxRefusalReasonLength = 500; private readonly IVendorPortalTokenService _tokens; @@ -820,15 +848,30 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - if (file.Length > _documentOptions.MaxSizeBytes) + // SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB + // (MP4/MOV). Documents keep the configured VendorDocuments cap. + var contentType = ResolveUploadContentType(file); + if (contentType == null) { - throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); + throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } - var contentType = (file.ContentType ?? string.Empty).Trim(); - if (!AllowedContentTypes.Contains(contentType)) + var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName); + if (kind == WorkOrderMediaContract.UploadKind.Photo + && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { - throw new InvalidOperationException("Only PDF, JPG, and PNG completion documents are accepted."); + throw new InvalidOperationException("Photos must be 10 MB or smaller."); + } + if (kind == WorkOrderMediaContract.UploadKind.Video + && file.Length > WorkOrderMediaContract.MaxVideoBytes) + { + throw new InvalidOperationException("Videos must be 100 MB or smaller."); + } + if (kind != WorkOrderMediaContract.UploadKind.Photo + && kind != WorkOrderMediaContract.UploadKind.Video + && file.Length > _documentOptions.MaxSizeBytes) + { + throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); } var resolvedPurpose = string.IsNullOrWhiteSpace(purpose) @@ -851,7 +894,7 @@ namespace SeaHaven.Services.Implementation await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); - if (!MatchesSignature(contentType, bytes)) + if (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes)) { throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } @@ -984,35 +1027,6 @@ namespace SeaHaven.Services.Implementation }; } - private static bool MatchesSignature(string contentType, byte[] bytes) - { - if (bytes.Length == 0) - { - return false; - } - - if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 4 - && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF - } - - 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) - || contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) - { - return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; - } - - return false; - } - private static string GetSafeExtension(string fileName) { var extension = Path.GetExtension(fileName); diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index 0b0e38c..dccbffd 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -153,6 +153,8 @@ namespace SeaHaven.Services.Implementation }; } + await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken); + var attachment = new WorkOrderAttachments { WorkorderId = workOrderId, @@ -348,6 +350,42 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } + /// + /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the + /// stored attachment rows. Legacy Before/After column slots replace in place and are + /// not counted. Documents have no per-work-order count limit. + /// + private async Task EnsureWithinMediaCountsAsync( + int workOrderId, + string fileUrl, + CancellationToken cancellationToken) + { + var kind = WorkOrderMediaContract.ResolveKind(null, fileUrl); + if (kind != WorkOrderMediaContract.UploadKind.Photo + && kind != WorkOrderMediaContract.UploadKind.Video) + return; + + var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); + var sameKindCount = urls.Count(url => + WorkOrderMediaContract.ResolveKind(null, url) == kind); + + if (kind == WorkOrderMediaContract.UploadKind.Photo + && sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder) + { + throw new WorkOrderBoardValidationException( + "MediaCountExceeded", + "A work order can have at most 10 photos."); + } + + if (kind == WorkOrderMediaContract.UploadKind.Video + && sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder) + { + throw new WorkOrderBoardValidationException( + "MediaCountExceeded", + "A work order can have at most 3 videos."); + } + } + /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index cd24883..d5771a9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1309,6 +1309,289 @@ public class WorkOrderMediaServiceTests Assert.Equal(WorkOrderMediaCategory.Extra, media.Category); } + [Fact] + public async Task AddMedia_EleventhPhoto_ThrowsMediaCountExceeded() + { + 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 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/photo-10.jpg", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 10 photos.", ex.Message); + Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); + } + + [Fact] + public async Task AddMedia_FourthVideo_CountsStoredPhoneFileUrls() + { + // Same URL shape FileStorageAdapter.SaveFileAsync returns for an iPhone upload. + static string StoredUrl(string name) => + $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}"; + + 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 } + }); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.mov", "clip.MP4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = StoredUrl(name), + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + StoredUrl("IMG_0004.MOV"), + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + } + + [Fact] + public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + AccountId = 20, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/photo-10.jpg", + AuthenticatedUser(accountId: 10), + "actor-1")); + + // Another account must not learn the work order exists or how full it is. + Assert.Equal("NotFound", ex.Code); + Assert.Equal(10, context.workOrderAttachments.Count(a => a.IsDeleted != true)); + } + + [Fact] + public async Task AddMedia_TenthPhoto_Succeeds() + { + 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 } + }); + for (var i = 0; i < 9; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo-9.jpg", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_FourthVideo_ThrowsMediaCountExceeded() + { + 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.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-a.mp4", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-b.mov", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-c.mp4", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://example.com/clip-d.mov", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + } + + [Fact] + public async Task AddMedia_ThirdVideo_Succeeds() + { + 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.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-a.mp4", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/clip-b.mov", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/clip-c.mp4", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_DeletedPhoto_DoesNotConsumePhotoCount() + { + 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 } + }); + for (var i = 0; i < 10; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra, + IsDeleted = i == 0 + }); + } + await context.SaveChangesAsync(); + + var media = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo-new.jpg", + AuthenticatedUser(), + "actor-1"); + + Assert.True(media.Id > 0); + } + + [Fact] + public async Task AddMedia_DocumentsDoNotConsumePhotoOrVideoCounts() + { + 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 } + }); + for (var i = 0; i < 5; i++) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://example.com/report-{i}.pdf", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var photo = await service.AddMediaAsync( + 1, + null, + "https://example.com/photo.jpg", + AuthenticatedUser(), + "actor-1"); + var video = await service.AddMediaAsync( + 1, + null, + "https://example.com/clip.mp4", + AuthenticatedUser(), + "actor-1"); + + Assert.True(photo.Id > 0); + Assert.True(video.Id > 0); + } + [Fact] public async Task DeleteMedia_Technician_ThrowsForbidden() { diff --git a/scripts/check_app_terraform_isolation.py b/scripts/check_app_terraform_isolation.py index 9f87212..11db9a8 100644 --- a/scripts/check_app_terraform_isolation.py +++ b/scripts/check_app_terraform_isolation.py @@ -26,7 +26,7 @@ def is_app_path(path: str) -> bool: normalized = path.replace("\\", "/") if normalized in APP_SCRIPT_NAMES: return True - if normalized.startswith(".ebextensions/"): + if normalized.startswith((".ebextensions/", ".platform/")): return True return normalized.endswith(APP_SUFFIXES) diff --git a/scripts/package-elastic-beanstalk.sh b/scripts/package-elastic-beanstalk.sh index bef20cf..5d42d50 100755 --- a/scripts/package-elastic-beanstalk.sh +++ b/scripts/package-elastic-beanstalk.sh @@ -8,6 +8,7 @@ # ./ published Api.SeaHavenIndustries (self-contained, linux-x64) # ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x) # ./.ebextensions/* leader-only migration container command +# ./.platform/nginx/conf.d/* nginx proxy overrides (upload body size) # # The bundle reads ConnectionStrings__DefaultConnection from the runtime # environment (Elastic Beanstalk property). No connection string or secret is @@ -137,6 +138,10 @@ log "copy .ebextensions into bundle root" mkdir -p "$STAGING_DIR/.ebextensions" cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/" +log "copy .platform (nginx proxy overrides) into bundle root" +mkdir -p "$STAGING_DIR/.platform" +cp -R .platform/. "$STAGING_DIR/.platform/" + log "verify no committed secret placeholders survived publish" if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then die "potential secret detected in publish output; refusing to package." diff --git a/scripts/test_check_app_terraform_isolation.py b/scripts/test_check_app_terraform_isolation.py index 94899a6..b13e0e7 100644 --- a/scripts/test_check_app_terraform_isolation.py +++ b/scripts/test_check_app_terraform_isolation.py @@ -54,6 +54,17 @@ class IsolationTests(unittest.TestCase): self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"]) self.assertTrue(any(path.endswith(".cs") for path in app_files)) + def test_platform_proxy_config_is_app_side(self) -> None: + violation = isolation_violation( + [ + "terraform/live/dev/main.tf", + ".platform/nginx/conf.d/01_upload_body_size.conf", + ] + ) + self.assertIsNotNone(violation) + _, app_files = violation or ([], []) + self.assertEqual(app_files, [".platform/nginx/conf.d/01_upload_body_size.conf"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/validate-elastic-beanstalk-bundle.sh b/scripts/validate-elastic-beanstalk-bundle.sh index 139eb6b..1d88367 100755 --- a/scripts/validate-elastic-beanstalk-bundle.sh +++ b/scripts/validate-elastic-beanstalk-bundle.sh @@ -15,13 +15,15 @@ die() { contents_file="$(mktemp)" webhook_file="$(mktemp)" -trap 'rm -f "$contents_file" "$webhook_file"' EXIT +nginx_file="$(mktemp)" +trap 'rm -f "$contents_file" "$webhook_file" "$nginx_file"' EXIT unzip -tq "$BUNDLE" unzip -Z1 "$BUNDLE" > "$contents_file" grep -Fxq "efbundle" "$contents_file" grep -Fxq ".ebextensions/01_migrations.config" "$contents_file" grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file" +grep -Fxq ".platform/nginx/conf.d/01_upload_body_size.conf" "$contents_file" unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file" grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file" @@ -30,5 +32,9 @@ grep -Fxq \ ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ "$webhook_file" +# Without this override the platform nginx caps request bodies at 1 MB (SH-383). +unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file" +grep -Fxq 'client_max_body_size 120M;' "$nginx_file" + printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \ "$(wc -c < "$BUNDLE" | tr -d ' ')" From 07bc81cc5270a963edc313bf31e92cdbffd8d91c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:18:00 -0300 Subject: [PATCH 2/7] fix(media): size uploads by the validated content type A misleading file name could move a file into a larger size class: a real PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal, and a .jpg declared with a foreign video type got it on the media endpoint. Classify by the same resolved type the signature check validates; the extension only decides when no allowlisted type is known. --- .../VendorPortalDocumentTests.cs | 22 +++++++ .../WorkOrderMediaControllerTests.cs | 21 ++++++ .../Controllers/WorkOrderMediaController.cs | 3 +- .../Helpers/WorkOrderMediaContract.cs | 65 ++++++++++++------- .../Helpers/WorkOrderMediaFileRules.cs | 19 ++++-- .../Implementation/VendorPortalService.cs | 3 +- 6 files changed, 103 insertions(+), 30 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 83d9d1d..4a6ac0d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -431,6 +431,28 @@ public sealed class VendorPortalDocumentTests : IDisposable (await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg"); } + [Theory] + // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must + // follow the validated type, not the name, or the document/photo caps are bypassed. + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })] + [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] + public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( + string fileName, + string contentType, + int size, + byte[] header) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(MediaBytes(size, header), fileName, contentType)); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 32c70df..c990b2e 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -91,6 +91,27 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } + [Fact] + public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto() + { + // A .jpg with a foreign video type resolves to image/jpeg for validation, so it must + // also be sized as a photo, not given the 100 MB video allowance. + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(60_000_000); + file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg"); + file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp"); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("Photos must be 10 MB or smaller.", error.Message); + storage.VerifyNoOtherCalls(); + } + [Fact] public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 4f854ef..b19efb9 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -89,8 +89,9 @@ namespace Api.SeaHavenIndustries.Controllers try { // SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB). + // Classify by the same resolved type EnsureAllowed validates against. var sizeMessage = WorkOrderMediaContract.ValidateSize( - file.ContentType, file.FileName, file.Length); + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length); if (sizeMessage != null) { throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 243dcc7..f36fcf9 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -33,34 +33,51 @@ namespace SeaHaven.Services.Helpers Unknown } + private static readonly Dictionary KindByContentType = + new(StringComparer.OrdinalIgnoreCase) + { + ["image/jpeg"] = UploadKind.Photo, + ["image/jpg"] = UploadKind.Photo, + ["image/png"] = UploadKind.Photo, + ["image/heic"] = UploadKind.Photo, + ["video/mp4"] = UploadKind.Video, + ["video/quicktime"] = UploadKind.Video, + ["application/pdf"] = UploadKind.Document, + ["application/msword"] = UploadKind.Document, + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = + UploadKind.Document, + }; + + private static readonly Dictionary KindByExtension = + new(StringComparer.OrdinalIgnoreCase) + { + [".jpg"] = UploadKind.Photo, + [".jpeg"] = UploadKind.Photo, + [".png"] = UploadKind.Photo, + [".heic"] = UploadKind.Photo, + [".mp4"] = UploadKind.Video, + [".mov"] = UploadKind.Video, + [".pdf"] = UploadKind.Document, + [".doc"] = UploadKind.Document, + [".docx"] = UploadKind.Document, + }; + + /// + /// Classifies an upload. Pass the validated (resolved) content type: it decides whenever + /// it is an allowlisted type, so a misleading file name cannot move a file into a larger + /// size class. The extension only decides when no allowlisted type is known (for example + /// counting stored attachment URLs). + /// public static UploadKind ResolveKind(string? contentType, string? fileName) { var type = (contentType ?? string.Empty).Trim(); + if (KindByContentType.TryGetValue(type, out var byType)) + return byType; + var extension = Path.GetExtension(fileName ?? string.Empty); - - if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".mov", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Video; - - if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".heic", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Photo; - - if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) - || type.Equals("application/msword", StringComparison.OrdinalIgnoreCase) - || type.Equals( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - StringComparison.OrdinalIgnoreCase) - || extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".doc", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".docx", StringComparison.OrdinalIgnoreCase)) - return UploadKind.Document; - - return UploadKind.Unknown; + return KindByExtension.TryGetValue(extension, out var byExtension) + ? byExtension + : UploadKind.Unknown; } /// Stable, generic per-kind size message; never echoes file metadata. diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index ee02d9b..c438759 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -67,6 +67,19 @@ namespace SeaHaven.Services.Helpers return contentType; } + /// + /// The canonical content type validates the file as, or null when + /// no allowlisted type applies. Size classification must use this same type so a declared + /// type or a misleading extension cannot move a file into a larger size class. + /// + public static string? ResolveUploadContentType(IFormFile file) + { + var declaredType = (file.ContentType ?? string.Empty).Trim(); + var extension = Path.GetExtension(file.FileName ?? string.Empty); + var resolvedType = ResolveContentType(declaredType, extension); + return resolvedType == null ? null : CanonicalContentType(resolvedType); + } + public static bool IsAllowed( IFormFile file, WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) @@ -74,13 +87,11 @@ namespace SeaHaven.Services.Helpers if (file == null || file.Length <= 0) return false; - var declaredType = (file.ContentType ?? string.Empty).Trim(); var extension = Path.GetExtension(file.FileName ?? string.Empty); - var resolvedType = ResolveContentType(declaredType, extension); - if (resolvedType == null) + var contentType = ResolveUploadContentType(file); + if (contentType == null) return false; - var contentType = CanonicalContentType(resolvedType); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; if (IsDocument(contentType) && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index cbe8192..0893fbb 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -856,7 +856,8 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } - var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName); + // Classify by the resolved type the signature check validates, never by the file name. + var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null); if (kind == WorkOrderMediaContract.UploadKind.Photo && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { From 16845a55f3ff7ddc04aab4ab3ba23d06a743f184 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:18:49 -0300 Subject: [PATCH 3/7] test(vendor-portal): use a valid PDF signature in the size-class regression --- Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 4a6ac0d..132f538 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -434,7 +434,7 @@ public sealed class VendorPortalDocumentTests : IDisposable [Theory] // Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must // follow the validated type, not the name, or the document/photo caps are bypassed. - [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })] + [InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D })] [InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })] public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap( string fileName, From e15de6e90bde6d1e02dd87fbace6c0376f7d43ac Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:27:01 -0300 Subject: [PATCH 4/7] fix(media): enforce the per-work-order photo/video limit across both surfaces The 10-photo / 3-video limit only counted dispatcher attachments, so vendor portal uploads could push a work order past it (and vice versa). Count the work order's current vendor documents alongside its attachments on both the dispatcher media endpoint and the vendor portal. A new completion version does not count the version it replaces. --- .../VendorPortalDocumentTests.cs | 56 +++++++++++++++++++ .../VendorDocumentDataService.cs | 33 +++++++++++ .../WorkOrderMediaDataService.cs | 18 ++++++ .../Interfaces/IVendorDocumentDataService.cs | 13 +++++ .../Interfaces/IWorkOrderMediaDataService.cs | 8 +++ .../Helpers/WorkOrderMediaContract.cs | 23 ++++++++ .../Implementation/VendorPortalService.cs | 20 +++++++ .../Implementation/WorkOrderMediaService.cs | 27 +++------ .../WorkOrderPhase6Tests.cs | 51 +++++++++++++++++ 9 files changed, 229 insertions(+), 20 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 132f538..2ef7eca 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -453,6 +453,62 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().BeEmpty(); } + [Fact] + public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "clip.mp4" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip.MOV", "video/quicktime")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_NewVersionDoesNotCountTheVideoItReplaces() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(FtypVideoBytes(2_048), "site-clip-v2.mp4", "video/mp4")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(2); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs index a952f45..d2a6890 100644 --- a/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDocumentDataService.cs @@ -17,6 +17,39 @@ namespace SeaHaven.DataServices.Implementation // supporting evidence never participates in completion versioning or replacement. private const string CompletionPurpose = "Completion"; + public async Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken) + { + // Uplift evidence also records the latest completion id in ReplacesDocumentId, so only + // a newer completion version supersedes a document. + return await _context.VendorCompletionDocuments + .AsNoTracking() + .Where(document => document.WorkOrderId == workOrderId + && (document.IsDeleted == null || document.IsDeleted == false) + && (excludingDocumentId == null || document.Id != excludingDocumentId) + && !_context.VendorCompletionDocuments.Any(newer => + newer.ReplacesDocumentId == document.Id + && newer.Purpose == CompletionPurpose + && (newer.IsDeleted == null || newer.IsDeleted == false))) + .Select(document => document.ContentType) + .ToListAsync(cancellationToken); + } + + public async Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken) + { + return await _context.workOrderAttachments + .AsNoTracking() + .Where(attachment => attachment.WorkorderId == workOrderId + && attachment.IsDeleted != true + && attachment.Attachments != null) + .Select(attachment => attachment.Attachments!) + .ToListAsync(cancellationToken); + } + public Task GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken) { return _context.VendorCompletionDocuments diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index 9ad9dbe..2d226e6 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -68,6 +68,24 @@ namespace SeaHaven.DataServices.Implementation return urls; } + public async Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken) + { + // Uplift evidence also records the latest completion id in ReplacesDocumentId, so only + // a newer completion version supersedes a document. + return await _context.VendorCompletionDocuments + .AsNoTracking() + .Where(document => document.WorkOrderId == workOrderId + && (document.IsDeleted == null || document.IsDeleted == false) + && !_context.VendorCompletionDocuments.Any(newer => + newer.ReplacesDocumentId == document.Id + && newer.Purpose == "Completion" + && (newer.IsDeleted == null || newer.IsDeleted == false))) + .Select(document => document.ContentType) + .ToListAsync(cancellationToken); + } + public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; diff --git a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs index b56948c..6986ef2 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDocumentDataService.cs @@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces Task GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); Task> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken); Task GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken); + /// + /// Content types of the work order's current vendor documents (not deleted, not replaced by a + /// newer completion version), optionally excluding one being replaced. SH-116 photo/video counts. + /// + Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken); + + /// Stored URLs of the work order's non-deleted dispatcher attachments (SH-116 counts). + Task> ListActiveWorkOrderAttachmentUrlsAsync( + int workOrderId, + CancellationToken cancellationToken); Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index 30d0005..caf6071 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -27,6 +27,14 @@ namespace SeaHaven.DataServices.Interfaces Task> ListActiveAttachmentUrlsAsync( int workOrderId, CancellationToken cancellationToken); + + /// + /// Content types of the work order's current vendor-portal documents (not deleted, not + /// replaced by a newer completion version). SH-116 counts span both upload surfaces. + /// + Task> ListActiveVendorMediaContentTypesAsync( + int workOrderId, + CancellationToken cancellationToken); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void MarkWorkOrderModified(WorkOrder workOrder); Task SaveAsync(CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index f36fcf9..5ba60e3 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -80,6 +80,29 @@ namespace SeaHaven.Services.Helpers : UploadKind.Unknown; } + /// + /// Per-work-order count check across both upload surfaces: dispatcher attachments + /// (classified by stored URL) and vendor-portal documents (classified by validated + /// content type). Returns the stable rejection message, or null when there is room. + /// + public static string? ValidateCount( + UploadKind kind, + IEnumerable attachmentUrls, + IEnumerable vendorContentTypes) + { + if (kind != UploadKind.Photo && kind != UploadKind.Video) + return null; + + var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind) + + vendorContentTypes.Count(type => ResolveKind(type, null) == kind); + + if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder) + return "A work order can have at most 10 photos."; + if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder) + return "A work order can have at most 3 videos."; + return null; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0893fbb..0aa4353 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -926,6 +926,26 @@ namespace SeaHaven.Services.Implementation "The completion document could not be replaced."); } } + + // SH-116: the 10-photo / 3-video limit is per work order across the dispatcher and + // vendor surfaces. A new completion version replaces its predecessor, so that one + // does not count against the upload that supersedes it. + if (dispatch.WorkOrderId is int workOrderId) + { + var excluded = resolvedPurpose == VendorDocumentPurpose.Completion + ? replacedDocument?.Id + : null; + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, excluded, cancellationToken); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); + if (countMessage != null) + { + throw new InvalidOperationException(countMessage); + } + } + var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index dccbffd..10ad8ff 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -352,8 +352,9 @@ namespace SeaHaven.Services.Implementation /// /// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the - /// stored attachment rows. Legacy Before/After column slots replace in place and are - /// not counted. Documents have no per-work-order count limit. + /// stored attachment rows plus the work order's current vendor-portal documents. Legacy + /// Before/After column slots replace in place and are not counted. Documents have no + /// per-work-order count limit. /// private async Task EnsureWithinMediaCountsAsync( int workOrderId, @@ -366,24 +367,10 @@ namespace SeaHaven.Services.Implementation return; var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); - var sameKindCount = urls.Count(url => - WorkOrderMediaContract.ResolveKind(null, url) == kind); - - if (kind == WorkOrderMediaContract.UploadKind.Photo - && sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder) - { - throw new WorkOrderBoardValidationException( - "MediaCountExceeded", - "A work order can have at most 10 photos."); - } - - if (kind == WorkOrderMediaContract.UploadKind.Video - && sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder) - { - throw new WorkOrderBoardValidationException( - "MediaCountExceeded", - "A work order can have at most 3 videos."); - } + var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes); + if (countMessage != null) + throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage); } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index d5771a9..b1fe06f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1380,6 +1380,57 @@ public class WorkOrderMediaServiceTests Assert.Equal("A work order can have at most 3 videos.", ex.Message); } + [Fact] + public async Task AddMedia_FourthVideo_CountsVendorPortalVideos() + { + 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 } + }); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + Category = WorkOrderMediaCategory.Extra + }); + } + // Vendor v1 video was replaced by v2 (also a video): only v2 is current. + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, + ContentType = "video/mp4", Purpose = "Completion" + }); + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, + ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 + }); + // Another work order's vendor video never counts here. + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, + ContentType = "video/mp4", Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync( + 1, + null, + "https://api.example.com/Assets/Documents/x_IMG_0004.MOV", + AuthenticatedUser(), + "actor-1")); + + Assert.Equal("MediaCountExceeded", ex.Code); + Assert.Equal("A work order can have at most 3 videos.", ex.Message); + Assert.Equal(2, context.workOrderAttachments.Count()); + } + [Fact] public async Task AddMedia_CrossAccount_FullWorkOrder_ThrowsNotFoundNotCount() { From a8414cd4fa258fe608691ce35cacc96001275ccc Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:29:51 -0300 Subject: [PATCH 5/7] style(tests): format vendor quota test initializers --- .../WorkOrderPhase6Tests.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index b1fe06f..3c299ba 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1402,19 +1402,35 @@ public class WorkOrderMediaServiceTests // Vendor v1 video was replaced by v2 (also a video): only v2 is current. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 50, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 1, - ContentType = "video/mp4", Purpose = "Completion" + Id = 50, + WorkOrderId = 1, + DispatchId = 7, + VendorId = 3, + Version = 1, + ContentType = "video/mp4", + Purpose = "Completion" }); context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 51, WorkOrderId = 1, DispatchId = 7, VendorId = 3, Version = 2, - ContentType = "video/quicktime", Purpose = "Completion", ReplacesDocumentId = 50 + Id = 51, + WorkOrderId = 1, + DispatchId = 7, + VendorId = 3, + Version = 2, + ContentType = "video/quicktime", + Purpose = "Completion", + ReplacesDocumentId = 50 }); // Another work order's vendor video never counts here. context.VendorCompletionDocuments.Add(new VendorCompletionDocument { - Id = 60, WorkOrderId = 2, DispatchId = 8, VendorId = 3, Version = 1, - ContentType = "video/mp4", Purpose = "Completion" + Id = 60, + WorkOrderId = 2, + DispatchId = 8, + VendorId = 3, + Version = 1, + ContentType = "video/mp4", + Purpose = "Completion" }); await context.SaveChangesAsync(); From fd59a3da1353988f52e1376486abea89db01de7d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:38:00 -0300 Subject: [PATCH 6/7] fix(media): enforce the 90-second video limit on the server Read the duration from the MP4/MOV movie header (moov/mvhd) on both the dispatcher media endpoint and the vendor portal completion upload, so a direct request cannot bypass the browser check. Unreadable metadata still never blocks an upload. --- .../VendorPortalDocumentTests.cs | 44 ++++++ .../WorkOrderMediaControllerTests.cs | 54 ++++++++ .../Controllers/WorkOrderMediaController.cs | 9 ++ .../Helpers/VideoDurationProbe.cs | 129 ++++++++++++++++++ .../Helpers/WorkOrderMediaContract.cs | 18 ++- .../Implementation/VendorPortalService.cs | 10 ++ .../VideoDurationProbeTests.cs | 112 +++++++++++++++ 7 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 SeaHaven.Services/Helpers/VideoDurationProbe.cs create mode 100644 SeaHavenIndustries.Tests/VideoDurationProbeTests.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 2ef7eca..1258703 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -453,6 +453,50 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().BeEmpty(); } + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static byte[] PhoneVideoBytes(uint durationSeconds) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600); + return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + } + + [Theory] + [InlineData(95u, false)] + [InlineData(89u, true)] + public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted) + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime")); + + if (accepted) + { + result.Should().BeOfType(); + } + else + { + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + } + [Fact] public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads() { diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index c990b2e..8234c92 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -182,6 +182,60 @@ public class WorkOrderMediaControllerTests Assert.Equal(5, Assert.IsType(ok.Value).Id); } + [Fact] + public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity() + { + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime"); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("VideoTooLong", error.Code); + Assert.Equal("Videos must be 90 seconds or shorter.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted() + { + var (service, storage) = SetupSuccessfulAddMedia(); + var controller = CreateController(service, storage); + var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", ""); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + Assert.IsType(result); + } + + /// ftyp + mdat + moov/mvhd (moov last, as phones write it). + private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType) + { + static byte[] Box(string type, byte[] body) + { + var box = new byte[8 + body.Length]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + var mvhd = new byte[20]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000); + var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0")) + .Concat(Box("mdat", new byte[4096])) + .Concat(Box("moov", Box("mvhd", mvhd))) + .ToArray(); + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + [Fact] public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted() { diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b19efb9..e795f3c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -98,6 +98,15 @@ namespace Api.SeaHavenIndustries.Controllers } WorkOrderMediaFileRules.EnsureAllowed(file, category); + if (WorkOrderMediaContract.ResolveKind( + WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName) + == WorkOrderMediaContract.UploadKind.Video) + { + using var content = file.OpenReadStream(); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage); + } var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); diff --git a/SeaHaven.Services/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs new file mode 100644 index 0000000..8355c46 --- /dev/null +++ b/SeaHaven.Services/Helpers/VideoDurationProbe.cs @@ -0,0 +1,129 @@ +using System.Buffers.Binary; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header: + /// top-level moov box → mvhd timescale and duration. It seeks over box + /// headers only (the moov box may sit after a large mdat), never decodes + /// media and never allocates more than a few bytes. Returns null whenever the structure + /// cannot be read, so callers can let unreadable files through (SH-116: unreadable + /// metadata never blocks an upload). + /// + public static class VideoDurationProbe + { + private const int MaxBoxesPerLevel = 1024; + + public static double? TryReadDurationSeconds(Stream? stream) + { + if (stream == null || !stream.CanSeek || !stream.CanRead) + return null; + + try + { + var moov = FindBox(stream, 0, stream.Length, "moov"); + if (moov == null) + return null; + + var mvhd = FindBox(stream, moov.Value.BodyStart, moov.Value.End, "mvhd"); + return mvhd == null ? null : ReadMovieHeaderSeconds(stream, mvhd.Value); + } + catch (IOException) + { + return null; + } + } + + private readonly record struct Box(long BodyStart, long End); + + private static Box? FindBox(Stream stream, long start, long end, string type) + { + var header = new byte[8]; + var position = start; + for (var i = 0; i < MaxBoxesPerLevel && position + 8 <= end; i++) + { + stream.Position = position; + if (!ReadExactly(stream, header, 8)) + return null; + + long size = BinaryPrimitives.ReadUInt32BigEndian(header); + var boxType = Encoding.ASCII.GetString(header, 4, 4); + var headerLength = 8; + if (size == 1) + { + // 64-bit "largesize" follows the type. + var large = new byte[8]; + if (!ReadExactly(stream, large, 8)) + return null; + var largeSize = BinaryPrimitives.ReadUInt64BigEndian(large); + if (largeSize > long.MaxValue) + return null; + size = (long)largeSize; + headerLength = 16; + } + else if (size == 0) + { + size = end - position; + } + + if (size < headerLength || position + size > end) + return null; + + if (boxType == type) + return new Box(position + headerLength, position + size); + + position += size; + } + + return null; + } + + private static double? ReadMovieHeaderSeconds(Stream stream, Box mvhd) + { + // version(1) flags(3), then v0: creation(4) modification(4) timescale(4) duration(4) + // v1: creation(8) modification(8) timescale(4) duration(8) + var body = new byte[32]; + stream.Position = mvhd.BodyStart; + var available = (int)Math.Min(body.Length, mvhd.End - mvhd.BodyStart); + if (available < 20 || !ReadExactly(stream, body, available)) + return null; + + uint timescale; + ulong duration; + if (body[0] == 0) + { + timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(12, 4)); + duration = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4)); + } + else if (body[0] == 1 && available >= 32) + { + timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(20, 4)); + duration = BinaryPrimitives.ReadUInt64BigEndian(body.AsSpan(24, 8)); + } + else + { + return null; + } + + // All-ones duration means "unknown" in the spec. + if (timescale == 0 || duration == uint.MaxValue || duration == ulong.MaxValue) + return null; + + return (double)duration / timescale; + } + + private static bool ReadExactly(Stream stream, byte[] buffer, int count) + { + var read = 0; + while (read < count) + { + var n = stream.Read(buffer, read, count - read); + if (n <= 0) + return false; + read += n; + } + return true; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index 5ba60e3..a0bfa56 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -4,9 +4,9 @@ namespace SeaHaven.Services.Helpers /// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work /// order, across the dispatcher media modal, the completion-doc media tab and the vendor - /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only - /// checkable client-side (the server cannot probe it cheaply), so it is enforced in the - /// browser and documented here as part of the same contract. + /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read + /// from the MP4/MOV movie header (); the browser pre-checks + /// it and the server enforces it, and unreadable metadata never blocks an upload. /// public static class WorkOrderMediaContract { @@ -103,6 +103,18 @@ namespace SeaHaven.Services.Helpers return null; } + /// + /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose + /// duration cannot be read is not blocked. Returns the stable message or null. + /// + public static string? ValidateVideoDuration(Stream? content) + { + var seconds = VideoDurationProbe.TryReadDurationSeconds(content); + return seconds > MaxVideoDurationSeconds + ? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter." + : null; + } + /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 0aa4353..c1307dc 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -900,6 +900,16 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } + if (kind == WorkOrderMediaContract.UploadKind.Video) + { + using var content = new MemoryStream(bytes, writable: false); + var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); + if (durationMessage != null) + { + throw new InvalidOperationException(durationMessage); + } + } + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch == null) { diff --git a/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs new file mode 100644 index 0000000..df5913d --- /dev/null +++ b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs @@ -0,0 +1,112 @@ +using System.Buffers.Binary; +using System.Text; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class VideoDurationProbeTests +{ + internal static byte[] Box(string type, params byte[][] children) + { + var body = children.SelectMany(child => child).ToArray(); + var box = new byte[8 + body.Length]; + BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length); + Encoding.ASCII.GetBytes(type).CopyTo(box, 4); + body.CopyTo(box, 8); + return box; + } + + internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false) + { + var body = new byte[version1 ? 32 : 20]; + body[0] = version1 ? (byte)1 : (byte)0; + if (version1) + { + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale); + BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration); + } + else + { + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale); + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration); + } + return Box("mvhd", body); + } + + /// A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). + internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096) + { + var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]); + var mdat = Box("mdat", new byte[mediaBytes]); + var moov = Box("moov", MovieHeader(timescale, duration, version1)); + return ftyp.Concat(mdat).Concat(moov).ToArray(); + } + + [Fact] + public void ReadsDurationFromMoovAfterMediaData() + { + Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000)))); + } + + [Fact] + public void ReadsVersionOneMovieHeader() + { + Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds( + new MemoryStream(Mp4(1000, 30_500, version1: true)))); + } + + [Fact] + public void FollowsSixtyFourBitBoxSizes() + { + var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]); + var mdatBody = new byte[512]; + var mdat = new byte[16 + mdatBody.Length]; + BinaryPrimitives.WriteUInt32BigEndian(mdat, 1); + Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4); + BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length); + var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120)); + var file = ftyp.Concat(mdat).Concat(moov).ToArray(); + + Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file))); + } + + [Theory] + [InlineData("no-moov")] + [InlineData("truncated")] + [InlineData("zero-timescale")] + [InlineData("oversized-box")] + public void UnreadableStructureReturnsNull(string shape) + { + var bytes = shape switch + { + "no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(), + "truncated" => Mp4(600, 57_000)[..^10], + "zero-timescale" => Mp4(0, 57_000), + _ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(), + }; + + Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes))); + } + + [Fact] + public void NonSeekableStreamReturnsNull() + { + Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000)))); + } + + [Theory] + [InlineData(90.0, true)] + [InlineData(90.5, false)] + public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed) + { + var message = WorkOrderMediaContract.ValidateVideoDuration( + new MemoryStream(Mp4(1000, (ulong)(seconds * 1000)))); + + Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message); + } + + private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes) + { + public override bool CanSeek => false; + } +} From eecc2a61bd95284ccc6afd3f77176e0905639e6c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:24:07 -0300 Subject: [PATCH 7/7] feat(vendor-portal): report work-order media counts on the dispatch detail Adds MediaCounts (limits, current photos/videos, and the counts a new completion version would see) so the portal can refuse an 11th photo or 4th video before uploading it. Uses the same count and replacement rule the upload check enforces. --- .../VendorPortalDocumentTests.cs | 40 +++++++++++++++++++ .../DTOs/VendorPortalServiceDTOs.cs | 20 ++++++++++ .../Helpers/WorkOrderMediaContract.cs | 21 ++++++++-- .../Implementation/VendorPortalService.cs | 29 ++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index 1258703..2eefb5c 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -553,6 +553,46 @@ public sealed class VendorPortalDocumentTests : IDisposable context.VendorCompletionDocuments.Should().HaveCount(2); } + [Fact] + public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck() + { + using var context = NewContext(); + var (vendor, dispatch) = await SeedDispatch(context); + foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" }) + { + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = dispatch.WorkOrderId!.Value, + Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}", + }); + } + context.VendorCompletionDocuments.Add(new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = dispatch.Id, + WorkOrderId = dispatch.WorkOrderId!.Value, + ContentType = "video/mp4", + StoredFileName = "v1.mp4", + Version = 1, + Purpose = "Completion" + }); + await context.SaveChangesAsync(); + + var service = NewService(context); + var session = await service.ResolveSessionAsync(Token, CancellationToken.None); + var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None); + + detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO + { + MaxPhotos = 10, + MaxVideos = 3, + Photos = 1, + Videos = 3, + CompletionPhotos = 1, + CompletionVideos = 2, + }); + } + [Fact] public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes() { diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index afdaf18..ace32c8 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -134,6 +134,26 @@ namespace SeaHaven.Services.DTOs public IEnumerable Comments { get; set; } = Enumerable.Empty(); public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); public IEnumerable Documents { get; set; } = Enumerable.Empty(); + public PortalMediaCountsDTO? MediaCounts { get; set; } + } + + /// + /// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload + /// before sending it. The server still enforces the limit on upload. + /// + public class PortalMediaCountsDTO + { + public int MaxPhotos { get; set; } + public int MaxVideos { get; set; } + /// Photos/videos on the work order, counted for evidence uploads. + public int Photos { get; set; } + public int Videos { get; set; } + /// + /// Photos/videos counted for a new completion version, which replaces the dispatch's + /// latest document and so does not count it. + /// + public int CompletionPhotos { get; set; } + public int CompletionVideos { get; set; } } public class VendorPortalDocumentDTO diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs index a0bfa56..34f8862 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -93,16 +93,29 @@ namespace SeaHaven.Services.Helpers if (kind != UploadKind.Photo && kind != UploadKind.Video) return null; - var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind) - + vendorContentTypes.Count(type => ResolveKind(type, null) == kind); + var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes); - if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder) + if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder) return "A work order can have at most 10 photos."; - if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder) + if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder) return "A work order can have at most 3 videos."; return null; } + /// + /// Photos and videos on a work order: dispatcher attachments (kind from the stored URL) + /// plus current vendor-portal documents (kind from the validated content type). + /// + public static (int Photos, int Videos) CountKinds( + IEnumerable attachmentUrls, + IEnumerable vendorContentTypes) + { + var kinds = attachmentUrls.Select(url => ResolveKind(null, url)) + .Concat(vendorContentTypes.Select(type => ResolveKind(type, null))) + .ToList(); + return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video)); + } + /// /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose /// duration cannot be read is not blocked. Returns the stable message or null. diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index c1307dc..6ddcd95 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -206,8 +206,37 @@ namespace SeaHaven.Services.Implementation } } + PortalMediaCountsDTO? mediaCounts = null; + if (dispatch.WorkOrderId is int workOrderId) + { + // Same basis as the upload check: a new completion version replaces the + // dispatch's latest document, so that one is not counted for it. + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, cancellationToken); + var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, null, cancellationToken); + var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken); + var completionTypes = latest == null + ? vendorTypes + : await _documentData.ListActiveContentTypesForWorkOrderAsync( + workOrderId, latest.Id, cancellationToken); + var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes); + var (completionPhotos, completionVideos) = + WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes); + mediaCounts = new PortalMediaCountsDTO + { + MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder, + MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder, + Photos = photos, + Videos = videos, + CompletionPhotos = completionPhotos, + CompletionVideos = completionVideos + }; + } + return new VendorDispatchDetailDTO { + MediaCounts = mediaCounts, Id = dispatch.Id, DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber,