Files
PowderCoatingLogix/src/PowderCoating.Application/Services/AzureBlobStorageService.cs
T
2026-04-23 21:38:24 -04:00

175 lines
7.4 KiB
C#

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Azure Blob Storage wrapper that provides a uniform, result-tuple API for blob operations used
/// throughout the application (company logos, quote photos, documents). Containers are created
/// on first use with private access so blobs are never publicly URL-accessible; all serving goes
/// through controller actions that perform authorization checks before streaming bytes.
/// All methods catch exceptions and return a failure result rather than propagating so that blob
/// storage errors degrade gracefully without crashing the request pipeline.
/// </summary>
public class AzureBlobStorageService : IAzureBlobStorageService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorageService> _logger;
/// <summary>
/// Initializes the service using the storage connection string from <see cref="StorageSettings"/>.
/// The <see cref="BlobServiceClient"/> is created once and reused across requests because it is
/// registered as a singleton in DI, matching the thread-safe design of the Azure SDK client.
/// </summary>
public AzureBlobStorageService(
IOptions<StorageSettings> settings,
ILogger<AzureBlobStorageService> logger)
{
_blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
_logger = logger;
}
/// <summary>
/// Uploads a stream to the specified blob path, setting the Content-Type header so that the
/// Azure portal and download responses report the correct MIME type. The container is created
/// with private access if it does not already exist, preventing accidental public exposure.
/// </summary>
public async Task<(bool Success, string ErrorMessage)> UploadAsync(
string containerName,
string blobName,
Stream content,
string contentType)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);
var blobClient = containerClient.GetBlobClient(blobName);
var uploadOptions = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = contentType }
};
await blobClient.UploadAsync(content, uploadOptions);
_logger.LogInformation("Uploaded blob {BlobName} to container {Container}", blobName, containerName);
return (true, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading blob {BlobName} to container {Container}", blobName, containerName);
return (false, "An error occurred while uploading the file.");
}
}
/// <summary>
/// Downloads a blob's full content into memory and returns the byte array along with the
/// stored Content-Type. Checks blob existence before attempting download to return a clean
/// "not found" failure rather than surfacing an Azure SDK exception to the caller.
/// </summary>
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(
string containerName,
string blobName)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
if (!await blobClient.ExistsAsync())
{
return (false, Array.Empty<byte>(), string.Empty, "File not found.");
}
var response = await blobClient.DownloadContentAsync();
var content = response.Value.Content.ToArray();
var contentType = response.Value.Details.ContentType ?? "application/octet-stream";
return (true, content, contentType, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading blob {BlobName} from container {Container}", blobName, containerName);
return (false, Array.Empty<byte>(), string.Empty, "An error occurred while retrieving the file.");
}
}
/// <summary>
/// Deletes a blob if it exists, returning success in both the deleted and not-found cases.
/// Using <c>DeleteIfExistsAsync</c> makes the operation idempotent so callers do not need to
/// check existence before deleting (e.g., during temp photo cleanup after quote promotion).
/// </summary>
public async Task<(bool Success, string ErrorMessage)> DeleteAsync(
string containerName,
string blobName)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
var deleted = await blobClient.DeleteIfExistsAsync();
if (deleted)
_logger.LogInformation("Deleted blob {BlobName} from container {Container}", blobName, containerName);
return (true, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting blob {BlobName} from container {Container}", blobName, containerName);
return (false, "An error occurred while deleting the file.");
}
}
/// <summary>
/// Checks whether a blob exists without downloading its content.
/// Used by controllers to determine whether to show a file download link or a
/// "no file available" placeholder, avoiding a full download just for an existence check.
/// Returns <c>false</c> on exception so that storage errors do not surface as unhandled faults.
/// </summary>
public async Task<bool> ExistsAsync(string containerName, string blobName)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
var response = await blobClient.ExistsAsync();
return response.Value;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking existence of blob {BlobName} in container {Container}", blobName, containerName);
return false;
}
}
/// <summary>
/// Lists all blob names in a container that share the given prefix, used by
/// <see cref="QuotePhotoService"/> to enumerate all photos belonging to a temp session or a
/// specific quote. Returns an empty enumerable on exception so callers can safely iterate
/// without null checks.
/// </summary>
public async Task<IEnumerable<string>> ListBlobsByPrefixAsync(string containerName, string prefix)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var names = new List<string>();
await foreach (var item in containerClient.GetBlobsAsync(prefix: prefix))
names.Add(item.Name);
return names;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing blobs with prefix {Prefix} in container {Container}", prefix, containerName);
return [];
}
}
}