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..da18278 --- /dev/null +++ b/.platform/nginx/conf.d/01_upload_body_size.conf @@ -0,0 +1,6 @@ +# 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..e454b5f 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); + upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns((int _, Func> work, CancellationToken ct) => work(ct)); var commentData = new Mock(); commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); @@ -363,6 +368,278 @@ 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"); + } + + [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, 0x2D })] + [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(); + } + + /// 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() + { + 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 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() + { + 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..f0ae1af 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,36 +40,293 @@ 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); + var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader); var storage = new Mock(MockBehavior.Strict); var controller = CreateController(storage: storage); - var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + var result = await controller.AddMedia(1, null, file, CancellationToken.None); 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 = OversizeFile(10_000_001, "photo.jpg", "image/jpeg", JpegHeader); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, 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_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 = OversizeFile(60_000_000, "photo.jpg", "video/3gpp", JpegHeader); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, 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 = OversizeFile(50_000_001, "report.pdf", "application/pdf", PdfHeader); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, 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(); + } + + [Fact] + public async Task AddMedia_OversizeUnsupportedType_ReportsUnsupportedTypeNotOversizeVideo() + { + var file = OversizeFile(150_000_000, "payload.exe", "application/octet-stream", [0x4D, 0x5A]); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public void ValidateSize_OversizeUnknownKind_UsesTypeNeutralMessage() + { + Assert.Equal( + "Files must be 100 MB or smaller.", + WorkOrderMediaContract.ValidateSize(150_000_000, WorkOrderMediaContract.UploadKind.Unknown)); + } + + private static readonly byte[] FtypHeader = [0, 0, 0, 0x18, (byte)'f', (byte)'t', (byte)'y', (byte)'p', (byte)'i', (byte)'s', (byte)'o', (byte)'m']; + private static readonly byte[] JpegHeader = [0xFF, 0xD8, 0xFF, 0xE0]; + private static readonly byte[] PdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2D]; + + /// + /// A file that reports bytes but only backs the 512-byte header the + /// type rules read, so oversize cases run through real signature validation cheaply. + /// + private static FormFile OversizeFile(long length, string fileName, string contentType, byte[] header) + { + var bytes = new byte[512]; + header.CopyTo(bytes, 0); + return new FormFile(new MemoryStream(bytes), 0, length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + 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_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() + { + 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() { @@ -167,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, - storage.Object); + storage.Object, + new Mock(MockBehavior.Strict).Object); var user = new ClaimsPrincipal(new ClaimsIdentity( new[] { @@ -184,4 +443,37 @@ public class WorkOrderMediaServiceCancellationTests mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); mediaData.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_ServesHeicAsImageHeic() + { + const string url = "https://example.test/Assets/Images/photo.heic"; + var mediaData = new Mock(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny())) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny())) + .ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object, + new Mock(MockBehavior.Strict).Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "actor-1"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }, + "Test")); + + var result = await service.GetMediaContentAsync(1, 10, user, "actor-1"); + result.Content.Dispose(); + + Assert.Equal("image/heic", result.ContentType); + Assert.Equal("photo.heic", result.FileName); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 636f963..41d024c 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers @@ -299,7 +300,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..32122ca 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,14 +88,28 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - if (file.Length > MaxUploadBytes) + // Type first, so an unsupported file always reports UnsupportedMediaType instead + // of being sized under a kind it does not have. + WorkOrderMediaFileRules.EnsureAllowed(file, category); + + // Media 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( + WorkOrderMediaFileRules.ResolveUploadContentType(file), 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); + 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.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 fd05a45..2d226e6 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -56,6 +56,36 @@ 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 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..46a4274 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. Feeds the per-work-order photo/video counts. + /// + Task> ListActiveContentTypesForWorkOrderAsync( + int workOrderId, + int? excludingDocumentId, + CancellationToken cancellationToken); + + /// Stored URLs of the work order's non-deleted dispatcher attachments (photo/video 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 57cbbf3..144a521 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -22,6 +22,19 @@ 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 (photo/video counts). + 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). The 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/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs index 8e4be7c..8ddf071 100644 --- a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -136,6 +136,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; } + } + + /// + /// 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/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs new file mode 100644 index 0000000..1818c50 --- /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 (per the media contract: 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 new file mode 100644 index 0000000..46865bf --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs @@ -0,0 +1,149 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// 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 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 + { + 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 + } + + 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); + return KindByExtension.TryGetValue(extension, out var byExtension) + ? byExtension + : 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 (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes); + + if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder) + return "A work order can have at most 10 photos."; + 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. + /// + 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) + { + 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 => + "Files 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..b4db302 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) @@ -61,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) @@ -68,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) @@ -123,7 +140,7 @@ namespace SeaHaven.Services.Helpers { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", - "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); + "Supported file types are JPG, PNG, HEIC, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } @@ -139,6 +156,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 +228,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 6bb22ab..d0f14d1 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -5,6 +5,7 @@ using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -13,9 +14,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; @@ -180,8 +208,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, @@ -833,15 +890,31 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException("A completion document file is required."); } - if (file.Length > _documentOptions.MaxSizeBytes) + // Media 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)) + // 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) { - 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) @@ -864,11 +937,21 @@ 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."); } + 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) { @@ -895,50 +978,80 @@ namespace SeaHaven.Services.Implementation "The completion document could not be replaced."); } } + var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; - - var now = DateTime.UtcNow; - var document = new VendorCompletionDocument - { - VendorId = session.Id, - DispatchId = dispatchId, - WorkOrderId = dispatch.WorkOrderId ?? 0, - OriginalFileName = Path.GetFileName(file.FileName), - StoredFileName = storedFileName, - ContentType = contentType, - SizeBytes = bytes.Length, - ScanStatus = "Pending", - ReviewStatus = "Processing", - Version = version, - ReplacesDocumentId = replacedDocument?.Id, - Purpose = resolvedPurpose, - CreatedDate = now - }; - - await _documentData.AddAsync(document, cancellationToken); - var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence; - var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; - await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + async Task PersistAsync(CancellationToken ct) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - DispatchId = dispatchId, - FieldName = fieldName, - OldValue = isUpliftEvidence || replacedDocument == null - ? null - : $"v{replacedDocument.Version}", - NewValue = isUpliftEvidence - ? $"evidence {document.OriginalFileName}" - : $"v{version}", - Action = isUpliftEvidence - ? "vendor_uplift_evidence_uploaded" - : "vendor_completion_document_uploaded", - ActorType = "vendor", - CreatedAt = now - }, cancellationToken); - await _documentData.SaveChangesAsync(cancellationToken); + // 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, ct); + var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( + workOrderId, ct); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); + if (countMessage != null) + { + throw new InvalidOperationException(countMessage); + } + } + + var now = DateTime.UtcNow; + var created = new VendorCompletionDocument + { + VendorId = session.Id, + DispatchId = dispatchId, + WorkOrderId = dispatch.WorkOrderId ?? 0, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = storedFileName, + ContentType = contentType, + SizeBytes = bytes.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = version, + ReplacesDocumentId = replacedDocument?.Id, + Purpose = resolvedPurpose, + CreatedDate = now + }; + + await _documentData.AddAsync(created, ct); + + var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + DispatchId = dispatchId, + FieldName = fieldName, + OldValue = isUpliftEvidence || replacedDocument == null + ? null + : $"v{replacedDocument.Version}", + NewValue = isUpliftEvidence + ? $"evidence {created.OriginalFileName}" + : $"v{version}", + Action = isUpliftEvidence + ? "vendor_uplift_evidence_uploaded" + : "vendor_completion_document_uploaded", + ActorType = "vendor", + CreatedAt = now + }, ct); + await _documentData.SaveChangesAsync(ct); + return created; + } + + // The count and the insert run under the per-work-order mutation lock the dispatcher + // media upload also takes, so concurrent uploads cannot both claim the last slot. + var document = dispatch.WorkOrderId is int lockedWorkOrderId + ? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken) + : await PersistAsync(cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); @@ -997,35 +1110,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..d2c266d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -16,17 +16,20 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; private readonly IFileStoragePort _fileStorage; + private readonly IUpliftDataService _upliftData; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, IWorkOrderAuditService auditService, - IFileStoragePort fileStorage) + IFileStoragePort fileStorage, + IUpliftDataService upliftData) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; _fileStorage = fileStorage; + _upliftData = upliftData; } public async Task?> GetMediaAsync( @@ -153,23 +156,34 @@ namespace SeaHaven.Services.Implementation }; } - var attachment = new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl, - Category = category.HasValue ? resolvedCategory : null, - CreatedDate = DateTime.UtcNow, - createdby = actorId - }; - - _mediaData.TrackAttachment(attachment); - await _auditService.StageFieldChangedAsync( + // Photo/video caps are a work-order aggregate shared with the vendor portal + // upload, so the count and the insert run under the per-work-order mutation lock. + var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, - "MediaCategory", - null, - FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()), - actorId); - await SaveMediaAsync(cancellationToken); + async ct => + { + await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct); + + var created = new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl, + Category = category.HasValue ? resolvedCategory : null, + CreatedDate = DateTime.UtcNow, + createdby = actorId + }; + + _mediaData.TrackAttachment(created); + await _auditService.StageFieldChangedAsync( + workOrderId, + "MediaCategory", + null, + FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()), + actorId); + await SaveMediaAsync(ct); + return created; + }, + cancellationToken); return new WorkOrderMediaFileDto { @@ -348,6 +362,29 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } + /// + /// Media contract: at most 10 photos and 3 videos per work order, counted over the + /// 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, + 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 vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken); + var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes); + if (countMessage != null) + throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage); + } + /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. @@ -432,6 +469,7 @@ namespace SeaHaven.Services.Implementation { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", + ".heic" => "image/heic", ".mp4" => "video/mp4", ".mov" => "video/quicktime", ".pdf" => "application/pdf", 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; + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index a51311c..cc765ed 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index 1f2eea6..191dcca 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -176,7 +176,8 @@ public class WorkOrderMediaConcurrencyRelationalTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - new TestFileStoragePort()); + new TestFileStoragePort(), + new UpliftDataService(context)); } private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index cd24883..ea77fb4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using System.Text; using System.IO.Compression; +using System.Reflection; +using System.Text.RegularExpressions; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -812,7 +814,8 @@ public class WorkOrderMediaServiceTests new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), audit, - fileStorage ?? new TestFileStoragePort()); + fileStorage ?? new TestFileStoragePort(), + new UpliftDataService(context)); return (context, service); } @@ -1309,6 +1312,410 @@ 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_CountsAndInsertsUnderTheWorkOrderMutationLock() + { + // Distinct id: the mutation gate is process-wide per work order. + const int workOrderId = 173_001; + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = workOrderId, + 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 = workOrderId, + Attachments = $"https://example.com/photo-{i}.jpg", + Category = WorkOrderMediaCategory.Extra + }); + } + await context.SaveChangesAsync(); + + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var holder = new UpliftDataService(context).ExecuteWorkOrderMutationAsync( + workOrderId, + async _ => + { + held.SetResult(); + await release.Task; + return 0; + }, + CancellationToken.None); + await held.Task; + + var upload = service.AddMediaAsync( + workOrderId, + null, + "https://example.com/photo-9.jpg", + AuthenticatedUser(), + "actor-1"); + await Task.Delay(200); + + Assert.False(upload.IsCompleted); + Assert.Equal(9, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && a.IsDeleted != true)); + + release.SetResult(); + await holder; + await upload; + + Assert.Equal(10, context.workOrderAttachments.Count(a => a.WorkorderId == workOrderId && 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_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() + { + 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() { @@ -1968,6 +2375,32 @@ public class WorkOrderMediaFileRulesTests return stream.ToArray(); } + [Fact] + public void EnsureAllowed_RejectionMessage_NamesEveryAllowedType() + { + const BindingFlags privateStatic = BindingFlags.NonPublic | BindingFlags.Static; + var allowedTypes = (HashSet?)typeof(WorkOrderMediaFileRules) + .GetField("AllowedContentTypes", privateStatic)?.GetValue(null); + var extensionsByType = (Dictionary>?)typeof(WorkOrderMediaFileRules) + .GetField("ExtensionsByContentType", privateStatic)?.GetValue(null); + Assert.NotNull(allowedTypes); + Assert.NotNull(extensionsByType); + Assert.NotEmpty(allowedTypes); + + var ex = Assert.Throws( + () => WorkOrderMediaFileRules.EnsureAllowed(FormFile(Encoding.UTF8.GetBytes("plain text"), "notes.txt", "text/plain"))); + + foreach (var contentType in allowedTypes) + { + var canonical = contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : contentType; + Assert.True(extensionsByType.TryGetValue(canonical, out var extensions), $"No extensions mapped for {contentType}."); + var labels = extensions.Select(extension => extension.TrimStart('.').ToUpperInvariant()).ToList(); + Assert.True( + labels.Any(label => Regex.IsMatch(ex.Message, $@"\b{Regex.Escape(label)}\b")), + $"Rejection message does not name {contentType} ({string.Join("/", labels)}): {ex.Message}"); + } + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { 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..83fcc9e 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. +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 ' ')"