using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Concelier.Connector.CertBund.Configuration; namespace StellaOps.Concelier.Connector.CertBund.Internal; public sealed class CertBundFeedClient { private readonly IHttpClientFactory _httpClientFactory; private readonly CertBundOptions _options; private readonly ILogger _logger; private readonly SemaphoreSlim _bootstrapSemaphore = new(1, 1); private volatile bool _bootstrapped; public CertBundFeedClient( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); _options.Validate(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> LoadAsync(CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(CertBundOptions.HttpClientName); await EnsureSessionAsync(client, cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Get, _options.FeedUri); request.Headers.TryAddWithoutValidation("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8"); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var document = XDocument.Load(stream); var items = new List(); foreach (var element in document.Descendants("item")) { cancellationToken.ThrowIfCancellationRequested(); var linkValue = element.Element("link")?.Value?.Trim(); if (string.IsNullOrWhiteSpace(linkValue) || !Uri.TryCreate(linkValue, UriKind.Absolute, out var portalUri)) { continue; } var advisoryId = TryExtractNameParameter(portalUri); if (string.IsNullOrWhiteSpace(advisoryId)) { continue; } var detailUri = _options.BuildDetailUri(advisoryId); var pubDateText = element.Element("pubDate")?.Value; var published = ParseDate(pubDateText); var title = element.Element("title")?.Value?.Trim(); var category = element.Element("category")?.Value?.Trim(); items.Add(new CertBundFeedItem(advisoryId, detailUri, portalUri, published, title, category)); } return items; } private async Task EnsureSessionAsync(HttpClient client, CancellationToken cancellationToken) { if (_bootstrapped) { return; } await _bootstrapSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_bootstrapped) { return; } using var request = new HttpRequestMessage(HttpMethod.Get, _options.PortalBootstrapUri); request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); _bootstrapped = true; } finally { _bootstrapSemaphore.Release(); } } private static string? TryExtractNameParameter(Uri portalUri) { if (portalUri is null) { return null; } var query = portalUri.Query; if (string.IsNullOrEmpty(query)) { return null; } var trimmed = query.TrimStart('?'); foreach (var pair in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries)) { var separatorIndex = pair.IndexOf('='); if (separatorIndex <= 0) { continue; } var key = pair[..separatorIndex].Trim(); if (!key.Equals("name", StringComparison.OrdinalIgnoreCase)) { continue; } var value = pair[(separatorIndex + 1)..]; return Uri.UnescapeDataString(value); } return null; } private static DateTimeOffset ParseDate(string? value) => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) ? parsed : DateTimeOffset.UtcNow; }