using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; namespace Nuuru.Server.Services; public sealed class CloudflareCachePurgeOptions { public const string SectionName = "CloudflarePurge"; public string Domain { get; set; } = string.Empty; public List Domains { get; set; } = []; public string ZoneId { get; set; } = string.Empty; public string Token { get; set; } = string.Empty; } public interface ICloudflareCachePurgeService { Task PurgeUriAsync(string uri, CancellationToken cancellationToken = default); Task PurgeUrisAsync(IEnumerable uris, CancellationToken cancellationToken = default); } public sealed class CloudflareCachePurgeService : ICloudflareCachePurgeService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly IHttpClientFactory _httpClientFactory; private readonly CloudflareCachePurgeOptions _options; private readonly ILogger _logger; public CloudflareCachePurgeService( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger) { _httpClientFactory = httpClientFactory; _options = options.Value; _logger = logger; } public async Task PurgeUriAsync(string uri, CancellationToken cancellationToken = default) { return await PurgeUrisAsync([uri], cancellationToken); } public async Task PurgeUrisAsync(IEnumerable uris, CancellationToken cancellationToken = default) { var absoluteUrls = BuildAbsoluteUrls(uris); if (absoluteUrls.Count == 0) { return false; } if (!HasApiCredentials()) { _logger.LogDebug("Skipping Cloudflare purge because CloudflarePurge is not fully configured."); return false; } var payload = JsonSerializer.Serialize(new { files = absoluteUrls }, JsonOptions); using var request = new HttpRequestMessage( HttpMethod.Post, $"https://api.cloudflare.com/client/v4/zones/{_options.ZoneId}/purge_cache"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.Token); request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); try { var client = _httpClientFactory.CreateClient(); using var response = await client.SendAsync(request, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Cloudflare purge failed for {Urls} with status {StatusCode}: {Body}", absoluteUrls, (int)response.StatusCode, body); return false; } if (TryGetSuccess(body, out var success)) { if (!success) { _logger.LogWarning("Cloudflare purge returned success=false for {Urls}: {Body}", absoluteUrls, body); } return success; } return true; } catch (Exception ex) { _logger.LogWarning(ex, "Cloudflare purge threw for {Urls}", absoluteUrls); return false; } } private bool HasApiCredentials() { return !string.IsNullOrWhiteSpace(_options.ZoneId) && !string.IsNullOrWhiteSpace(_options.Token); } private List BuildAbsoluteUrls(IEnumerable uris) { var absoluteUrls = new List(); var configuredDomains = GetConfiguredDomains(); foreach (var uri in uris.Where(static x => !string.IsNullOrWhiteSpace(x))) { if (Uri.TryCreate(uri, UriKind.Absolute, out var absoluteUri)) { absoluteUrls.Add(absoluteUri.ToString()); continue; } if (configuredDomains.Count == 0) { _logger.LogDebug("Skipping Cloudflare purge for {Uri} because no domains are configured.", uri); continue; } var normalizedPath = uri.TrimStart('/'); absoluteUrls.AddRange(configuredDomains.Select(domain => { var normalizedDomain = domain; if (!normalizedDomain.Contains("://", StringComparison.Ordinal)) { normalizedDomain = $"https://{normalizedDomain}"; } if (!normalizedDomain.EndsWith("/", StringComparison.Ordinal)) { normalizedDomain += "/"; } return new Uri(new Uri(normalizedDomain), normalizedPath).ToString(); })); } return absoluteUrls .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } private List GetConfiguredDomains() { var domains = new List(); if (!string.IsNullOrWhiteSpace(_options.Domain)) { domains.Add(_options.Domain.Trim()); } foreach (var domain in _options.Domains) { if (!string.IsNullOrWhiteSpace(domain)) { domains.Add(domain.Trim()); } } return domains .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } private static bool TryGetSuccess(string body, out bool success) { success = false; if (string.IsNullOrWhiteSpace(body)) { return false; } try { using var document = JsonDocument.Parse(body); if (!document.RootElement.TryGetProperty("success", out var successElement)) { return false; } if (successElement.ValueKind != JsonValueKind.True && successElement.ValueKind != JsonValueKind.False) { return false; } success = successElement.GetBoolean(); return true; } catch (JsonException) { return false; } } }