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; /// /// 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. /// public class AzureBlobStorageService : IAzureBlobStorageService { private readonly BlobServiceClient _blobServiceClient; private readonly ILogger _logger; /// /// Initializes the service using the storage connection string from . /// The 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. /// public AzureBlobStorageService( IOptions settings, ILogger logger) { _blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString); _logger = logger; } /// /// 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. /// 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."); } } /// /// 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. /// 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(), 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(), string.Empty, "An error occurred while retrieving the file."); } } /// /// Deletes a blob if it exists, returning success in both the deleted and not-found cases. /// Using DeleteIfExistsAsync makes the operation idempotent so callers do not need to /// check existence before deleting (e.g., during temp photo cleanup after quote promotion). /// 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."); } } /// /// 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 false on exception so that storage errors do not surface as unhandled faults. /// public async Task 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; } } /// /// Lists all blob names in a container that share the given prefix, used by /// 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. /// public async Task> ListBlobsByPrefixAsync(string containerName, string prefix) { try { var containerClient = _blobServiceClient.GetBlobContainerClient(containerName); var names = new List(); 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 []; } } }