mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #173 from Sea-Haven-Industries/fix/ab/sh-383-media-contract
fix(media): lift the 1 MB proxy body cap and apply the SH-116 media contract
This commit is contained in:
commit
2c8ffaf10e
23 changed files with 1825 additions and 120 deletions
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
6
.platform/nginx/conf.d/01_upload_body_size.conf
Normal file
|
|
@ -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;
|
||||
|
|
@ -54,6 +54,11 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
|||
var upliftData = new Mock<IUpliftDataService>();
|
||||
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PortalUpliftData>());
|
||||
upliftData.Setup(u => u.ExecuteWorkOrderMutationAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<Func<CancellationToken, Task<VendorCompletionDocument>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((int _, Func<CancellationToken, Task<VendorCompletionDocument>> work, CancellationToken ct) => work(ct));
|
||||
var commentData = new Mock<ICommentDataService>();
|
||||
commentData.Setup(c => c.GetVendorViewableForDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PortalCommentData>());
|
||||
|
|
@ -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<OkObjectResult>();
|
||||
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<OkObjectResult>();
|
||||
(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<OkObjectResult>();
|
||||
(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<BadRequestObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
||||
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<OkObjectResult>();
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
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<BadRequestObjectResult>();
|
||||
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<OkObjectResult>();
|
||||
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<BadRequestObjectResult>();
|
||||
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<BadRequestObjectResult>();
|
||||
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<BadRequestObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<RequestFormLimitsAttribute>(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<IFormFile>();
|
||||
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
|
||||
var file = OversizeFile(100_000_001, "clip.mp4", "video/mp4", FtypHeader);
|
||||
var storage = new Mock<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
|
||||
var controller = CreateController(storage: storage);
|
||||
|
||||
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||
|
||||
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
|
||||
var controller = CreateController(storage: storage);
|
||||
|
||||
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||
|
||||
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
|
||||
var controller = CreateController(storage: storage);
|
||||
|
||||
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file, CancellationToken.None);
|
||||
|
||||
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<IFileStoragePort>(MockBehavior.Strict);
|
||||
var controller = CreateController(storage: storage);
|
||||
|
||||
var result = await controller.AddMedia(1, null, file, CancellationToken.None);
|
||||
|
||||
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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];
|
||||
|
||||
/// <summary>
|
||||
/// A file that reports <paramref name="length"/> bytes but only backs the 512-byte header the
|
||||
/// type rules read, so oversize cases run through real signature validation cheaply.
|
||||
/// </summary>
|
||||
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<IWorkOrderMediaService> Service, Mock<IFileStoragePort> Storage) SetupSuccessfulAddMedia()
|
||||
{
|
||||
var service = new Mock<IWorkOrderMediaService>(MockBehavior.Strict);
|
||||
service
|
||||
.Setup(candidate => candidate.EnsureCanMutateMediaAsync(
|
||||
1, It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), null))
|
||||
.Returns(Task.CompletedTask);
|
||||
service
|
||||
.Setup(candidate => candidate.AddMediaAsync(
|
||||
1, null, "https://storage.test/stored", It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WorkOrderMediaFileDto { Id = 5, Url = "https://storage.test/stored" });
|
||||
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||
storage
|
||||
.Setup(candidate => candidate.SaveFileAsync(It.IsAny<IFormFile>()))
|
||||
.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<OkObjectResult>(result);
|
||||
Assert.Equal(5, Assert.IsType<WorkOrderMediaFileDto>(ok.Value).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
|
||||
{
|
||||
var storage = new Mock<IFileStoragePort>(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<UnprocessableEntityObjectResult>(result);
|
||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(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<OkObjectResult>(result);
|
||||
}
|
||||
|
||||
/// <summary>ftyp + mdat + moov/mvhd (moov last, as phones write it).</summary>
|
||||
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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
|
||||
{
|
||||
|
|
@ -167,7 +425,8 @@ public class WorkOrderMediaServiceCancellationTests
|
|||
mediaData.Object,
|
||||
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
||||
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
||||
storage.Object);
|
||||
storage.Object,
|
||||
new Mock<IUpliftDataService>(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<IWorkOrderMediaDataService>(MockBehavior.Strict);
|
||||
mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WorkOrder { Id = 1 });
|
||||
mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WorkOrderAttachments { Id = 10, WorkorderId = 1, Attachments = url });
|
||||
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||
storage.Setup(candidate => candidate.OpenRead(url)).Returns(new MemoryStream([1, 2, 3]));
|
||||
var service = new WorkOrderMediaService(
|
||||
mediaData.Object,
|
||||
new Mock<IWorkOrderDetailDataService>(MockBehavior.Strict).Object,
|
||||
new Mock<IWorkOrderAuditService>(MockBehavior.Strict).Object,
|
||||
storage.Object,
|
||||
new Mock<IUpliftDataService>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IActionResult> UploadCompletionDocument(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<string>> 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<IReadOnlyList<string>> 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<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
|
|
|
|||
|
|
@ -56,6 +56,36 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public void TrackAttachment(WorkOrderAttachments attachment)
|
||||
=> _context.workOrderAttachments.Add(attachment);
|
||||
|
||||
public async Task<IReadOnlyList<string>> 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<IReadOnlyList<string>> 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,19 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<VendorCompletionDocument?> GetMetadataForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task<List<VendorCompletionDocument>> ListForVendorDispatchAsync(int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorCompletionDocument?> GetUpliftEvidenceAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> ListActiveContentTypesForWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? excludingDocumentId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Stored URLs of the work order's non-deleted dispatcher attachments (photo/video counts).</summary>
|
||||
Task<IReadOnlyList<string>> ListActiveWorkOrderAttachmentUrlsAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken);
|
||||
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
|
||||
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
||||
void TrackAttachment(WorkOrderAttachments attachment);
|
||||
|
||||
/// <summary>Stored URLs of the work order's non-deleted attachments (photo/video counts).</summary>
|
||||
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> ListActiveVendorMediaContentTypesAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken);
|
||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||
void MarkWorkOrderModified(WorkOrder workOrder);
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,26 @@ namespace SeaHaven.Services.DTOs
|
|||
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
|
||||
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
|
||||
public IEnumerable<VendorPortalDocumentDTO> Documents { get; set; } = Enumerable.Empty<VendorPortalDocumentDTO>();
|
||||
public PortalMediaCountsDTO? MediaCounts { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PortalMediaCountsDTO
|
||||
{
|
||||
public int MaxPhotos { get; set; }
|
||||
public int MaxVideos { get; set; }
|
||||
/// <summary>Photos/videos on the work order, counted for evidence uploads.</summary>
|
||||
public int Photos { get; set; }
|
||||
public int Videos { get; set; }
|
||||
/// <summary>
|
||||
/// Photos/videos counted for a new completion version, which replaces the dispatch's
|
||||
/// latest document and so does not count it.
|
||||
/// </summary>
|
||||
public int CompletionPhotos { get; set; }
|
||||
public int CompletionVideos { get; set; }
|
||||
}
|
||||
|
||||
public class VendorPortalDocumentDTO
|
||||
|
|
|
|||
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header:
|
||||
/// top-level <c>moov</c> box → <c>mvhd</c> timescale and duration. It seeks over box
|
||||
/// headers only (the <c>moov</c> box may sit after a large <c>mdat</c>), 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
Normal file
149
SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="VideoDurationProbe"/>); the browser pre-checks
|
||||
/// it and the server enforces it, and unreadable metadata never blocks an upload.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits
|
||||
/// above <see cref="MaxVideoBytes"/> 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.
|
||||
/// </summary>
|
||||
public const long MaxUploadRequestBytes = 110_000_000;
|
||||
|
||||
public enum UploadKind
|
||||
{
|
||||
Photo,
|
||||
Video,
|
||||
Document,
|
||||
Unknown
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, UploadKind> 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<string, UploadKind> 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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string? ValidateCount(
|
||||
UploadKind kind,
|
||||
IEnumerable<string> attachmentUrls,
|
||||
IEnumerable<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static (int Photos, int Videos) CountKinds(
|
||||
IEnumerable<string> attachmentUrls,
|
||||
IEnumerable<string> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static string? ValidateVideoDuration(Stream? content)
|
||||
{
|
||||
var seconds = VideoDurationProbe.TryReadDurationSeconds(content);
|
||||
return seconds > MaxVideoDurationSeconds
|
||||
? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter."
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
||||
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
|
||||
["image/heic"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".heic" },
|
||||
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
|
||||
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
||||
["application/pdf"] = new HashSet<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The canonical content type <see cref="IsAllowed"/> 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.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> 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<VendorCompletionDocument> 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);
|
||||
|
|
|
|||
|
|
@ -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<IReadOnlyList<WorkOrderMediaFileDto>?> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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",
|
||||
|
|
|
|||
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,8 @@ public class WorkOrderCompletedSelectiveLockTests
|
|||
new WorkOrderMediaDataService(context),
|
||||
new WorkOrderDetailDataService(context),
|
||||
audit,
|
||||
new TestFileStoragePort());
|
||||
new TestFileStoragePort(),
|
||||
new UpliftDataService(context));
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<WorkOrderBoardValidationException>(() =>
|
||||
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<string>?)typeof(WorkOrderMediaFileRules)
|
||||
.GetField("AllowedContentTypes", privateStatic)?.GetValue(null);
|
||||
var extensionsByType = (Dictionary<string, HashSet<string>>?)typeof(WorkOrderMediaFileRules)
|
||||
.GetField("ExtensionsByContentType", privateStatic)?.GetValue(null);
|
||||
Assert.NotNull(allowedTypes);
|
||||
Assert.NotNull(extensionsByType);
|
||||
Assert.NotEmpty(allowedTypes);
|
||||
|
||||
var ex = Assert.Throws<WorkOrderBoardValidationException>(
|
||||
() => 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ' ')"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue