rdt-client/server/RdtClient.Service/Services/TrackerListGrabber.cs
2026-02-11 19:44:49 -07:00

192 lines
7.4 KiB
C#

using System.Reflection;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace RdtClient.Service.Services;
public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache, ILogger<TrackerListGrabber> logger) : ITrackerListGrabber
{
private const String CacheKey = "TrackerList";
private static readonly SemaphoreSlim Semaphore = new(1, 1);
private Int32? _lastExpirationMinutes;
public async Task<String[]> GetTrackers()
{
var trackerUrlList = Settings.Get.General.TrackerEnrichmentList;
if (String.IsNullOrWhiteSpace(trackerUrlList))
{
return [];
}
if (!Uri.TryCreate(trackerUrlList, UriKind.Absolute, out var trackerUri) ||
(trackerUri.Scheme != Uri.UriSchemeHttp && trackerUri.Scheme != Uri.UriSchemeHttps))
{
logger.LogWarning("Invalid tracker list URL format: {Url}", trackerUrlList);
return [];
}
var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration;
var useCache = currentExpiration > 0;
if (!useCache)
{
memoryCache.Remove(CacheKey);
_lastExpirationMinutes = null;
}
else
{
if (_lastExpirationMinutes is not null && currentExpiration != _lastExpirationMinutes)
{
logger.LogDebug("Tracker list cache timeout changed, invalidating cache.");
memoryCache.Remove(CacheKey);
}
_lastExpirationMinutes = currentExpiration;
if (memoryCache.TryGetValue(CacheKey, out String[]? cachedTrackers) && cachedTrackers is { Length: > 0 })
{
logger.LogDebug("Using cached tracker list.");
return cachedTrackers;
}
}
await Semaphore.WaitAsync().ConfigureAwait(false);
try
{
if (useCache)
{
if (memoryCache.TryGetValue(CacheKey, out String[]? cachedTrackers) && cachedTrackers is { Length: > 0 })
{
logger.LogDebug("Using cached tracker list (after lock).");
return cachedTrackers;
}
}
logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list.");
var trackers = await FetchAndParseTrackersAsync(trackerUri).ConfigureAwait(false);
if (useCache)
{
memoryCache.Set(CacheKey,
trackers,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(currentExpiration)
});
}
return trackers;
}
catch (TaskCanceledException ex)
{
logger.LogError(ex, "Fetching tracker list was canceled (timeout or cancellation).");
throw new TaskCanceledException("Fetching tracker list was canceled due to timeout or cancellation.", ex);
}
catch (Exception ex)
{
logger.LogError(ex, "Unable to fetch tracker list.");
throw new("Unable to fetch tracker list for enrichment.", ex);
}
finally
{
Semaphore.Release();
}
}
private async Task<String[]> FetchAndParseTrackersAsync(Uri trackerUri)
{
logger.LogDebug("Fetching tracker list from URL: {TrackerUrl}", trackerUri);
var httpClient = httpClientFactory.CreateClient();
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
var currentVersion = version != null && version.LastIndexOf('.') > 0
? $"v{version[..version.LastIndexOf('.')]}"
: "";
httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", currentVersion));
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var token = cts.Token;
var response = await httpClient.GetAsync(trackerUri, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
using (response)
{
response.EnsureSuccessStatusCode();
await using var contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
using var reader = new StreamReader(contentStream);
var result = await reader.ReadToEndAsync(token).ConfigureAwait(false);
String[] trackers;
try
{
var trackerRejectionCount = 0;
trackers = result
.Split([
"\r\n", "\n"
],
StringSplitOptions.RemoveEmptyEntries)
.Where(line => !String.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#'))
.Select(t => t.EndsWith("/") ? t.TrimEnd('/') : t)
.Select(t => t.Trim())
.Where(t =>
{
if (!Uri.TryCreate(t, UriKind.Absolute, out var uri))
{
logger.LogDebug("Rejected tracker: {TrackerUrl} - Reason: Invalid format or unsupported scheme.", t);
trackerRejectionCount++;
return false;
}
var isIpv6 = uri.Host.StartsWith('[') && uri.Host.Contains(']');
var valid = ((isIpv6 && uri.Host.All(c => Char.IsLetterOrDigit(c) || c == '.' || c == ':' || c == '[' || c == ']')) ||
(!isIpv6 && uri.Host.All(c => Char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_'))) &&
(uri.Scheme == Uri.UriSchemeHttp ||
uri.Scheme == Uri.UriSchemeHttps ||
uri.Scheme.Equals("udp", StringComparison.OrdinalIgnoreCase) ||
uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)) &&
!t.Contains("..") &&
!t.Contains("\\") &&
!t.Any(Char.IsControl) &&
uri.Host.Length > 0;
if (!valid)
{
logger.LogDebug("Enrichment tracker rejected: {TrackerUrl} - Reason: Invalid format or unsupported scheme.", t);
trackerRejectionCount++;
}
return valid;
})
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
logger.LogInformation("{TrackerRejectionCount} trackers were rejected during enrichment.", trackerRejectionCount);
}
catch (Exception ex)
{
logger.LogError(ex, "Error parsing tracker list response.");
throw new InvalidOperationException("Failed to parse tracker list response.", ex);
}
return trackers;
}
}
}