Files
Dev 9e850b07f2 feat(11-04): add UpdateProfileAsync to ProfileService and ImportLogoFromBytesAsync to BrandingService
- ProfileService.UpdateProfileAsync: replaces profile by name and persists the change
- IBrandingService: add ImportLogoFromBytesAsync to interface contract
- BrandingService.ImportLogoFromBytesAsync: validates magic bytes, compresses if > 512KB, returns LogoData
- BrandingService.ImportLogoAsync: refactored to delegate to ImportLogoFromBytesAsync
- ProfileServiceTests: 2 new tests (UpdateProfileAsync happy path + KeyNotFoundException)
- BrandingServiceTests: 2 new tests (ImportLogoFromBytesAsync valid PNG + invalid bytes)
- Tests.csproj: suppress NU1701 for pre-existing LiveCharts2/OpenTK transitive warnings
2026-04-08 14:34:11 +02:00

161 lines
5.4 KiB
C#

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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<LogoData> ImportLogoAsync(string filePath)
{
var bytes = await File.ReadAllBytesAsync(filePath);
return await ImportLogoFromBytesAsync(bytes);
}
/// <summary>
/// Validates raw bytes as PNG or JPEG via magic bytes, auto-compresses if over 512 KB,
/// and returns a LogoData record. Used when bytes are obtained from a stream (e.g. Entra branding API).
/// </summary>
public Task<LogoData> ImportLogoFromBytesAsync(byte[] bytes)
{
var mimeType = DetectMimeType(bytes);
if (bytes.Length > MaxSizeBytes)
{
bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes);
}
return Task.FromResult(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<LogoData?> 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.");
}
/// <summary>
/// Compresses image bytes using WPF imaging (PresentationCore) to fit within <paramref name="maxBytes"/>.
/// Resizes proportionally to max 300x300 at quality 75 first pass; if still too large, 200x200 at quality 50.
/// </summary>
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
};
}
}