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.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 21:38:00 -03:00
parent a8414cd4fa
commit fd59a3da13
7 changed files with 373 additions and 3 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()
{

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

@ -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
{
@ -103,6 +103,18 @@ namespace SeaHaven.Services.Helpers
return null;
}
/// <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

@ -900,6 +900,16 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("The uploaded file signature does not match its declared content type.");
}
if (kind == WorkOrderMediaContract.UploadKind.Video)
{
using var content = new MemoryStream(bytes, writable: false);
var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content);
if (durationMessage != null)
{
throw new InvalidOperationException(durationMessage);
}
}
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken);
if (dispatch == null)
{

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