using Microsoft.Extensions.Caching.Memory; using System.Text.Json; using System.Text.Json.Serialization; namespace Nuuru.Server.Services { public interface IIpIntelligenceService { Task LookupAsync(string ipAddress, string? lookupUrl = null, CancellationToken cancellationToken = default); } public sealed class IpIntelligenceResult { public string? IpAddress { get; init; } public bool IsFlagged { get; init; } public string? CountryCode { get; init; } public string? RegionCode { get; init; } public string? Region { get; init; } public string? City { get; init; } public string? IspAsn { get; init; } public string? IspName { get; init; } } public class IpIntelligenceService : IIpIntelligenceService { private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; private const string DefaultLookupUrl = "http://localhost:8080/ip"; private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(7); private static readonly TimeSpan FailedLookupCacheDuration = TimeSpan.FromMinutes(1); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; public IpIntelligenceService( IHttpClientFactory httpClientFactory, IMemoryCache memoryCache, ILogger logger) { _httpClientFactory = httpClientFactory; _memoryCache = memoryCache; _logger = logger; } public async Task LookupAsync(string ipAddress, string? lookupUrl = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(ipAddress)) { return null; } var normalizedIp = ipAddress.Trim(); var normalizedLookupUrl = string.IsNullOrWhiteSpace(lookupUrl) ? DefaultLookupUrl : lookupUrl.Trim(); var cacheKey = BuildCacheKey(normalizedIp, normalizedLookupUrl); if (_memoryCache.TryGetValue(cacheKey, out CachedLookup? cachedLookup)) { return cachedLookup?.Value; } var client = _httpClientFactory.CreateClient(); var endpoints = BuildCandidateEndpoints(normalizedIp, normalizedLookupUrl); foreach (var endpoint in endpoints) { try { using var response = await client.GetAsync(endpoint, cancellationToken); if (!response.IsSuccessStatusCode) { continue; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var payload = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); if (payload == null) { continue; } var result = new IpIntelligenceResult { IpAddress = payload.Ip, IsFlagged = payload.Flagged, CountryCode = payload.Geo?.CountryCode, RegionCode = payload.Geo?.RegionCode, Region = payload.Geo?.Region, City = payload.Geo?.City, IspAsn = payload.AutonomousSystem?.Asn, IspName = payload.AutonomousSystem?.Name }; _memoryCache.Set( cacheKey, new CachedLookup { Value = result }, CacheDuration); return result; } catch (Exception ex) { _logger.LogWarning(ex, "IP intelligence lookup failed for endpoint {Endpoint}", endpoint); } } _memoryCache.Set( cacheKey, new CachedLookup { Value = null }, FailedLookupCacheDuration); return null; } private static string BuildCacheKey(string ipAddress, string lookupUrl) => $"ip-intel:{lookupUrl}:{ipAddress}"; private sealed class CachedLookup { public IpIntelligenceResult? Value { get; init; } } private static IEnumerable BuildCandidateEndpoints(string ipAddress, string? lookupUrl) { var baseLookup = (lookupUrl ?? DefaultLookupUrl).Trim(); if (baseLookup.Contains("{ip}", StringComparison.OrdinalIgnoreCase)) { yield return baseLookup.Replace("{ip}", Uri.EscapeDataString(ipAddress), StringComparison.OrdinalIgnoreCase); yield break; } var trimmed = baseLookup.TrimEnd('/'); yield return $"{trimmed}/{Uri.EscapeDataString(ipAddress)}"; yield return $"{trimmed}?ip={Uri.EscapeDataString(ipAddress)}"; yield return trimmed; } private sealed class IpLookupResponse { [JsonPropertyName("ip")] public string? Ip { get; set; } [JsonPropertyName("flagged")] public bool Flagged { get; set; } [JsonPropertyName("as")] public AsInfo? AutonomousSystem { get; set; } [JsonPropertyName("geo")] public GeoInfo? Geo { get; set; } } private sealed class AsInfo { [JsonPropertyName("asn")] public string? Asn { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } } private sealed class GeoInfo { [JsonPropertyName("country_code")] public string? CountryCode { get; set; } [JsonPropertyName("region_code")] public string? RegionCode { get; set; } [JsonPropertyName("region")] public string? Region { get; set; } [JsonPropertyName("city")] public string? City { get; set; } } } }