From 130386622fab17475cfb8cd56ab078679c54ff8e Mon Sep 17 00:00:00 2001 From: Dev Date: Wed, 8 Apr 2026 12:32:23 +0200 Subject: [PATCH] feat(10-01): create BrandingService with magic byte validation and auto-compression - Add IBrandingService interface with ImportLogoAsync, Save/Clear/GetMspLogoAsync - Add BrandingService: PNG/JPEG magic byte detection, rejects unsupported formats with descriptive error, auto-compresses files over 512 KB using WPF PresentationCore imaging - Add BrandingServiceTests: 9 tests covering validation, rejection, compression, CRUD - Deviation: used WPF BitmapEncoder/TransformedBitmap instead of System.Drawing.Bitmap (System.Drawing.Common not available without new NuGet package; WPF PresentationCore is in the existing stack per architectural decisions) --- .../Services/BrandingServiceTests.cs | 223 ++++++++++++++++++ SharepointToolbox/Services/BrandingService.cs | 152 ++++++++++++ .../Services/IBrandingService.cs | 11 + 3 files changed, 386 insertions(+) create mode 100644 SharepointToolbox.Tests/Services/BrandingServiceTests.cs create mode 100644 SharepointToolbox/Services/BrandingService.cs create mode 100644 SharepointToolbox/Services/IBrandingService.cs diff --git a/SharepointToolbox.Tests/Services/BrandingServiceTests.cs b/SharepointToolbox.Tests/Services/BrandingServiceTests.cs new file mode 100644 index 0000000..e0f1eb9 --- /dev/null +++ b/SharepointToolbox.Tests/Services/BrandingServiceTests.cs @@ -0,0 +1,223 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Persistence; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +[Trait("Category", "Unit")] +public class BrandingServiceTests : IDisposable +{ + private readonly string _tempRepoFile; + private readonly List _tempFiles = new(); + + public BrandingServiceTests() + { + _tempRepoFile = Path.GetTempFileName(); + File.Delete(_tempRepoFile); + } + + public void Dispose() + { + if (File.Exists(_tempRepoFile)) File.Delete(_tempRepoFile); + if (File.Exists(_tempRepoFile + ".tmp")) File.Delete(_tempRepoFile + ".tmp"); + foreach (var f in _tempFiles) + { + if (File.Exists(f)) File.Delete(f); + } + } + + private BrandingRepository CreateRepository() => new(_tempRepoFile); + private BrandingService CreateService() => new(CreateRepository()); + + private string WriteTempFile(byte[] bytes) + { + var path = Path.GetTempFileName(); + File.WriteAllBytes(path, bytes); + _tempFiles.Add(path); + return path; + } + + // Minimal valid 1x1 PNG bytes + private static byte[] MinimalPngBytes() + { + // Full 1x1 transparent PNG (67 bytes) + return new byte[] + { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, // width = 1 + 0x00, 0x00, 0x00, 0x01, // height = 1 + 0x08, 0x02, // bit depth = 8, color type = RGB + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + 0x00, 0x00, 0x00, 0x0C, // IDAT length + 0x49, 0x44, 0x41, 0x54, // IDAT + 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // compressed data + 0xE2, 0x21, 0xBC, 0x33, // CRC + 0x00, 0x00, 0x00, 0x00, // IEND length + 0x49, 0x45, 0x4E, 0x44, // IEND + 0xAE, 0x42, 0x60, 0x82 // CRC + }; + } + + // Minimal valid JPEG bytes (SOI + APP0 JFIF header + EOI) + private static byte[] MinimalJpegBytes() + { + return new byte[] + { + 0xFF, 0xD8, // SOI + 0xFF, 0xE0, // APP0 marker + 0x00, 0x10, // length = 16 + 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, // version 1.1 + 0x00, // aspect ratio units = 0 + 0x00, 0x01, 0x00, 0x01, // X/Y density = 1 + 0x00, 0x00, // thumbnail size + 0xFF, 0xD9 // EOI + }; + } + + [Fact] + public async Task ImportLogoAsync_ValidPng_ReturnsPngLogoData() + { + var service = CreateService(); + var pngBytes = MinimalPngBytes(); + var path = WriteTempFile(pngBytes); + + var result = await service.ImportLogoAsync(path); + + Assert.Equal("image/png", result.MimeType); + Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64); + } + + [Fact] + public async Task ImportLogoAsync_ValidJpeg_ReturnsJpegLogoData() + { + var service = CreateService(); + var jpegBytes = MinimalJpegBytes(); + var path = WriteTempFile(jpegBytes); + + var result = await service.ImportLogoAsync(path); + + Assert.Equal("image/jpeg", result.MimeType); + } + + [Fact] + public async Task ImportLogoAsync_BmpFile_ThrowsInvalidDataExceptionMentioningPngAndJpg() + { + var service = CreateService(); + // BMP magic bytes: 0x42 0x4D + var bmpBytes = new byte[] { 0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; + var path = WriteTempFile(bmpBytes); + + var ex = await Assert.ThrowsAsync(() => service.ImportLogoAsync(path)); + Assert.Contains("PNG", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JPG", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ImportLogoAsync_EmptyFile_ThrowsInvalidDataException() + { + var service = CreateService(); + var path = WriteTempFile(Array.Empty()); + + await Assert.ThrowsAsync(() => service.ImportLogoAsync(path)); + } + + [Fact] + public async Task ImportLogoAsync_FileUnder512KB_ReturnOriginalBytesUnmodified() + { + var service = CreateService(); + var pngBytes = MinimalPngBytes(); + var path = WriteTempFile(pngBytes); + + var result = await service.ImportLogoAsync(path); + + Assert.Equal(Convert.ToBase64String(pngBytes), result.Base64); + } + + [Fact] + public async Task ImportLogoAsync_FileOver512KB_ReturnsCompressedUnder512KB() + { + var service = CreateService(); + + // Create a large PNG image in memory (400x400 random pixels) + var largePngPath = Path.GetTempFileName(); + _tempFiles.Add(largePngPath); + + using (var bmp = new Bitmap(400, 400)) + { + var rng = new Random(42); + for (int y = 0; y < 400; y++) + for (int x = 0; x < 400; x++) + bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256))); + bmp.Save(largePngPath, ImageFormat.Png); + } + + var fileSize = new FileInfo(largePngPath).Length; + // PNG with random pixels should exceed 512 KB + // If not, we'll pad it + if (fileSize <= 512 * 1024) + { + // Generate a bigger image to be sure + using var bmp = new Bitmap(800, 800); + var rng = new Random(42); + for (int y = 0; y < 800; y++) + for (int x = 0; x < 800; x++) + bmp.SetPixel(x, y, Color.FromArgb(255, rng.Next(256), rng.Next(256), rng.Next(256))); + bmp.Save(largePngPath, ImageFormat.Png); + } + + fileSize = new FileInfo(largePngPath).Length; + Assert.True(fileSize > 512 * 1024, $"Test setup: PNG file must be > 512 KB but was {fileSize} bytes"); + + var result = await service.ImportLogoAsync(largePngPath); + + var decodedBytes = Convert.FromBase64String(result.Base64); + Assert.True(decodedBytes.Length <= 512 * 1024, + $"Compressed result must be <= 512 KB but was {decodedBytes.Length} bytes"); + } + + [Fact] + public async Task SaveMspLogoAsync_PersistsLogoInRepository() + { + var repo = CreateRepository(); + var service = new BrandingService(repo); + var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" }; + + await service.SaveMspLogoAsync(logo); + + var loaded = await repo.LoadAsync(); + Assert.NotNull(loaded.MspLogo); + Assert.Equal("abc123==", loaded.MspLogo.Base64); + Assert.Equal("image/png", loaded.MspLogo.MimeType); + } + + [Fact] + public async Task ClearMspLogoAsync_SetsMspLogoToNull() + { + var repo = CreateRepository(); + var service = new BrandingService(repo); + var logo = new LogoData { Base64 = "abc123==", MimeType = "image/png" }; + await service.SaveMspLogoAsync(logo); + + await service.ClearMspLogoAsync(); + + var loaded = await repo.LoadAsync(); + Assert.Null(loaded.MspLogo); + } + + [Fact] + public async Task GetMspLogoAsync_WhenNoLogoConfigured_ReturnsNull() + { + var service = CreateService(); + + var result = await service.GetMspLogoAsync(); + + Assert.Null(result); + } +} diff --git a/SharepointToolbox/Services/BrandingService.cs b/SharepointToolbox/Services/BrandingService.cs new file mode 100644 index 0000000..11abc5d --- /dev/null +++ b/SharepointToolbox/Services/BrandingService.cs @@ -0,0 +1,152 @@ +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Persistence; + +namespace SharepointToolbox.Services; + +public class BrandingService : IBrandingService +{ + private const int MaxSizeBytes = 512 * 1024; // 512 KB + + // PNG signature: first 4 bytes + private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 }; + // JPEG signature: first 3 bytes + private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF }; + + private readonly BrandingRepository _repository; + + public BrandingService(BrandingRepository repository) + { + _repository = repository; + } + + /// + /// Reads a file, validates that it is PNG or JPEG via magic bytes, auto-compresses if over 512 KB, + /// and returns a LogoData record. Does NOT persist anything — the caller decides where to store it. + /// + public async Task ImportLogoAsync(string filePath) + { + var bytes = await File.ReadAllBytesAsync(filePath); + + var mimeType = DetectMimeType(bytes); + + if (bytes.Length > MaxSizeBytes) + { + bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes); + } + + return new LogoData + { + Base64 = Convert.ToBase64String(bytes), + MimeType = mimeType + }; + } + + public async Task SaveMspLogoAsync(LogoData logo) + { + var settings = await _repository.LoadAsync(); + settings.MspLogo = logo; + await _repository.SaveAsync(settings); + } + + public async Task ClearMspLogoAsync() + { + var settings = await _repository.LoadAsync(); + settings.MspLogo = null; + await _repository.SaveAsync(settings); + } + + public async Task GetMspLogoAsync() + { + var settings = await _repository.LoadAsync(); + return settings.MspLogo; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static string DetectMimeType(byte[] bytes) + { + if (bytes.Length == 0) + throw new InvalidDataException("File is empty. Only PNG and JPG files are accepted."); + + if (bytes.Length >= 4 && bytes[0] == PngMagic[0] && bytes[1] == PngMagic[1] + && bytes[2] == PngMagic[2] && bytes[3] == PngMagic[3]) + return "image/png"; + + if (bytes.Length >= 3 && bytes[0] == JpegMagic[0] && bytes[1] == JpegMagic[1] + && bytes[2] == JpegMagic[2]) + return "image/jpeg"; + + throw new InvalidDataException( + "File format is not PNG or JPG. Only PNG and JPG are accepted."); + } + + /// + /// Compresses image bytes using WPF imaging (PresentationCore) to fit within . + /// Resizes proportionally to max 300x300 at quality 75 first pass; if still too large, 200x200 at quality 50. + /// + private static byte[] CompressToLimit(byte[] bytes, string mimeType, int maxBytes) + { + // First pass: resize to 300x300 max, quality 75 + var compressed = ResizeAndEncode(bytes, mimeType, 300, 75); + if (compressed.Length <= maxBytes) + return compressed; + + // Second pass: resize to 200x200 max, quality 50 + compressed = ResizeAndEncode(bytes, mimeType, 200, 50); + return compressed; + } + + private static byte[] ResizeAndEncode(byte[] originalBytes, string mimeType, int maxDimension, int quality) + { + // Decode source image using WPF BitmapDecoder + using var inputStream = new MemoryStream(originalBytes); + var decoder = BitmapDecoder.Create( + inputStream, + BitmapCreateOptions.PreservePixelFormat, + BitmapCacheOption.OnLoad); + + var frame = decoder.Frames[0]; + + // Calculate target dimensions (proportional scaling) + double srcWidth = frame.PixelWidth; + double srcHeight = frame.PixelHeight; + double scale = Math.Min((double)maxDimension / srcWidth, (double)maxDimension / srcHeight); + + // Only scale down, never up + if (scale >= 1.0) + scale = 1.0; + + int targetWidth = Math.Max(1, (int)(srcWidth * scale)); + int targetHeight = Math.Max(1, (int)(srcHeight * scale)); + + // Scale the bitmap using TransformedBitmap + var scaledBitmap = new TransformedBitmap( + frame, + new ScaleTransform(scale, scale)); + + // Encode to target format + using var outputStream = new MemoryStream(); + BitmapEncoder encoder = mimeType == "image/png" + ? new PngBitmapEncoder() + : CreateJpegEncoder(quality); + + encoder.Frames.Add(BitmapFrame.Create(scaledBitmap)); + encoder.Save(outputStream); + + return outputStream.ToArray(); + } + + private static BitmapEncoder CreateJpegEncoder(int quality) + { + return new JpegBitmapEncoder + { + QualityLevel = quality + }; + } +} diff --git a/SharepointToolbox/Services/IBrandingService.cs b/SharepointToolbox/Services/IBrandingService.cs new file mode 100644 index 0000000..049abb3 --- /dev/null +++ b/SharepointToolbox/Services/IBrandingService.cs @@ -0,0 +1,11 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +public interface IBrandingService +{ + Task ImportLogoAsync(string filePath); + Task SaveMspLogoAsync(LogoData logo); + Task ClearMspLogoAsync(); + Task GetMspLogoAsync(); +}