using SixLabors.ImageSharp; using Microsoft.Extensions.Caching.Memory; namespace Nuuru.Server.Services; public sealed record RemoteImageInfo( string ContentType, string DataUrl, int? Width, int? Height); public interface IRemoteImageInfoService { Task GetAsync(string? url, int maxBytes, CancellationToken cancellationToken = default); } public sealed class RemoteImageInfoService : IRemoteImageInfoService { private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(12); private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; private readonly ILogger _logger; public RemoteImageInfoService( IHttpClientFactory httpClientFactory, IMemoryCache cache, ILogger logger) { _httpClientFactory = httpClientFactory; _cache = cache; _logger = logger; } public async Task GetAsync(string? url, int maxBytes, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; if (!await RemoteFetchSupport.IsSafeRemoteUriAsync(uri, cancellationToken)) return null; var cacheKey = $"remote-image:{maxBytes}:{uri}"; if (_cache.TryGetValue(cacheKey, out var cached)) return cached; RemoteImageInfo? result = null; try { result = await FetchAsync(uri, maxBytes, cancellationToken); } catch (Exception ex) { _logger.LogDebug(ex, "Failed to fetch remote image metadata for {Url}", url); } _cache.Set(cacheKey, result, CacheDuration); return result; } private async Task FetchAsync(Uri uri, int maxBytes, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(5); using var request = new HttpRequestMessage(HttpMethod.Get, uri); RemoteFetchSupport.ApplyBrowserHeaders(request, uri, isImageRequest: true); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) return null; var contentType = response.Content.Headers.ContentType?.MediaType; if (string.IsNullOrWhiteSpace(contentType) || (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && !contentType.Equals("image/x-icon", StringComparison.OrdinalIgnoreCase) && !contentType.Equals("image/vnd.microsoft.icon", StringComparison.OrdinalIgnoreCase))) { return null; } if (response.Content.Headers.ContentLength is long contentLength && contentLength > maxBytes) return null; await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var bytes = await RemoteFetchSupport.ReadBytesUpToAsync(stream, maxBytes, cancellationToken); if (bytes == null || bytes.Length == 0) return null; int? width = null; int? height = null; try { using var imageStream = new MemoryStream(bytes, writable: false); var imageInfo = await Image.IdentifyAsync(imageStream, cancellationToken); if (imageInfo != null) { width = imageInfo.Width; height = imageInfo.Height; } } catch (Exception ex) { _logger.LogDebug(ex, "Failed to identify image dimensions for {Url}", uri); } return new RemoteImageInfo( contentType, $"data:{contentType};base64,{Convert.ToBase64String(bytes)}", width, height); } }