Compare commits

...

2 commits

Author SHA1 Message Date
Alexandre Brandizzi
eecc2a61bd feat(vendor-portal): report work-order media counts on the dispatch detail
Adds MediaCounts (limits, current photos/videos, and the counts a new
completion version would see) so the portal can refuse an 11th photo or
4th video before uploading it. Uses the same count and replacement rule
the upload check enforces.
2026-09-24 22:24:07 -03:00
Alexandre Brandizzi
fd59a3da13 fix(media): enforce the 90-second video limit on the server
Read the duration from the MP4/MOV movie header (moov/mvhd) on both the
dispatcher media endpoint and the vendor portal completion upload, so a
direct request cannot bypass the browser check. Unreadable metadata still
never blocks an upload.
2026-09-24 21:38:00 -03:00
8 changed files with 479 additions and 7 deletions

View file

@ -453,6 +453,50 @@ public sealed class VendorPortalDocumentTests : IDisposable
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()
{
@ -509,6 +553,46 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().HaveCount(2);
}
[Fact]
public async Task GetDispatchDetail_ReportsWorkOrderMediaCountsForThePortalPreCheck()
{
using var context = NewContext();
var (vendor, dispatch) = await SeedDispatch(context);
foreach (var name in new[] { "IMG_0001.MOV", "IMG_0002.MOV", "IMG_0003.jpg" })
{
context.workOrderAttachments.Add(new WorkOrderAttachments
{
WorkorderId = dispatch.WorkOrderId!.Value,
Attachments = $"https://api.example.com/Assets/Documents/{Guid.NewGuid()}_{name}",
});
}
context.VendorCompletionDocuments.Add(new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = dispatch.Id,
WorkOrderId = dispatch.WorkOrderId!.Value,
ContentType = "video/mp4",
StoredFileName = "v1.mp4",
Version = 1,
Purpose = "Completion"
});
await context.SaveChangesAsync();
var service = NewService(context);
var session = await service.ResolveSessionAsync(Token, CancellationToken.None);
var detail = await service.GetDispatchDetailAsync(session!, dispatch.Id, CancellationToken.None);
detail!.MediaCounts.Should().BeEquivalentTo(new SeaHaven.Services.DTOs.PortalMediaCountsDTO
{
MaxPhotos = 10,
MaxVideos = 3,
Photos = 1,
Videos = 3,
CompletionPhotos = 1,
CompletionVideos = 2,
});
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{

View file

@ -182,6 +182,60 @@ public class WorkOrderMediaControllerTests
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()
{

View file

@ -98,6 +98,15 @@ namespace Api.SeaHavenIndustries.Controllers
}
WorkOrderMediaFileRules.EnsureAllowed(file, category);
if (WorkOrderMediaContract.ResolveKind(
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName)
== WorkOrderMediaContract.UploadKind.Video)
{
using var content = file.OpenReadStream();
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
throw new WorkOrderBoardValidationException("VideoTooLong", durationMessage);
}
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category);

View file

@ -134,6 +134,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>
/// SH-116 per-work-order photo/video usage, so the portal can pre-check an upload
/// before sending it. The server still enforces the limit on upload.
/// </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

View 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 (SH-116: 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;
}
}
}

View file

@ -4,9 +4,9 @@ namespace SeaHaven.Services.Helpers
/// SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC),
/// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work
/// order, across the dispatcher media modal, the completion-doc media tab and the vendor
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is only
/// checkable client-side (the server cannot probe it cheaply), so it is enforced in the
/// browser and documented here as part of the same contract.
/// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read
/// from the MP4/MOV movie header (<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
{
@ -93,16 +93,41 @@ namespace SeaHaven.Services.Helpers
if (kind != UploadKind.Photo && kind != UploadKind.Video)
return null;
var count = attachmentUrls.Count(url => ResolveKind(null, url) == kind)
+ vendorContentTypes.Count(type => ResolveKind(type, null) == kind);
var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes);
if (kind == UploadKind.Photo && count >= MaxPhotosPerWorkOrder)
if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder)
return "A work order can have at most 10 photos.";
if (kind == UploadKind.Video && count >= MaxVideosPerWorkOrder)
if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder)
return "A work order can have at most 3 videos.";
return null;
}
/// <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)
{

View file

@ -206,8 +206,37 @@ namespace SeaHaven.Services.Implementation
}
}
PortalMediaCountsDTO? mediaCounts = null;
if (dispatch.WorkOrderId is int workOrderId)
{
// Same basis as the upload check: a new completion version replaces the
// dispatch's latest document, so that one is not counted for it.
var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync(
workOrderId, cancellationToken);
var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, null, cancellationToken);
var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken);
var completionTypes = latest == null
? vendorTypes
: await _documentData.ListActiveContentTypesForWorkOrderAsync(
workOrderId, latest.Id, cancellationToken);
var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes);
var (completionPhotos, completionVideos) =
WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes);
mediaCounts = new PortalMediaCountsDTO
{
MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder,
MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder,
Photos = photos,
Videos = videos,
CompletionPhotos = completionPhotos,
CompletionVideos = completionVideos
};
}
return new VendorDispatchDetailDTO
{
MediaCounts = mediaCounts,
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
@ -900,6 +929,16 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
}
if (kind == WorkOrderMediaContract.UploadKind.Video)
{
using var content = new MemoryStream(bytes, writable: false);
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
{
throw new InvalidOperationException(durationMessage);
}
}
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
if (dispatch == null)
{

View 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;
}
}