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