diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
index 2ef7eca..1258703 100644
--- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
+++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
@@ -453,6 +453,50 @@ public sealed class VendorPortalDocumentTests : IDisposable
context.VendorCompletionDocuments.Should().BeEmpty();
}
+ /// ftyp + mdat + moov/mvhd (moov last, as phones write it).
+ private static byte[] PhoneVideoBytes(uint durationSeconds)
+ {
+ static byte[] Box(string type, byte[] body)
+ {
+ var box = new byte[8 + body.Length];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
+ System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
+ body.CopyTo(box, 8);
+ return box;
+ }
+
+ var mvhd = new byte[20];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 600);
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 600);
+ return Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
+ .Concat(Box("mdat", new byte[4096]))
+ .Concat(Box("moov", Box("mvhd", mvhd)))
+ .ToArray();
+ }
+
+ [Theory]
+ [InlineData(95u, false)]
+ [InlineData(89u, true)]
+ public async Task UploadCompletionDocument_EnforcesNinetySecondVideoLimit(uint seconds, bool accepted)
+ {
+ using var context = NewContext();
+ var (_, dispatch) = await SeedDispatch(context);
+
+ var result = await NewController(context).UploadCompletionDocument(
+ dispatch.Id,
+ FormFile(PhoneVideoBytes(seconds), "IMG_0042.MOV", "video/quicktime"));
+
+ if (accepted)
+ {
+ result.Should().BeOfType();
+ }
+ else
+ {
+ result.Should().BeOfType();
+ context.VendorCompletionDocuments.Should().BeEmpty();
+ }
+ }
+
[Fact]
public async Task UploadCompletionDocument_RejectsFourthVideoAcrossDispatcherAndVendorUploads()
{
diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs
index c990b2e..8234c92 100644
--- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs
+++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs
@@ -182,6 +182,60 @@ public class WorkOrderMediaControllerTests
Assert.Equal(5, Assert.IsType(ok.Value).Id);
}
+ [Fact]
+ public async Task AddMedia_VideoLongerThanNinetySeconds_ReturnsStableUnprocessableEntity()
+ {
+ var storage = new Mock(MockBehavior.Strict);
+ var controller = CreateController(storage: storage);
+ var file = PhoneVideo(durationSeconds: 95, "IMG_0042.MOV", "video/quicktime");
+
+ var result = await controller.AddMedia(1, null, file, CancellationToken.None);
+
+ var response = Assert.IsType(result);
+ var error = Assert.IsType(response.Value);
+ Assert.Equal("VideoTooLong", error.Code);
+ Assert.Equal("Videos must be 90 seconds or shorter.", error.Message);
+ storage.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task AddMedia_VideoWithinNinetySeconds_IsAccepted()
+ {
+ var (service, storage) = SetupSuccessfulAddMedia();
+ var controller = CreateController(service, storage);
+ var file = PhoneVideo(durationSeconds: 60, "IMG_0043.MOV", "");
+
+ var result = await controller.AddMedia(1, null, file, CancellationToken.None);
+
+ Assert.IsType(result);
+ }
+
+ /// ftyp + mdat + moov/mvhd (moov last, as phones write it).
+ private static FormFile PhoneVideo(uint durationSeconds, string fileName, string contentType)
+ {
+ static byte[] Box(string type, byte[] body)
+ {
+ var box = new byte[8 + body.Length];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
+ System.Text.Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
+ body.CopyTo(box, 8);
+ return box;
+ }
+
+ var mvhd = new byte[20];
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(12), 1000);
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(mvhd.AsSpan(16), durationSeconds * 1000);
+ var bytes = Box("ftyp", System.Text.Encoding.ASCII.GetBytes("qt \0\0\0\0"))
+ .Concat(Box("mdat", new byte[4096]))
+ .Concat(Box("moov", Box("mvhd", mvhd)))
+ .ToArray();
+ return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
+ {
+ Headers = new HeaderDictionary(),
+ ContentType = contentType
+ };
+ }
+
[Fact]
public async Task AddMedia_SixtyMegabyteQuicktimeMov_IsAccepted()
{
diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs
index b19efb9..e795f3c 100644
--- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs
+++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs
@@ -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);
diff --git a/SeaHaven.Services/Helpers/VideoDurationProbe.cs b/SeaHaven.Services/Helpers/VideoDurationProbe.cs
new file mode 100644
index 0000000..8355c46
--- /dev/null
+++ b/SeaHaven.Services/Helpers/VideoDurationProbe.cs
@@ -0,0 +1,129 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header:
+ /// top-level moov box → mvhd timescale and duration. It seeks over box
+ /// headers only (the moov box may sit after a large mdat), never decodes
+ /// media and never allocates more than a few bytes. Returns null whenever the structure
+ /// cannot be read, so callers can let unreadable files through (SH-116: unreadable
+ /// metadata never blocks an upload).
+ ///
+ public static class VideoDurationProbe
+ {
+ private const int MaxBoxesPerLevel = 1024;
+
+ public static double? TryReadDurationSeconds(Stream? stream)
+ {
+ if (stream == null || !stream.CanSeek || !stream.CanRead)
+ return null;
+
+ try
+ {
+ var moov = FindBox(stream, 0, stream.Length, "moov");
+ if (moov == null)
+ return null;
+
+ var mvhd = FindBox(stream, moov.Value.BodyStart, moov.Value.End, "mvhd");
+ return mvhd == null ? null : ReadMovieHeaderSeconds(stream, mvhd.Value);
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ }
+
+ private readonly record struct Box(long BodyStart, long End);
+
+ private static Box? FindBox(Stream stream, long start, long end, string type)
+ {
+ var header = new byte[8];
+ var position = start;
+ for (var i = 0; i < MaxBoxesPerLevel && position + 8 <= end; i++)
+ {
+ stream.Position = position;
+ if (!ReadExactly(stream, header, 8))
+ return null;
+
+ long size = BinaryPrimitives.ReadUInt32BigEndian(header);
+ var boxType = Encoding.ASCII.GetString(header, 4, 4);
+ var headerLength = 8;
+ if (size == 1)
+ {
+ // 64-bit "largesize" follows the type.
+ var large = new byte[8];
+ if (!ReadExactly(stream, large, 8))
+ return null;
+ var largeSize = BinaryPrimitives.ReadUInt64BigEndian(large);
+ if (largeSize > long.MaxValue)
+ return null;
+ size = (long)largeSize;
+ headerLength = 16;
+ }
+ else if (size == 0)
+ {
+ size = end - position;
+ }
+
+ if (size < headerLength || position + size > end)
+ return null;
+
+ if (boxType == type)
+ return new Box(position + headerLength, position + size);
+
+ position += size;
+ }
+
+ return null;
+ }
+
+ private static double? ReadMovieHeaderSeconds(Stream stream, Box mvhd)
+ {
+ // version(1) flags(3), then v0: creation(4) modification(4) timescale(4) duration(4)
+ // v1: creation(8) modification(8) timescale(4) duration(8)
+ var body = new byte[32];
+ stream.Position = mvhd.BodyStart;
+ var available = (int)Math.Min(body.Length, mvhd.End - mvhd.BodyStart);
+ if (available < 20 || !ReadExactly(stream, body, available))
+ return null;
+
+ uint timescale;
+ ulong duration;
+ if (body[0] == 0)
+ {
+ timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(12, 4));
+ duration = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(16, 4));
+ }
+ else if (body[0] == 1 && available >= 32)
+ {
+ timescale = BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(20, 4));
+ duration = BinaryPrimitives.ReadUInt64BigEndian(body.AsSpan(24, 8));
+ }
+ else
+ {
+ return null;
+ }
+
+ // All-ones duration means "unknown" in the spec.
+ if (timescale == 0 || duration == uint.MaxValue || duration == ulong.MaxValue)
+ return null;
+
+ return (double)duration / timescale;
+ }
+
+ private static bool ReadExactly(Stream stream, byte[] buffer, int count)
+ {
+ var read = 0;
+ while (read < count)
+ {
+ var n = stream.Read(buffer, read, count - read);
+ if (n <= 0)
+ return false;
+ read += n;
+ }
+ return true;
+ }
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
index 5ba60e3..a0bfa56 100644
--- a/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderMediaContract.cs
@@ -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 (); the browser pre-checks
+ /// it and the server enforces it, and unreadable metadata never blocks an upload.
///
public static class WorkOrderMediaContract
{
@@ -103,6 +103,18 @@ namespace SeaHaven.Services.Helpers
return null;
}
+ ///
+ /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose
+ /// duration cannot be read is not blocked. Returns the stable message or null.
+ ///
+ public static string? ValidateVideoDuration(Stream? content)
+ {
+ var seconds = VideoDurationProbe.TryReadDurationSeconds(content);
+ return seconds > MaxVideoDurationSeconds
+ ? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter."
+ : null;
+ }
+
/// Stable, generic per-kind size message; never echoes file metadata.
public static string? ValidateSize(long length, UploadKind kind)
{
diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs
index 0aa4353..c1307dc 100644
--- a/SeaHaven.Services/Implementation/VendorPortalService.cs
+++ b/SeaHaven.Services/Implementation/VendorPortalService.cs
@@ -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)
{
diff --git a/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
new file mode 100644
index 0000000..df5913d
--- /dev/null
+++ b/SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
@@ -0,0 +1,112 @@
+using System.Buffers.Binary;
+using System.Text;
+using SeaHaven.Services.Helpers;
+
+namespace SeaHavenIndustries.Tests;
+
+public class VideoDurationProbeTests
+{
+ internal static byte[] Box(string type, params byte[][] children)
+ {
+ var body = children.SelectMany(child => child).ToArray();
+ var box = new byte[8 + body.Length];
+ BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
+ Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
+ body.CopyTo(box, 8);
+ return box;
+ }
+
+ internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false)
+ {
+ var body = new byte[version1 ? 32 : 20];
+ body[0] = version1 ? (byte)1 : (byte)0;
+ if (version1)
+ {
+ BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale);
+ BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration);
+ }
+ else
+ {
+ BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale);
+ BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration);
+ }
+ return Box("mvhd", body);
+ }
+
+ /// A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start).
+ internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096)
+ {
+ var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]);
+ var mdat = Box("mdat", new byte[mediaBytes]);
+ var moov = Box("moov", MovieHeader(timescale, duration, version1));
+ return ftyp.Concat(mdat).Concat(moov).ToArray();
+ }
+
+ [Fact]
+ public void ReadsDurationFromMoovAfterMediaData()
+ {
+ Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000))));
+ }
+
+ [Fact]
+ public void ReadsVersionOneMovieHeader()
+ {
+ Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds(
+ new MemoryStream(Mp4(1000, 30_500, version1: true))));
+ }
+
+ [Fact]
+ public void FollowsSixtyFourBitBoxSizes()
+ {
+ var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]);
+ var mdatBody = new byte[512];
+ var mdat = new byte[16 + mdatBody.Length];
+ BinaryPrimitives.WriteUInt32BigEndian(mdat, 1);
+ Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4);
+ BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length);
+ var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120));
+ var file = ftyp.Concat(mdat).Concat(moov).ToArray();
+
+ Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file)));
+ }
+
+ [Theory]
+ [InlineData("no-moov")]
+ [InlineData("truncated")]
+ [InlineData("zero-timescale")]
+ [InlineData("oversized-box")]
+ public void UnreadableStructureReturnsNull(string shape)
+ {
+ var bytes = shape switch
+ {
+ "no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(),
+ "truncated" => Mp4(600, 57_000)[..^10],
+ "zero-timescale" => Mp4(0, 57_000),
+ _ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(),
+ };
+
+ Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes)));
+ }
+
+ [Fact]
+ public void NonSeekableStreamReturnsNull()
+ {
+ Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000))));
+ }
+
+ [Theory]
+ [InlineData(90.0, true)]
+ [InlineData(90.5, false)]
+ public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed)
+ {
+ var message = WorkOrderMediaContract.ValidateVideoDuration(
+ new MemoryStream(Mp4(1000, (ulong)(seconds * 1000))));
+
+ Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message);
+ }
+
+ private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes)
+ {
+ public override bool CanSeek => false;
+ }
+}