175 lines
7.4 KiB
C#
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 [];
|
|
}
|
|
}
|
|
}
|