From 47022c7761e5f54e7da89f8ef83f5d4979afcbd3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:02:40 -0300 Subject: [PATCH] feat(work-orders): allow Extra Docs PDF/DOC by category --- .../Helpers/WorkOrderMediaFileRules.cs | 71 ++++++++++++++++-- .../WorkOrderPhase6Tests.cs | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index bc03e6c..4786552 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http; +using Data.SeaHavenIndustries.Enums; +using System.IO.Compression; namespace SeaHaven.Services.Helpers { @@ -10,7 +12,10 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/png", "video/mp4", - "video/quicktime" + "video/quicktime", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private static readonly Dictionary> ExtensionsByContentType = @@ -19,10 +24,16 @@ namespace SeaHaven.Services.Helpers ["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" } + ["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" } }; - public static bool IsAllowed(IFormFile file) + public static bool IsAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (file == null || file.Length <= 0) return false; @@ -31,6 +42,13 @@ namespace SeaHaven.Services.Helpers if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) return false; + var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; + if (IsDocument(contentType) + && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) + { + return false; + } + var extension = Path.GetExtension(file.FileName ?? string.Empty); if (string.IsNullOrWhiteSpace(extension) || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) @@ -42,7 +60,7 @@ namespace SeaHaven.Services.Helpers try { using var stream = file.OpenReadStream(); - var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64); + var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 512); if (headerLength == 0) return false; @@ -54,6 +72,9 @@ namespace SeaHaven.Services.Helpers if (read < header.Length) Array.Resize(ref header, read); + if (IsDocx(contentType)) + return IsWordDocumentArchive(stream); + return MatchesSignature(contentType, header); } catch @@ -62,13 +83,15 @@ namespace SeaHaven.Services.Helpers } } - public static void EnsureAllowed(IFormFile file) + public static void EnsureAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { - if (!IsAllowed(file)) + if (!IsAllowed(file, category)) { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", - "Supported media types are JPG, PNG, MP4, and MOV."); + "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } @@ -95,9 +118,43 @@ namespace SeaHaven.Services.Helpers 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) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1ee9a4f..a0f81cd 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text; +using System.IO.Compression; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -1741,6 +1742,19 @@ public class WorkOrderMediaFileRulesTests ContentType = contentType }; + private static byte[] DocxBytes(bool includeWordEntry) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(includeWordEntry ? "word/document.xml" : "not-word/content.xml"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(""); + } + + return stream.ToArray(); + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { @@ -1790,4 +1804,65 @@ public class WorkOrderMediaFileRulesTests WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png"))); Assert.Equal("UnsupportedMediaType", ex.Code); } + + [Theory] + [InlineData(WorkOrderMediaCategory.Extra)] + [InlineData(WorkOrderMediaCategory.Aveta)] + public void EnsureAllowed_PdfForDocumentCategory_Succeeds(WorkOrderMediaCategory category) + { + var pdf = Encoding.ASCII.GetBytes("%PDF-1.7"); + + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(pdf, "report.pdf", "application/pdf"), + category); + } + + [Fact] + public void EnsureAllowed_DocWithOleSignatureForExtra_Succeeds() + { + var doc = new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x00 }; + + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(doc, "report.doc", "application/msword"), + WorkOrderMediaCategory.Extra); + } + + [Fact] + public void EnsureAllowed_RealDocxForExtra_Succeeds() + { + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: true), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra); + } + + [Theory] + [InlineData(WorkOrderMediaCategory.Before)] + [InlineData(WorkOrderMediaCategory.After)] + public void EnsureAllowed_PdfForPhotoCategory_ThrowsUnsupportedMediaType( + WorkOrderMediaCategory category) + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(Encoding.ASCII.GetBytes("%PDF-1.7"), "report.pdf", "application/pdf"), + category)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } + + [Fact] + public void EnsureAllowed_ZipWithoutWordEntry_ThrowsUnsupportedMediaType() + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: false), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } }