mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Mobile browsers attach an unreliable Content-Type to a picked file: empty or application/octet-stream when the OS cannot classify it, and sometimes a foreign-but-plausible type for a supported container (video/3gpp for an .mp4, video/x-quicktime for a .mov). The media allowlist already resolved empty and octet-stream types from the extension, but a concrete foreign type was rejected outright, so a real .MP4/.MOV picked on a phone passed the client dialog and was refused by the API. Any declared type that is not itself on the allowlist now falls back to the extension. The extension pairing and magic-byte signature still decide, so the accepted set of files is unchanged; an allowlisted declared type stays authoritative and must still match its own extension.
210 lines
8.5 KiB
C#
210 lines
8.5 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using System.IO.Compression;
|
|
|
|
namespace SeaHaven.Services.Helpers
|
|
{
|
|
/// <summary>SH-116 media type allowlist for board work-order uploads.</summary>
|
|
public static class WorkOrderMediaFileRules
|
|
{
|
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"video/mp4",
|
|
"video/quicktime",
|
|
"application/pdf",
|
|
"application/msword",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
};
|
|
|
|
private static readonly Dictionary<string, HashSet<string>> ExtensionsByContentType =
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
|
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
|
|
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
|
|
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
|
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
|
|
["application/msword"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".doc" },
|
|
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
|
|
new HashSet<string>(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)
|
|
{
|
|
if (AllowedContentTypes.Contains(declaredType))
|
|
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/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<byte> 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';
|
|
}
|
|
}
|
|
}
|