Initial commit
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages company logo files stored in Azure Blob Storage.
|
||||
/// Logos are stored in the <c>companylogos</c> container using the path
|
||||
/// <c>{companyId}/company-logo{ext}</c> (e.g. <c>7/company-logo.png</c>).
|
||||
/// The service enforces a 10 MB size limit and restricts uploads to common
|
||||
/// web-safe image formats. It also handles the "replace" case by deleting
|
||||
/// blobs of every other allowed extension before writing the new one, so a
|
||||
/// company can never end up with multiple active logos at different paths.
|
||||
/// </summary>
|
||||
public class CompanyLogoService : ICompanyLogoService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
|
||||
/// <summary>Maximum logo file size accepted on upload (10 MB).</summary>
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive list of image extensions permitted for company logos.
|
||||
/// SVG is included because some companies upload vector-format brand assets.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider and storage
|
||||
/// configuration (container names, etc.).
|
||||
/// </summary>
|
||||
public CompanyLogoService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the deterministic blob path for a company logo given its file
|
||||
/// extension. Using a fixed name (rather than a GUID) allows the path to
|
||||
/// be derived from <c>companyId</c> alone — useful for PDF embedding and
|
||||
/// sidebar rendering without an extra DB lookup.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <param name="extension">File extension including the leading dot (e.g. <c>.png</c>).</param>
|
||||
/// <returns>Blob-relative path such as <c>7/company-logo.png</c>.</returns>
|
||||
public string GetCompanyLogoPath(int companyId, string extension)
|
||||
{
|
||||
return $"{companyId}/company-logo{extension}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and uploads a new company logo, replacing any existing logo
|
||||
/// regardless of its extension. Old blobs at alternative extensions are
|
||||
/// deleted first so the container never holds stale copies.
|
||||
/// </summary>
|
||||
/// <param name="file">The uploaded file from the HTTP request.</param>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <returns>
|
||||
/// A tuple indicating success, the stored blob path (on success), and a
|
||||
/// human-readable error message (on failure).
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
|
||||
// Delete old logo (any extension) before saving new one
|
||||
await DeleteOldLogosAsync(companyId, extension);
|
||||
|
||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||
var contentType = GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
||||
|
||||
if (!result.Success)
|
||||
return (false, string.Empty, result.ErrorMessage);
|
||||
|
||||
return (true, blobName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the company logo blob at the given path from Azure Blob Storage.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveCompanyLogoAsync"/>.</param>
|
||||
/// <returns>Success flag and an error message on failure.</returns>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteCompanyLogoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return (false, "File path is empty");
|
||||
|
||||
return await _blobService.DeleteAsync(_settings.Containers.CompanyLogos, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw bytes of the company logo for embedding in PDFs or
|
||||
/// serving through the <c>/CompanyLogo</c> controller endpoint.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path of the logo.</param>
|
||||
/// <returns>
|
||||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||||
/// and an error message on failure.
|
||||
/// </returns>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetCompanyLogoAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is empty");
|
||||
|
||||
return await _blobService.DownloadAsync(_settings.Containers.CompanyLogos, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a logo blob exists at the given path without downloading it.
|
||||
/// Used by the sidebar to decide whether to show the tenant logo or fall back
|
||||
/// to the default PCL logo.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Blob-relative path to check.</param>
|
||||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||||
public async Task<bool> CompanyLogoExistsAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return false;
|
||||
|
||||
return await _blobService.ExistsAsync(_settings.Containers.CompanyLogos, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes any pre-existing logo blobs at extensions other than
|
||||
/// <paramref name="currentExtension"/> so that uploading a PNG logo
|
||||
/// automatically removes a previously stored JPG logo (and vice versa).
|
||||
/// Errors are intentionally swallowed — a missing old blob is not a failure.
|
||||
/// </summary>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <param name="currentExtension">
|
||||
/// The extension of the incoming upload; blobs with this extension are
|
||||
/// skipped because they will be overwritten by the caller.
|
||||
/// </param>
|
||||
private async Task DeleteOldLogosAsync(int companyId, string currentExtension)
|
||||
{
|
||||
foreach (var ext in AllowedExtensions)
|
||||
{
|
||||
if (ext == currentExtension) continue;
|
||||
var oldBlobName = GetCompanyLogoPath(companyId, ext);
|
||||
await _blobService.DeleteAsync(_settings.Containers.CompanyLogos, oldBlobName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// The correct content type is required so that browsers render the image
|
||||
/// inline rather than triggering a download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user