mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
fix(media): apply SH-116 media contract and lift the 1 MB proxy body cap
The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every media upload over ~1 MB got an nginx 413 before reaching the API. Ship a .platform nginx override (120M) in the bundle and assert it in the bundle contract. Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order, with stable generic rejection messages. The request ceiling (110 MB) sits between the per-kind caps and the proxy so oversize files get the generic message. The vendor portal accepts the same photo/video types and caps.
This commit is contained in:
parent
f7db6b9f84
commit
dc251c42d4
16 changed files with 807 additions and 52 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 @@
|
||||||
|
# SH-383: the Elastic Beanstalk nginx proxy defaults client_max_body_size to 1m,
|
||||||
|
# which returned 413 for every media upload over ~1 MB before the request reached
|
||||||
|
# the API. The cap sits above the API's own request limit
|
||||||
|
# (WorkOrderMediaContract.MaxUploadRequestBytes = 110 MB) so oversize uploads get
|
||||||
|
# the API's generic per-kind message instead of an nginx error page.
|
||||||
|
client_max_body_size 120M;
|
||||||
|
|
@ -363,6 +363,116 @@ public sealed class VendorPortalDocumentTests : IDisposable
|
||||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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]
|
[Fact]
|
||||||
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
|
public async Task GetDispatchDetail_ReturnsUploadedDocumentWithQuarantineAndReplacementMetadata()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
@ -31,7 +32,7 @@ public class WorkOrderMediaControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddMedia_HasTwoHundredMegabyteRequestLimit()
|
public void AddMedia_HasContractRequestLimit()
|
||||||
{
|
{
|
||||||
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
||||||
var attribute = Assert.Single(
|
var attribute = Assert.Single(
|
||||||
|
|
@ -39,24 +40,26 @@ public class WorkOrderMediaControllerTests
|
||||||
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
|
candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute));
|
||||||
var bytes = Assert.Single(attribute.ConstructorArguments);
|
var bytes = Assert.Single(attribute.ConstructorArguments);
|
||||||
|
|
||||||
Assert.Equal(200_000_000L, bytes.Value);
|
Assert.Equal(110_000_000L, bytes.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddMedia_HasTwoHundredMegabyteMultipartBodyLimit()
|
public void AddMedia_HasContractMultipartBodyLimit()
|
||||||
{
|
{
|
||||||
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia));
|
||||||
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
|
var attribute = Assert.IsType<RequestFormLimitsAttribute>(Assert.Single(
|
||||||
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
|
method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: true)));
|
||||||
|
|
||||||
Assert.Equal(200_000_000L, attribute.MultipartBodyLengthLimit);
|
Assert.Equal(110_000_000L, attribute.MultipartBodyLengthLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity()
|
public async Task AddMedia_VideoOverHundredMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
{
|
{
|
||||||
var file = new Mock<IFormFile>();
|
var file = new Mock<IFormFile>();
|
||||||
file.SetupGet(candidate => candidate.Length).Returns(200_000_001);
|
file.SetupGet(candidate => candidate.Length).Returns(100_000_001);
|
||||||
|
file.SetupGet(candidate => candidate.FileName).Returns("clip.mp4");
|
||||||
|
file.SetupGet(candidate => candidate.ContentType).Returns("video/mp4");
|
||||||
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
var controller = CreateController(storage: storage);
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
|
@ -65,10 +68,160 @@ public class WorkOrderMediaControllerTests
|
||||||
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
|
||||||
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
|
||||||
Assert.Equal("FileTooLarge", error.Code);
|
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();
|
storage.VerifyNoOtherCalls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddMedia_PhotoOverTenMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
|
{
|
||||||
|
var file = new Mock<IFormFile>();
|
||||||
|
file.SetupGet(candidate => candidate.Length).Returns(10_000_001);
|
||||||
|
file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg");
|
||||||
|
file.SetupGet(candidate => candidate.ContentType).Returns("image/jpeg");
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, null, file.Object, 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 = new Mock<IFormFile>();
|
||||||
|
file.SetupGet(candidate => candidate.Length).Returns(50_000_001);
|
||||||
|
file.SetupGet(candidate => candidate.FileName).Returns("report.pdf");
|
||||||
|
file.SetupGet(candidate => candidate.ContentType).Returns("application/pdf");
|
||||||
|
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
|
||||||
|
var controller = CreateController(storage: storage);
|
||||||
|
|
||||||
|
var result = await controller.AddMedia(1, WorkOrderMediaCategory.Extra, file.Object, 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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]
|
[Fact]
|
||||||
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
|
public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
@ -294,7 +295,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/completion-documents")]
|
[HttpPost("dispatches/{id:int}/completion-documents")]
|
||||||
[HttpPost("dispatches/{id:int}/documents")]
|
[HttpPost("dispatches/{id:int}/documents")]
|
||||||
[RequestSizeLimit(10_000_000)]
|
[RequestSizeLimit(WorkOrderMediaContract.MaxUploadRequestBytes)]
|
||||||
public async Task<IActionResult> UploadCompletionDocument(
|
public async Task<IActionResult> UploadCompletionDocument(
|
||||||
int id,
|
int id,
|
||||||
[FromForm] IFormFile file,
|
[FromForm] IFormFile file,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/workorders")]
|
[Route("api/workorders")]
|
||||||
public class WorkOrderMediaController : Controller
|
public class WorkOrderMediaController : Controller
|
||||||
{
|
{
|
||||||
public const long MaxUploadBytes = 200_000_000;
|
public const long MaxUploadBytes = WorkOrderMediaContract.MaxUploadRequestBytes;
|
||||||
|
|
||||||
private readonly IWorkOrderMediaService _workOrderMediaService;
|
private readonly IWorkOrderMediaService _workOrderMediaService;
|
||||||
private readonly IFileStoragePort _fileStorage;
|
private readonly IFileStoragePort _fileStorage;
|
||||||
|
|
@ -88,11 +88,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
string? fileUrl = null;
|
string? fileUrl = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (file.Length > MaxUploadBytes)
|
// SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
|
||||||
|
var sizeMessage = WorkOrderMediaContract.ValidateSize(
|
||||||
|
file.ContentType, file.FileName, file.Length);
|
||||||
|
if (sizeMessage != null)
|
||||||
{
|
{
|
||||||
throw new WorkOrderBoardValidationException(
|
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
||||||
"FileTooLarge",
|
|
||||||
"The uploaded file must not exceed 200 MB.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
WorkOrderMediaFileRules.EnsureAllowed(file, category);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,18 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
public void TrackAttachment(WorkOrderAttachments attachment)
|
public void TrackAttachment(WorkOrderAttachments attachment)
|
||||||
=> _context.workOrderAttachments.Add(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 void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
|
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
|
||||||
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
|
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
|
|
||||||
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
||||||
void TrackAttachment(WorkOrderAttachments attachment);
|
void TrackAttachment(WorkOrderAttachments attachment);
|
||||||
|
|
||||||
|
/// <summary>Stored URLs of the work order's non-deleted attachments (SH-116 photo/video counts).</summary>
|
||||||
|
Task<IReadOnlyList<string>> ListActiveAttachmentUrlsAsync(
|
||||||
|
int workOrderId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||||
void MarkWorkOrderModified(WorkOrder workOrder);
|
void MarkWorkOrderModified(WorkOrder workOrder);
|
||||||
Task SaveAsync(CancellationToken cancellationToken);
|
Task SaveAsync(CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
84
SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
Normal file
84
SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC),
|
||||||
|
/// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work
|
||||||
|
/// order, across the dispatcher media modal, the completion-doc media tab and the vendor
|
||||||
|
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only
|
||||||
|
/// checkable client-side (the server cannot probe it cheaply), so it is enforced in the
|
||||||
|
/// browser and documented here as part of the same contract.
|
||||||
|
/// </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
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UploadKind ResolveKind(string? contentType, string? fileName)
|
||||||
|
{
|
||||||
|
var type = (contentType ?? string.Empty).Trim();
|
||||||
|
var extension = Path.GetExtension(fileName ?? string.Empty);
|
||||||
|
|
||||||
|
if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".mov", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return UploadKind.Video;
|
||||||
|
|
||||||
|
if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".png", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".heic", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return UploadKind.Photo;
|
||||||
|
|
||||||
|
if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| type.Equals("application/msword", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| type.Equals(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".doc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".docx", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return UploadKind.Document;
|
||||||
|
|
||||||
|
return UploadKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 =>
|
||||||
|
"Videos 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/jpeg",
|
||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
|
"image/heic",
|
||||||
"video/mp4",
|
"video/mp4",
|
||||||
"video/quicktime",
|
"video/quicktime",
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
|
|
@ -24,6 +25,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
||||||
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
|
["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/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
|
||||||
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
||||||
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
|
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
|
||||||
|
|
@ -42,7 +44,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
// the accepted set of files.
|
// the accepted set of files.
|
||||||
private static string? ResolveContentType(string declaredType, string extension)
|
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;
|
return declaredType;
|
||||||
|
|
||||||
foreach (var (contentType, extensions) in ExtensionsByContentType)
|
foreach (var (contentType, extensions) in ExtensionsByContentType)
|
||||||
|
|
@ -139,6 +145,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
&& 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))
|
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||||
|
|
@ -206,5 +217,20 @@ namespace SeaHaven.Services.Helpers
|
||||||
&& bytes[6] == (byte)'y'
|
&& bytes[6] == (byte)'y'
|
||||||
&& bytes[7] == (byte)'p';
|
&& 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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
|
|
@ -12,9 +13,36 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
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 const int MaxRefusalReasonLength = 500;
|
||||||
|
|
||||||
private readonly IVendorPortalTokenService _tokens;
|
private readonly IVendorPortalTokenService _tokens;
|
||||||
|
|
@ -820,15 +848,30 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new InvalidOperationException("A completion document file is required.");
|
throw new InvalidOperationException("A completion document file is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.Length > _documentOptions.MaxSizeBytes)
|
// SH-116 contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB
|
||||||
|
// (MP4/MOV). Documents keep the configured VendorDocuments cap.
|
||||||
|
var contentType = ResolveUploadContentType(file);
|
||||||
|
if (contentType == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size.");
|
throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentType = (file.ContentType ?? string.Empty).Trim();
|
var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName);
|
||||||
if (!AllowedContentTypes.Contains(contentType))
|
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)
|
var resolvedPurpose = string.IsNullOrWhiteSpace(purpose)
|
||||||
|
|
@ -851,7 +894,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
await file.CopyToAsync(buffer, cancellationToken);
|
await file.CopyToAsync(buffer, cancellationToken);
|
||||||
var bytes = buffer.ToArray();
|
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.");
|
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
|
||||||
}
|
}
|
||||||
|
|
@ -984,35 +1027,6 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesSignature(string contentType, byte[] bytes)
|
|
||||||
{
|
|
||||||
if (bytes.Length == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return bytes.Length >= 4
|
|
||||||
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46; // %PDF
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return bytes.Length >= 8
|
|
||||||
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
|
|
||||||
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetSafeExtension(string fileName)
|
private static string GetSafeExtension(string fileName)
|
||||||
{
|
{
|
||||||
var extension = Path.GetExtension(fileName);
|
var extension = Path.GetExtension(fileName);
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, cancellationToken);
|
||||||
|
|
||||||
var attachment = new WorkOrderAttachments
|
var attachment = new WorkOrderAttachments
|
||||||
{
|
{
|
||||||
WorkorderId = workOrderId,
|
WorkorderId = workOrderId,
|
||||||
|
|
@ -348,6 +350,42 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SH-116 contract: at most 10 photos and 3 videos per work order, counted over the
|
||||||
|
/// stored attachment rows. Legacy Before/After column slots replace in place and are
|
||||||
|
/// not counted. Documents have no per-work-order count limit.
|
||||||
|
/// </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 sameKindCount = urls.Count(url =>
|
||||||
|
WorkOrderMediaContract.ResolveKind(null, url) == kind);
|
||||||
|
|
||||||
|
if (kind == WorkOrderMediaContract.UploadKind.Photo
|
||||||
|
&& sameKindCount >= WorkOrderMediaContract.MaxPhotosPerWorkOrder)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"MediaCountExceeded",
|
||||||
|
"A work order can have at most 10 photos.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind == WorkOrderMediaContract.UploadKind.Video
|
||||||
|
&& sameKindCount >= WorkOrderMediaContract.MaxVideosPerWorkOrder)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"MediaCountExceeded",
|
||||||
|
"A work order can have at most 3 videos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
|
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
|
||||||
/// Call only after EnsureCan* has verified scope is not Missing.
|
/// Call only after EnsureCan* has verified scope is not Missing.
|
||||||
|
|
|
||||||
|
|
@ -1309,6 +1309,289 @@ public class WorkOrderMediaServiceTests
|
||||||
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
|
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_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_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]
|
[Fact]
|
||||||
public async Task DeleteMedia_Technician_ThrowsForbidden()
|
public async Task DeleteMedia_Technician_ThrowsForbidden()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ def is_app_path(path: str) -> bool:
|
||||||
normalized = path.replace("\\", "/")
|
normalized = path.replace("\\", "/")
|
||||||
if normalized in APP_SCRIPT_NAMES:
|
if normalized in APP_SCRIPT_NAMES:
|
||||||
return True
|
return True
|
||||||
if normalized.startswith(".ebextensions/"):
|
if normalized.startswith((".ebextensions/", ".platform/")):
|
||||||
return True
|
return True
|
||||||
return normalized.endswith(APP_SUFFIXES)
|
return normalized.endswith(APP_SUFFIXES)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
||||||
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
|
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
|
||||||
# ./.ebextensions/* leader-only migration container command
|
# ./.ebextensions/* leader-only migration container command
|
||||||
|
# ./.platform/nginx/conf.d/* nginx proxy overrides (upload body size)
|
||||||
#
|
#
|
||||||
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
|
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
|
||||||
# environment (Elastic Beanstalk property). No connection string or secret is
|
# 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"
|
mkdir -p "$STAGING_DIR/.ebextensions"
|
||||||
cp -R .ebextensions/. "$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"
|
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
|
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."
|
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.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
|
||||||
self.assertTrue(any(path.endswith(".cs") for path in app_files))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,15 @@ die() {
|
||||||
|
|
||||||
contents_file="$(mktemp)"
|
contents_file="$(mktemp)"
|
||||||
webhook_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 -tq "$BUNDLE"
|
||||||
unzip -Z1 "$BUNDLE" > "$contents_file"
|
unzip -Z1 "$BUNDLE" > "$contents_file"
|
||||||
grep -Fxq "efbundle" "$contents_file"
|
grep -Fxq "efbundle" "$contents_file"
|
||||||
grep -Fxq ".ebextensions/01_migrations.config" "$contents_file"
|
grep -Fxq ".ebextensions/01_migrations.config" "$contents_file"
|
||||||
grep -Fxq ".ebextensions/02_webhook_config.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"
|
unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file"
|
||||||
grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$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' \
|
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
|
||||||
"$webhook_file"
|
"$webhook_file"
|
||||||
|
|
||||||
|
# Without this override the platform nginx caps request bodies at 1 MB (SH-383).
|
||||||
|
unzip -p "$BUNDLE" .platform/nginx/conf.d/01_upload_body_size.conf > "$nginx_file"
|
||||||
|
grep -Fxq 'client_max_body_size 120M;' "$nginx_file"
|
||||||
|
|
||||||
printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \
|
printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \
|
||||||
"$(wc -c < "$BUNDLE" | tr -d ' ')"
|
"$(wc -c < "$BUNDLE" | tr -d ' ')"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue