mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
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:
parent
a8414cd4fa
commit
fd59a3da13
7 changed files with 373 additions and 3 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
129
SeaHaven.Services/Helpers/VideoDurationProbe.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a video's duration from the ISO base media (MP4 / QuickTime) movie header:
|
||||
/// top-level <c>moov</c> box → <c>mvhd</c> timescale and duration. It seeks over box
|
||||
/// headers only (the <c>moov</c> box may sit after a large <c>mdat</c>), never decodes
|
||||
/// media and never allocates more than a few bytes. Returns null whenever the structure
|
||||
/// cannot be read, so callers can let unreadable files through (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
112
SeaHavenIndustries.Tests/VideoDurationProbeTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class VideoDurationProbeTests
|
||||
{
|
||||
internal static byte[] Box(string type, params byte[][] children)
|
||||
{
|
||||
var body = children.SelectMany(child => child).ToArray();
|
||||
var box = new byte[8 + body.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(box, (uint)box.Length);
|
||||
Encoding.ASCII.GetBytes(type).CopyTo(box, 4);
|
||||
body.CopyTo(box, 8);
|
||||
return box;
|
||||
}
|
||||
|
||||
internal static byte[] MovieHeader(uint timescale, ulong duration, bool version1 = false)
|
||||
{
|
||||
var body = new byte[version1 ? 32 : 20];
|
||||
body[0] = version1 ? (byte)1 : (byte)0;
|
||||
if (version1)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(20), timescale);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(body.AsSpan(24), duration);
|
||||
}
|
||||
else
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(12), timescale);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(16), (uint)duration);
|
||||
}
|
||||
return Box("mvhd", body);
|
||||
}
|
||||
|
||||
/// <summary>A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start).</summary>
|
||||
internal static byte[] Mp4(uint timescale, ulong duration, bool version1 = false, int mediaBytes = 4096)
|
||||
{
|
||||
var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]);
|
||||
var mdat = Box("mdat", new byte[mediaBytes]);
|
||||
var moov = Box("moov", MovieHeader(timescale, duration, version1));
|
||||
return ftyp.Concat(mdat).Concat(moov).ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsDurationFromMoovAfterMediaData()
|
||||
{
|
||||
Assert.Equal(95.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(Mp4(600, 57_000))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsVersionOneMovieHeader()
|
||||
{
|
||||
Assert.Equal(30.5, VideoDurationProbe.TryReadDurationSeconds(
|
||||
new MemoryStream(Mp4(1000, 30_500, version1: true))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FollowsSixtyFourBitBoxSizes()
|
||||
{
|
||||
var ftyp = Box("ftyp", Encoding.ASCII.GetBytes("qt "), new byte[4]);
|
||||
var mdatBody = new byte[512];
|
||||
var mdat = new byte[16 + mdatBody.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(mdat, 1);
|
||||
Encoding.ASCII.GetBytes("mdat").CopyTo(mdat, 4);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(mdat.AsSpan(8), (ulong)mdat.Length);
|
||||
var moov = Box("moov", MovieHeader(90_000, 90_000UL * 120));
|
||||
var file = ftyp.Concat(mdat).Concat(moov).ToArray();
|
||||
|
||||
Assert.Equal(120.0, VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(file)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("no-moov")]
|
||||
[InlineData("truncated")]
|
||||
[InlineData("zero-timescale")]
|
||||
[InlineData("oversized-box")]
|
||||
public void UnreadableStructureReturnsNull(string shape)
|
||||
{
|
||||
var bytes = shape switch
|
||||
{
|
||||
"no-moov" => Box("ftyp", Encoding.ASCII.GetBytes("isom"), new byte[4]).Concat(Box("mdat", new byte[64])).ToArray(),
|
||||
"truncated" => Mp4(600, 57_000)[..^10],
|
||||
"zero-timescale" => Mp4(0, 57_000),
|
||||
_ => Box("ftyp", new byte[8]).Concat(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, (byte)'m', (byte)'o', (byte)'o', (byte)'v' }).ToArray(),
|
||||
};
|
||||
|
||||
Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new MemoryStream(bytes)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonSeekableStreamReturnsNull()
|
||||
{
|
||||
Assert.Null(VideoDurationProbe.TryReadDurationSeconds(new NonSeekableStream(Mp4(600, 57_000))));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(90.0, true)]
|
||||
[InlineData(90.5, false)]
|
||||
public void ContractAllowsUpToNinetySeconds(double seconds, bool allowed)
|
||||
{
|
||||
var message = WorkOrderMediaContract.ValidateVideoDuration(
|
||||
new MemoryStream(Mp4(1000, (ulong)(seconds * 1000))));
|
||||
|
||||
Assert.Equal(allowed ? null : "Videos must be 90 seconds or shorter.", message);
|
||||
}
|
||||
|
||||
private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes)
|
||||
{
|
||||
public override bool CanSeek => false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue