using Microsoft.AspNetCore.Http;
namespace SeaHaven.Services.Helpers
{
/// SH-116 media type allowlist for board work-order uploads.
public static class WorkOrderMediaFileRules
{
private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg",
"image/png",
"video/mp4",
"video/quicktime"
};
private static readonly Dictionary> ExtensionsByContentType =
new(StringComparer.OrdinalIgnoreCase)
{
["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" },
["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" },
["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }
};
public static bool IsAllowed(IFormFile file)
{
if (file == null || file.Length <= 0)
return false;
var contentType = (file.ContentType ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType))
return false;
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension)
|| !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions)
|| !allowedExtensions.Contains(extension))
{
return false;
}
try
{
using var stream = file.OpenReadStream();
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64);
if (headerLength == 0)
return false;
var header = new byte[headerLength];
var read = stream.Read(header, 0, header.Length);
if (read <= 0)
return false;
if (read < header.Length)
Array.Resize(ref header, read);
return MatchesSignature(contentType, header);
}
catch
{
return false;
}
}
public static void EnsureAllowed(IFormFile file)
{
if (!IsAllowed(file))
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported media types are JPG, PNG, MP4, and MOV.");
}
}
internal static bool MatchesSignature(string contentType, byte[] bytes)
{
if (bytes.Length == 0)
return false;
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))
{
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
}
if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
{
return HasFtypBox(bytes);
}
return false;
}
private static bool HasFtypBox(byte[] bytes)
{
if (bytes.Length < 12)
return false;
// ISO BMFF: [size:4][ftyp:4][major_brand:4]...
return bytes[4] == (byte)'f'
&& bytes[5] == (byte)'t'
&& bytes[6] == (byte)'y'
&& bytes[7] == (byte)'p';
}
}
}