354 lines
13 KiB
C#
354 lines
13 KiB
C#
using Azure.Storage.Blobs;
|
|
using Azure.Storage.Blobs.Models;
|
|
using System.Text.Json;
|
|
|
|
namespace Gateway.Services;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
public class ImageStorageService
|
|
{
|
|
private readonly BlobServiceClient _blobClient;
|
|
private readonly IHttpClientFactory _httpFactory;
|
|
private readonly ILogger<ImageStorageService> _logger;
|
|
private readonly string _containerName;
|
|
private readonly string _blobBaseUrl;
|
|
private readonly bool _isConfigured;
|
|
|
|
public ImageStorageService(
|
|
IHttpClientFactory httpFactory,
|
|
ILogger<ImageStorageService> logger,
|
|
IConfiguration config,
|
|
BlobServiceClient blobClient)
|
|
{
|
|
_httpFactory = httpFactory;
|
|
_logger = logger;
|
|
_blobClient = blobClient;
|
|
_containerName = config["BlobStorage:ContainerName"] ?? "creative-images";
|
|
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net";
|
|
_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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether blob storage is configured and available.
|
|
/// </summary>
|
|
public bool IsConfigured => _isConfigured;
|
|
|
|
/// <summary>
|
|
/// Process a Creative draft response, downloading and storing images in blob storage.
|
|
/// Returns the modified JSON with blob URLs replacing source URLs.
|
|
/// </summary>
|
|
public async Task<string> 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<Dictionary<string, object?>>();
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Process a single image: download and upload to blob storage.
|
|
/// </summary>
|
|
private async Task<Dictionary<string, object?>> 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<string, object?>
|
|
{
|
|
["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<string, string>
|
|
{
|
|
["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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuild the provider response JSON with processed image data.
|
|
/// </summary>
|
|
private static string RebuildResponseWithProcessedImages(
|
|
JsonDocument original,
|
|
List<Dictionary<string, object?>> processedImages)
|
|
{
|
|
var root = original.RootElement;
|
|
|
|
// Build new response maintaining structure
|
|
var response = new Dictionary<string, object?>
|
|
{
|
|
["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<string, object?>();
|
|
|
|
// 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
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert JsonElement to object for serialization.
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete all images for a specific draft.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete all images for a client.
|
|
/// </summary>
|
|
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"
|
|
};
|
|
}
|