144 lines
5.1 KiB
C#
144 lines
5.1 KiB
C#
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<CertBundFeedClient> _logger;
|
|
private readonly SemaphoreSlim _bootstrapSemaphore = new(1, 1);
|
|
private volatile bool _bootstrapped;
|
|
|
|
public CertBundFeedClient(
|
|
IHttpClientFactory httpClientFactory,
|
|
IOptions<CertBundOptions> options,
|
|
ILogger<CertBundFeedClient> 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<IReadOnlyList<CertBundFeedItem>> 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<CertBundFeedItem>();
|
|
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;
|
|
}
|