Initial commit
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user