using Microsoft.AspNetCore.Http; using Data.SeaHavenIndustries.Enums; using System.IO.Compression; 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/jpg", "image/png", "image/heic", "video/mp4", "video/quicktime", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private static readonly Dictionary> ExtensionsByContentType = new(StringComparer.OrdinalIgnoreCase) { ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, ["image/heic"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".heic" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, ["application/msword"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc" }, ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" } }; // The multipart part's Content-Type comes from the browser's File.type, which is // unreliable on mobile: it is left empty or sent as application/octet-stream when the // OS cannot classify a picked file, and is sometimes a foreign-but-plausible video // type the OS attaches to a supported container (e.g. video/3gpp for an .mp4, // video/x-quicktime for a .mov). An allowlisted declared type stays authoritative and // pairs against its own extension; anything else falls back to the extension. The // extension pairing and magic-byte signature below still decide, so this never widens // the accepted set of files. private static string? ResolveContentType(string declaredType, string extension) { // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic as // its type, so a declared image/heic is only authoritative on a real .heic file. var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) return declaredType; foreach (var (contentType, extensions) in ExtensionsByContentType) { if (extensions.Contains(extension)) return contentType; } return null; } private static string CanonicalContentType(string contentType) { if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) return "image/jpeg"; return contentType; } /// /// The canonical content type validates the file as, or null when /// no allowlisted type applies. Size classification must use this same type so a declared /// type or a misleading extension cannot move a file into a larger size class. /// public static string? ResolveUploadContentType(IFormFile file) { var declaredType = (file.ContentType ?? string.Empty).Trim(); var extension = Path.GetExtension(file.FileName ?? string.Empty); var resolvedType = ResolveContentType(declaredType, extension); return resolvedType == null ? null : CanonicalContentType(resolvedType); } public static bool IsAllowed( IFormFile file, WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (file == null || file.Length <= 0) return false; var extension = Path.GetExtension(file.FileName ?? string.Empty); var contentType = ResolveUploadContentType(file); if (contentType == null) return false; var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; if (IsDocument(contentType) && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) { return false; } 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), 512); 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); if (IsDocx(contentType)) return IsWordDocumentArchive(stream); return MatchesSignature(contentType, header); } catch { return false; } } public static void EnsureAllowed( IFormFile file, WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (!IsAllowed(file, category)) { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } 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/heic", StringComparison.OrdinalIgnoreCase)) { return IsHeifBrand(bytes); } if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)) { return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; } if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) { // %PDF- return bytes.Length >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D; } if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase) || contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase)) { return HasFtypBox(bytes); } if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) return bytes.Length >= 4 && bytes.AsSpan(0, 4).SequenceEqual("%PDF"u8); if (contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase)) { ReadOnlySpan oleSignature = stackalloc byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }; return bytes.Length >= oleSignature.Length && bytes.AsSpan(0, oleSignature.Length).SequenceEqual(oleSignature); } return false; } private static bool IsDocument(string contentType) => contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) || contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase) || IsDocx(contentType); private static bool IsDocx(string contentType) => contentType.Equals( "application/vnd.openxmlformats-officedocument.wordprocessingml.document", StringComparison.OrdinalIgnoreCase); private static bool IsWordDocumentArchive(Stream stream) { if (!stream.CanSeek) return false; stream.Position = 0; using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); return archive.Entries.Any(entry => entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)); } 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'; } private static bool IsHeifBrand(byte[] bytes) { if (!HasFtypBox(bytes)) return false; // HEIC/HEIF containers identify by the ftyp major brand (bytes 8..11). var brand = System.Text.Encoding.ASCII.GetString(bytes, 8, 4); return brand switch { "heic" or "heix" or "hevc" or "hevx" or "heim" or "heis" or "hevm" or "hevs" or "mif1" or "msf1" => true, _ => false }; } } }