using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using System.Text.Json; namespace Gateway.Services; /// /// Handles downloading images from source URLs and storing them in Azure Blob Storage. /// Used when processing Creative drafts to ensure all image URLs are permanent. /// /// Blob structure: {clientId}/drafts/{draftId}/{orientation}.{ext} /// Example: client-42/drafts/a1b2c3d4e5f6/landscape.jpg /// /// This structure enables: /// - Client isolation (easy to list/delete all client assets) /// - Draft organization (images grouped per draft) /// - Future expansion (campaigns, versions, etc.) /// - Per-client access control via SAS tokens if needed /// public class ImageStorageService { private readonly BlobServiceClient _blobClient; private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; private readonly string _containerName; private readonly string _blobBaseUrl; private readonly bool _isConfigured; public ImageStorageService( IHttpClientFactory httpFactory, ILogger logger, IConfiguration config, BlobServiceClient blobClient) { _httpFactory = httpFactory; _logger = logger; _blobClient = blobClient; _containerName = config["BlobStorage:ContainerName"] ?? "creative-images"; _blobBaseUrl = config["BlobStorage:BaseUrl"] ?? string.Empty; _isConfigured = blobClient != null; if (!_isConfigured) { _logger.LogWarning("[ImageStorage] Blob storage not configured - images will use source URLs"); } else { _logger.LogInformation("[ImageStorage] Blob storage configured: {BaseUrl}/{Container}", _blobBaseUrl, _containerName); } } /// /// Whether blob storage is configured and available. /// public bool IsConfigured => _isConfigured; /// /// Process a Creative draft response, downloading and storing images in blob storage. /// Returns the modified JSON with blob URLs replacing source URLs. /// public async Task ProcessCreativeDraftAsync( string clientId, string providerResponseJson, CancellationToken ct) { if (!_isConfigured) { _logger.LogDebug("[ImageStorage] Skipping image processing - not configured"); return providerResponseJson; } try { using var doc = JsonDocument.Parse(providerResponseJson); var root = doc.RootElement; // Check if this is a successful response with data if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean()) return providerResponseJson; if (!root.TryGetProperty("data", out var dataProp)) return providerResponseJson; // Check if data has images array if (!dataProp.TryGetProperty("images", out var imagesProp) || imagesProp.ValueKind != JsonValueKind.Array || imagesProp.GetArrayLength() == 0) { _logger.LogDebug("[ImageStorage] No images in draft response"); return providerResponseJson; } // Get draftId var draftId = dataProp.TryGetProperty("draftId", out var draftIdProp) ? draftIdProp.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12]; _logger.LogInformation( "[ImageStorage] Processing {Count} images for client {ClientId} draft {DraftId}", imagesProp.GetArrayLength(), clientId, draftId); // Ensure container exists var containerClient = _blobClient.GetBlobContainerClient(_containerName); await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: ct); // Process each image and collect results var processedImages = new List>(); foreach (var image in imagesProp.EnumerateArray()) { var processedImage = await ProcessSingleImageAsync( containerClient, clientId, draftId, image, ct); processedImages.Add(processedImage); } // Rebuild the response with updated image URLs return RebuildResponseWithProcessedImages(doc, processedImages); } catch (Exception ex) { _logger.LogError(ex, "[ImageStorage] Failed to process draft images, returning original response"); return providerResponseJson; } } /// /// Process a single image: download and upload to blob storage. /// private async Task> ProcessSingleImageAsync( BlobContainerClient container, string clientId, string draftId, JsonElement image, CancellationToken ct) { // Extract image properties var imageId = image.TryGetProperty("imageId", out var idProp) ? idProp.GetString() : null; var sourceUrl = image.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null; var downloadUrl = image.TryGetProperty("downloadUrl", out var dlProp) ? dlProp.GetString() : null; var orientation = image.TryGetProperty("orientation", out var orProp) ? orProp.GetString() ?? "unknown" : "unknown"; var source = image.TryGetProperty("source", out var srcProp) ? srcProp.GetString() : "unknown"; var width = image.TryGetProperty("width", out var wProp) ? wProp.GetInt32() : 0; var height = image.TryGetProperty("height", out var hProp) ? hProp.GetInt32() : 0; var altText = image.TryGetProperty("altText", out var altProp) ? altProp.GetString() : null; var attribution = image.TryGetProperty("attribution", out var attrProp) ? attrProp.GetString() : null; // Build result with original properties var result = new Dictionary { ["imageId"] = imageId, ["url"] = sourceUrl, // Will be replaced with blob URL on success ["source"] = source, ["orientation"] = orientation, ["width"] = width, ["height"] = height, ["altText"] = altText, ["attribution"] = attribution, ["downloadUrl"] = downloadUrl, ["blobStored"] = false // Track whether we stored it }; if (string.IsNullOrEmpty(sourceUrl)) { _logger.LogWarning("[ImageStorage] Image has no URL, skipping"); return result; } try { // Download image bytes (prefer download URL for higher quality) var fetchUrl = !string.IsNullOrEmpty(downloadUrl) ? downloadUrl : sourceUrl; var httpClient = _httpFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(30); _logger.LogDebug("[ImageStorage] Downloading from {Url}", fetchUrl); using var response = await httpClient.GetAsync(fetchUrl, ct); response.EnsureSuccessStatusCode(); var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"; var extension = GetExtensionFromContentType(contentType); // Build blob path: {clientId}/drafts/{draftId}/{orientation}.{ext} var blobName = $"{clientId}/drafts/{draftId}/{orientation}.{extension}"; var blobClient = container.GetBlobClient(blobName); // Upload with proper content type and caching headers await using var stream = await response.Content.ReadAsStreamAsync(ct); var uploadOptions = new BlobUploadOptions { HttpHeaders = new BlobHttpHeaders { ContentType = contentType, CacheControl = "public, max-age=31536000" // 1 year cache }, Metadata = new Dictionary { ["source"] = source ?? "unknown", ["originalUrl"] = sourceUrl, ["orientation"] = orientation, ["width"] = width.ToString(), ["height"] = height.ToString(), ["clientId"] = clientId, ["draftId"] = draftId } }; await blobClient.UploadAsync(stream, uploadOptions, ct); // Build permanent blob URL var blobUrl = $"{_blobBaseUrl}/{_containerName}/{blobName}"; result["url"] = blobUrl; result["blobStored"] = true; result["originalUrl"] = sourceUrl; // Keep original for reference _logger.LogInformation("[ImageStorage] Stored {Orientation} image: {BlobUrl}", orientation, blobUrl); } catch (Exception ex) { _logger.LogWarning(ex, "[ImageStorage] Failed to store {Orientation} image, keeping original URL", orientation); // Keep original URL as fallback } return result; } /// /// Rebuild the provider response JSON with processed image data. /// private static string RebuildResponseWithProcessedImages( JsonDocument original, List> processedImages) { var root = original.RootElement; // Build new response maintaining structure var response = new Dictionary { ["ok"] = root.GetProperty("ok").GetBoolean(), ["requestId"] = root.TryGetProperty("requestId", out var rid) ? rid.GetString() : null }; // Rebuild data object with processed images if (root.TryGetProperty("data", out var dataProp)) { var data = new Dictionary(); // Copy all data properties except images foreach (var prop in dataProp.EnumerateObject()) { if (prop.Name == "images") continue; data[prop.Name] = JsonElementToObject(prop.Value); } // Add processed images data["images"] = processedImages; response["data"] = data; } // Copy error if present if (root.TryGetProperty("error", out var errorProp)) { response["error"] = JsonElementToObject(errorProp); } return JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); } /// /// Convert JsonElement to object for serialization. /// private static object? JsonElementToObject(JsonElement element) { return element.ValueKind switch { JsonValueKind.Object => element.EnumerateObject() .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), JsonValueKind.Array => element.EnumerateArray() .Select(JsonElementToObject).ToList(), JsonValueKind.String => element.GetString(), JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, _ => null }; } /// /// Delete all images for a specific draft. /// public async Task DeleteDraftImagesAsync(string clientId, string draftId, CancellationToken ct) { if (!_isConfigured) return; var containerClient = _blobClient.GetBlobContainerClient(_containerName); var prefix = $"{clientId}/drafts/{draftId}/"; await foreach (var blob in containerClient.GetBlobsAsync( traits: BlobTraits.None, states: BlobStates.None, prefix: prefix, cancellationToken: ct)) { await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct); _logger.LogInformation("[ImageStorage] Deleted blob {Name}", blob.Name); } } /// /// Delete all images for a client. /// public async Task DeleteClientImagesAsync(string clientId, CancellationToken ct) { if (!_isConfigured) return; var containerClient = _blobClient.GetBlobContainerClient(_containerName); var prefix = $"{clientId}/"; var count = 0; await foreach (var blob in containerClient.GetBlobsAsync( traits: BlobTraits.None, states: BlobStates.None, prefix: prefix, cancellationToken: ct)) { await containerClient.DeleteBlobIfExistsAsync(blob.Name, cancellationToken: ct); count++; } _logger.LogInformation("[ImageStorage] Deleted {Count} blobs for client {ClientId}", count, clientId); } private static string GetExtensionFromContentType(string contentType) => contentType switch { "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp", "image/svg+xml" => "svg", _ => "jpg" }; }