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;
}
public static bool IsAllowed(
IFormFile file,
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
{
if (file == null || file.Length <= 0)
return false;
var declaredType = (file.ContentType ?? string.Empty).Trim();
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var resolvedType = ResolveContentType(declaredType, extension);
if (resolvedType == null)
return false;
var contentType = CanonicalContentType(resolvedType);
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
};
}
}
}