diff --git a/server/RdtClient.Service.Test/Services/EnricherTest.cs b/server/RdtClient.Service.Test/Services/EnricherTest.cs index 4c7ceb7..41ae6a2 100644 --- a/server/RdtClient.Service.Test/Services/EnricherTest.cs +++ b/server/RdtClient.Service.Test/Services/EnricherTest.cs @@ -23,7 +23,8 @@ public class EnricherTest : IDisposable _mockRepository.VerifyAll(); } - private const String TestMagnetLink = "magnet:?xt=urn:btih:1234567890123456789012345678901234567890&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce"; + private const String TestMagnetLink = + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce"; [Fact] public async Task EnrichMagnetLink_AddsNoTrackers_WhenNoTrackersFromTrackerGrabber() @@ -46,10 +47,10 @@ public class EnricherTest : IDisposable // Arrange SetupTrackerListGrabber(["http://new-tracker.com/announce"]); - var Enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); // Act - var enriched = await Enricher.EnrichMagnetLink(TestMagnetLink); + var enriched = await enricher.EnrichMagnetLink(TestMagnetLink); // Assert Assert.Equal(TestMagnetLink + $"&tr={Uri.EscapeDataString("http://new-tracker.com/announce")}", enriched); @@ -84,7 +85,6 @@ public class EnricherTest : IDisposable await Assert.ThrowsAsync(() => enricher.EnrichMagnetLink(TestMagnetLink)); } - [Fact] public async Task EnrichTorrentBytes_AddsTrackers_WhenTrackersFromTrackerGrabber() { @@ -96,14 +96,17 @@ public class EnricherTest : IDisposable { ["announce"] = new BEncodedString(originalTracker), ["announce-list"] = new BEncodedList + { + new BEncodedList { - new BEncodedList { new BEncodedString(originalTracker) } + new BEncodedString(originalTracker) } + } }; var originalTorrentBytes = torrentDict.Encode(); - SetupTrackerListGrabber(new[] { newTracker }); + SetupTrackerListGrabber([newTracker]); var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); // Act @@ -131,4 +134,138 @@ public class EnricherTest : IDisposable .ReturnsAsync(trackerList) .Verifiable(); } + + [Fact] + public async Task EnrichTorrentBytes_DoesNotAddTrackers_WhenNoTrackersFromTrackerGrabber() + { + // Arrange + var originalTracker = "http://tracker1.com/announce"; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(originalTracker), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString(originalTracker) + } + } + }; + + var originalTorrentBytes = torrentDict.Encode(); + + SetupTrackerListGrabber([]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + Assert.True(enrichedDict.ContainsKey("announce")); + Assert.True(enrichedDict.ContainsKey("announce-list")); + + var announceList = (BEncodedList)enrichedDict["announce-list"]; + var flattened = announceList.Cast().SelectMany(l => l.Cast().Select(s => s.Text)).ToList(); + + Assert.Single(flattened); + Assert.Contains(originalTracker, flattened); + } + + [Fact] + public async Task EnrichTorrentBytes_DoesNotAddDuplicateTrackers() + { + // Arrange + var originalTracker = "http://tracker1.com/announce"; + var duplicateTracker = "http://tracker1.com/announce"; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(originalTracker), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString(originalTracker) + } + } + }; + + var originalTorrentBytes = torrentDict.Encode(); + + SetupTrackerListGrabber([duplicateTracker]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + var announceList = (BEncodedList)enrichedDict["announce-list"]; + var flattened = announceList.Cast().SelectMany(l => l.Cast().Select(s => s.Text)).ToList(); + + Assert.Single(flattened); + Assert.Contains(originalTracker, flattened); + } + + [Fact] + public async Task EnrichTorrentBytes_AddsTrackers_WhenNoAnnounceListPresent() + { + // Arrange + var originalTracker = "http://tracker1.com/announce"; + var newTracker = "http://new-tracker.com/announce"; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(originalTracker) + + // No "announce-list" + }; + + var originalTorrentBytes = torrentDict.Encode(); + + SetupTrackerListGrabber([newTracker]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + Assert.True(enrichedDict.ContainsKey("announce-list")); + var announceList = (BEncodedList)enrichedDict["announce-list"]; + var flattened = announceList.Cast().SelectMany(l => l.Cast().Select(s => s.Text)).ToList(); + + Assert.Contains(originalTracker, flattened); + Assert.Contains(newTracker, flattened); + } + + [Fact] + public async Task EnrichTorrentBytes_Throws_WhenTrackerGrabberThrows() + { + // Arrange + _trackerListGrabberMock + .Setup(t => t.GetTrackers()) + .ThrowsAsync(new InvalidOperationException("Unable to fetch tracker list for enrichment.")); + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString("http://tracker1.com/announce"), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString("http://tracker1.com/announce") + } + } + }; + + var originalTorrentBytes = torrentDict.Encode(); + + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => enricher.EnrichTorrentBytes(originalTorrentBytes)); + } } \ No newline at end of file diff --git a/server/RdtClient.Service/Services/Enricher.cs b/server/RdtClient.Service/Services/Enricher.cs index 140d07a..f0e1d12 100644 --- a/server/RdtClient.Service/Services/Enricher.cs +++ b/server/RdtClient.Service/Services/Enricher.cs @@ -11,14 +11,14 @@ public interface IEnricher } /// -/// Enriches magnet links and torrents by adding trackers from the tracker list grabber. +/// Enriches magnet links and torrents by adding trackers from the tracker list grabber. /// public class Enricher(ILogger logger, ITrackerListGrabber trackerListGrabber) : IEnricher { /// /// Add trackers from the tracker list grabber to the magnet link. /// - /// Magnet link to add trackres to. Is not modified + /// Magnet link to add trackers to. Is not modified /// Magnet link with additional trackers public async Task EnrichMagnetLink(String magnetLink) { @@ -63,7 +63,11 @@ public class Enricher(ILogger logger, ITrackerListGrabber trackerListG { var newTrackers = await trackerListGrabber.GetTrackers(); - if (torrentBytes == null) throw new ArgumentNullException(nameof(torrentBytes)); + if (torrentBytes == null) + { + throw new ArgumentNullException(nameof(torrentBytes)); + } + var torrentDict = BEncodedValue.Decode(torrentBytes); diff --git a/server/RdtClient.Service/Services/TrackerListGrabber.cs b/server/RdtClient.Service/Services/TrackerListGrabber.cs index f9f64d9..f7a34c5 100644 --- a/server/RdtClient.Service/Services/TrackerListGrabber.cs +++ b/server/RdtClient.Service/Services/TrackerListGrabber.cs @@ -13,6 +13,21 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac public async Task 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.LogError("Invalid tracker list URL: {Url}", trackerUrlList); + + throw new InvalidOperationException("Invalid tracker list URL."); + } + var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration; var useCache = currentExpiration > 0; @@ -39,29 +54,23 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac } } - await Semaphore.WaitAsync(); + await Semaphore.WaitAsync().ConfigureAwait(false); try { - logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list."); - var trackerUrlList = Settings.Get.General.TrackerEnrichmentList; - - if (String.IsNullOrWhiteSpace(trackerUrlList)) + if (useCache) { - return []; + if (memoryCache.TryGetValue(CacheKey, out String[]? cachedTrackers) && cachedTrackers is { Length: > 0 }) + { + logger.LogDebug("Using cached tracker list (after lock)."); + + return cachedTrackers; + } } - var httpClient = httpClientFactory.CreateClient(); - var response = await httpClient.GetAsync(trackerUrlList); + logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list."); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadAsStringAsync(); - - var trackers = result - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + var trackers = await FetchAndParseTrackersAsync(trackerUri).ConfigureAwait(false); if (useCache) { @@ -75,6 +84,12 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac return trackers; } + catch (TaskCanceledException ex) + { + logger.LogError(ex, "Fetching tracker list was canceled (timeout or cancellation)."); + + throw new InvalidOperationException("Fetching tracker list was canceled due to timeout or cancellation.", ex); + } catch (Exception ex) { logger.LogError(ex, "Unable to fetch tracker list."); @@ -86,4 +101,42 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac Semaphore.Release(); } } -} \ No newline at end of file + + private async Task FetchAndParseTrackersAsync(Uri trackerUri) + { + logger.LogDebug("Fetching tracker list from URL: {TrackerUrl}", trackerUri); + + var httpClient = httpClientFactory.CreateClient(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var token = cts.Token; + using var response = await httpClient.GetAsync(trackerUri, token).ConfigureAwait(false); + + 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 + { + trackers = result + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(line => !String.IsNullOrWhiteSpace(line)) + .Select(t => t.EndsWith("/") ? t.TrimEnd('/') : t) + .Where(t => Uri.TryCreate(t, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error parsing tracker list response."); + + throw new InvalidOperationException("Failed to parse tracker list response.", ex); + } + + return trackers; + } +}