diff --git a/server/RdtClient.Service.Test/Services/EnricherTest.cs b/server/RdtClient.Service.Test/Services/EnricherTest.cs index 41ae6a2..ebdd84e 100644 --- a/server/RdtClient.Service.Test/Services/EnricherTest.cs +++ b/server/RdtClient.Service.Test/Services/EnricherTest.cs @@ -135,44 +135,6 @@ public class EnricherTest : IDisposable .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() { @@ -268,4 +230,197 @@ public class EnricherTest : IDisposable // Act & Assert await Assert.ThrowsAsync(() => enricher.EnrichTorrentBytes(originalTorrentBytes)); } + + [Fact] + public async Task EnrichTorrentBytes_ThrowsArgumentException_OnNullOrEmptyInput() + { + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + await Assert.ThrowsAsync(() => enricher.EnrichTorrentBytes(null!)); + await Assert.ThrowsAsync(() => enricher.EnrichTorrentBytes([])); + } + + [Fact] + public async Task EnrichTorrentBytes_ThrowsInvalidOperationException_OnNonTorrentBytes() + { + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + var notTorrent = new Byte[] + { + 1, 2, 3, 4, 5 + }; + + await Assert.ThrowsAsync(() => enricher.EnrichTorrentBytes(notTorrent)); + } + + [Fact] + public async Task EnrichMagnetLink_WithExistingTrackersAndOtherParameters() + { + // Arrange + var magnetLink = "magnet:?xt=urn:btih:HASH&dn=MyFile&tr=udp%3A%2F%2Fexisting"; + + var newTrackers = new[] + { + "udp://new1", "udp://existing" + }; + + SetupTrackerListGrabber(newTrackers); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var result = await enricher.EnrichMagnetLink(magnetLink); + + // Assert + var queryParams = System.Web.HttpUtility.ParseQueryString(new Uri(result).Query); + var trValues = queryParams.GetValues("tr")?.ToList() ?? new List(); + + Assert.Equal("urn:btih:HASH", queryParams["xt"]); + Assert.Equal("MyFile", queryParams["dn"]); + Assert.Contains("udp://existing", trValues); + Assert.Contains("udp://new1", trValues); + Assert.Equal(2, trValues.Count); + } + + [Fact] + public async Task EnrichMagnetLink_BasicMagnetWithHashOnly() + { + // Arrange + var magnetLink = "magnet:?xt=urn:btih:HASH"; + + var newTrackers = new[] + { + "udp://tracker1" + }; + + SetupTrackerListGrabber(newTrackers); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var result = await enricher.EnrichMagnetLink(magnetLink); + + // Assert + Assert.Equal($"{magnetLink}&tr={Uri.EscapeDataString("udp://tracker1")}", result); + } + + [Fact] + public async Task EnrichMagnetLink_MagnetEndsWithQuestionMarkOnly() + { + // Arrange + var magnetLink = "magnet:?"; + + var newTrackers = new[] + { + "udp://tracker1" + }; + + SetupTrackerListGrabber(newTrackers); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var result = await enricher.EnrichMagnetLink(magnetLink); + + // Assert + Assert.Equal($"{magnetLink}tr={Uri.EscapeDataString("udp://tracker1")}", result); + } + + [Fact] + public async Task EnrichMagnetLink_GetTrackersReturnsEmptyArray_Unchanged() + { + // Arrange + var magnetLink = "magnet:?xt=urn:btih:HASH&tr=udp%3A%2F%2Fexisting"; + SetupTrackerListGrabber([]); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var result = await enricher.EnrichMagnetLink(magnetLink); + + // Assert + Assert.Equal(magnetLink, result); + } + + [Fact] + public async Task EnrichTorrentBytes_NoExistingAnnounceOrAnnounceList() + { + // Arrange + var torrentDict = new BEncodedDictionary + { + ["info"] = new BEncodedDictionary() + }; + + var originalBytes = torrentDict.Encode(); + + var newTrackers = new[] + { + "udp://tracker1", "udp://tracker2" + }; + + SetupTrackerListGrabber(newTrackers); + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + Assert.Equal(newTrackers[0], ((BEncodedString)enrichedDict["announce"]).Text); + var announceList = (BEncodedList)enrichedDict["announce-list"]; + Assert.Equal(2, announceList.Count); + Assert.Equal(newTrackers[0], ((BEncodedString)((BEncodedList)announceList[0])[0]).Text); + Assert.Equal(newTrackers[1], ((BEncodedString)((BEncodedList)announceList[1])[0]).Text); + } + + [Fact] + public async Task EnrichTorrentBytes_WithExistingAnnounceAndAnnounceList() + { + // Arrange + var existingTrackers = new List + { + "http://tier1.com", + "http://existing.com/announce" + }; + + var torrentDict = new BEncodedDictionary + { + ["announce"] = new BEncodedString(existingTrackers[1]), + ["announce-list"] = new BEncodedList + { + new BEncodedList + { + new BEncodedString(existingTrackers[0]) + }, + new BEncodedList + { + new BEncodedString(existingTrackers[1]) + } + }, + ["info"] = new BEncodedDictionary() + }; + + var originalBytes = torrentDict.Encode(); + + var newTrackersFromGrabber = new[] + { + "udp://new1", "http://existing.com/announce" + }; + + SetupTrackerListGrabber(newTrackersFromGrabber); + + var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object); + + // Act + var enrichedBytes = await enricher.EnrichTorrentBytes(originalBytes); + var enrichedDict = BEncodedValue.Decode(enrichedBytes); + + // Assert + var finalAnnounceList = ((BEncodedList)enrichedDict["announce-list"]) + .Cast() + .SelectMany(tier => tier.Cast().Select(t => t.Text)) + .ToList(); + + Assert.Contains("http://tier1.com", finalAnnounceList); + Assert.Contains("http://existing.com/announce", finalAnnounceList); + Assert.Contains("udp://new1", finalAnnounceList); + Assert.Equal(3, finalAnnounceList.Count); + Assert.Contains(((BEncodedString)enrichedDict["announce"]).Text, finalAnnounceList); + } } \ No newline at end of file diff --git a/server/RdtClient.Service/Services/Enricher.cs b/server/RdtClient.Service/Services/Enricher.cs index f0e1d12..222cdca 100644 --- a/server/RdtClient.Service/Services/Enricher.cs +++ b/server/RdtClient.Service/Services/Enricher.cs @@ -1,3 +1,5 @@ + +using System.Text; using System.Web; using Microsoft.Extensions.Logging; using MonoTorrent.BEncoding; @@ -13,7 +15,7 @@ public interface IEnricher /// /// Enriches magnet links and torrents by adding trackers from the tracker list grabber. /// -public class Enricher(ILogger logger, ITrackerListGrabber trackerListGrabber) : IEnricher +public sealed class Enricher(ILogger logger, ITrackerListGrabber trackerListGrabber) : IEnricher { /// /// Add trackers from the tracker list grabber to the magnet link. @@ -22,36 +24,107 @@ public class Enricher(ILogger logger, ITrackerListGrabber trackerListG /// Magnet link with additional trackers public async Task EnrichMagnetLink(String magnetLink) { - var newTrackers = await trackerListGrabber.GetTrackers(); + var newTrackers = await trackerListGrabber.GetTrackers().ConfigureAwait(false); - var uri = new Uri(magnetLink); - var query = HttpUtility.ParseQueryString(uri.Query); - - var existingTrackers = query.GetValues("tr") ?? []; - var allTrackers = existingTrackers.Concat(newTrackers).Distinct(StringComparer.OrdinalIgnoreCase); - - var trackerQuery = String.Join("&tr=", allTrackers.Select(Uri.EscapeDataString)); - - if (!String.IsNullOrEmpty(trackerQuery)) + if (newTrackers.Length == 0) { - trackerQuery = "&tr=" + trackerQuery; + logger.LogWarning("No new trackers were retrieved."); + + return magnetLink; } - var baseWithoutTrackers = magnetLink.Split("&tr=")[0]; + var qmIdx = magnetLink.IndexOf('?'); - var separator = baseWithoutTrackers.Contains('?') ? "&" : "?"; + if (qmIdx == -1 || qmIdx == magnetLink.Length - 1) + { + var sb = new StringBuilder(magnetLink); - var newUri = baseWithoutTrackers + separator + trackerQuery.TrimStart('&'); - try - { - _ = new Uri(newUri); + if (qmIdx == -1) + { + sb.Append('?'); + } + + for (var i = 0; i < newTrackers.Length; i++) + { + if (i > 0 || (qmIdx != magnetLink.Length - 1 && qmIdx != -1)) + { + sb.Append('&'); + } + + sb.Append("tr=").Append(Uri.EscapeDataString(newTrackers[i])); + } + + logger.LogInformation("Added {NewTrackersCount} new trackers to a magnet link with no initial query string. Total trackers: {TotalTrackersCount}.", + newTrackers.Length, + newTrackers.Length); + + return sb.ToString(); } - catch (UriFormatException ex) + + var schemePart = magnetLink[..qmIdx]; + var queryPart = magnetLink[(qmIdx + 1)..]; + + var queryKVs = HttpUtility.ParseQueryString(queryPart); + + var paramDict = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (String key in queryKVs) { - logger.LogWarning(ex, "Failed to enrich magnet link: {newUri}", newUri); - throw new InvalidOperationException($"Failed to enrich magnet link: {newUri}", ex); + if (key == null) + { + continue; + } + + if (!paramDict.ContainsKey(key)) + { + paramDict[key] = new List(); + } + + paramDict[key].AddRange(queryKVs.GetValues(key) ?? []); } - return newUri; + + var existingTrackers = paramDict.TryGetValue("tr", out var value) + ? new HashSet(value, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + paramDict.Remove("tr"); + + var newUniqueTrackers = newTrackers.Where(t => !existingTrackers.Contains(t)).ToList(); + + foreach (var tr in newUniqueTrackers) + { + existingTrackers.Add(tr); + } + + var outParams = new List(); + + foreach (var kv in paramDict) + { + foreach (var v in kv.Value) + { + if (kv.Key == "xt" || kv.Key == "dn") + { + outParams.Add($"{kv.Key}={v}"); + } + else + { + outParams.Add($"{kv.Key}={HttpUtility.UrlEncode(v)}"); + } + } + } + + foreach (var tr in existingTrackers) + { + outParams.Add($"tr={Uri.EscapeDataString(tr)}"); + } + + var finalMagnet = schemePart + "?" + String.Join("&", outParams); + + logger.LogInformation("Added {NewTrackersCount} new trackers to the magnet link. Total trackers: {TotalTrackersCount}.", + newUniqueTrackers.Count, + existingTrackers.Count); + + return finalMagnet; } /// @@ -61,55 +134,94 @@ public class Enricher(ILogger logger, ITrackerListGrabber trackerListG /// Torrent file bytes with additional trackers public async Task EnrichTorrentBytes(Byte[] torrentBytes) { - var newTrackers = await trackerListGrabber.GetTrackers(); - - if (torrentBytes == null) + if (torrentBytes == null || torrentBytes.Length == 0) { - throw new ArgumentNullException(nameof(torrentBytes)); + throw new ArgumentException("Torrent bytes cannot be null or empty.", nameof(torrentBytes)); } - var torrentDict = BEncodedValue.Decode(torrentBytes); + BEncodedDictionary torrentDict; - - if (!torrentDict.TryGetValue("announce-list", out var announceListValue) || announceListValue is not BEncodedList announceList) + try { - announceList = new BEncodedList(); - if (torrentDict.TryGetValue("announce", out var announceValue) && announceValue is BEncodedString announceStr) - { - announceList.Add(new BEncodedList { announceStr }); - } + torrentDict = BEncodedValue.Decode(torrentBytes); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to decode torrent bytes."); + + throw new InvalidOperationException("Invalid torrent file format.", ex); } - var existingTrackers = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var tier in announceList) + var newTrackers = await trackerListGrabber.GetTrackers().ConfigureAwait(false); + + if (newTrackers.Length == 0) { - if (tier is BEncodedList tierList) + logger.LogWarning("No new trackers were retrieved."); + + return torrentBytes; + } + + var seenTrackers = new HashSet(StringComparer.OrdinalIgnoreCase); + var allTrackers = new List(); + + if (torrentDict.TryGetValue("announce-list", out var alc) && alc is BEncodedList alList) + { + foreach (var tier in alList.OfType()) { - foreach (var tracker in tierList) + foreach (var s in tier.OfType()) { - if (tracker is BEncodedString trackerStr) + if (seenTrackers.Add(s.Text)) { - existingTrackers.Add(trackerStr.Text); + allTrackers.Add(s.Text); } } } } - foreach (var tracker in newTrackers) + if (torrentDict.TryGetValue("announce", out var announceValue) && announceValue is BEncodedString announceStr) { - if (!existingTrackers.Contains(tracker)) + if (seenTrackers.Add(announceStr.Text)) { - announceList.Add(new BEncodedList { new BEncodedString(tracker) }); - existingTrackers.Add(tracker); + allTrackers.Add(announceStr.Text); } } - torrentDict["announce-list"] = announceList; - if (announceList.Count > 0 && announceList[0] is BEncodedList firstTier && firstTier.Count > 0 && firstTier[0] is BEncodedString firstTracker) + var addedTrackersCount = 0; + + foreach (var tracker in newTrackers) { - torrentDict["announce"] = firstTracker; + if (seenTrackers.Add(tracker)) + { + allTrackers.Add(tracker); + addedTrackersCount++; + } } + var dedupedAnnounceList = new BEncodedList(); + + foreach (var tracker in allTrackers) + { + dedupedAnnounceList.Add(new BEncodedList + { + new BEncodedString(tracker) + }); + } + + torrentDict["announce-list"] = dedupedAnnounceList; + + if (allTrackers.Count > 0) + { + torrentDict["announce"] = new BEncodedString(allTrackers[0]); + } + else + { + return torrentBytes; + } + + logger.LogInformation("Added {NewTrackersCount} new trackers to the torrent. Total trackers: {TotalTrackersCount}.", + addedTrackersCount, + allTrackers.Count); + return torrentDict.Encode(); } } \ No newline at end of file diff --git a/server/RdtClient.Service/Services/TrackerListGrabber.cs b/server/RdtClient.Service/Services/TrackerListGrabber.cs index f7a34c5..3ccc049 100644 --- a/server/RdtClient.Service/Services/TrackerListGrabber.cs +++ b/server/RdtClient.Service/Services/TrackerListGrabber.cs @@ -23,9 +23,9 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac 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); + logger.LogWarning("Invalid tracker list URL format: {Url}", trackerUrlList); - throw new InvalidOperationException("Invalid tracker list URL."); + return []; } var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration; @@ -88,13 +88,13 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac { 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); + 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 InvalidOperationException("Unable to fetch tracker list for enrichment.", ex); + throw new Exception("Unable to fetch tracker list for enrichment.", ex); } finally { @@ -109,34 +109,57 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac 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); + 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 + using (response) { - 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."); + response.EnsureSuccessStatusCode(); - throw new InvalidOperationException("Failed to parse tracker list response.", ex); - } + 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); - return trackers; + String[] trackers; + + try + { + 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)) + { + return false; + } + + return (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.All(c => Char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_' || c == ':') && + uri.Host.Length > 0; + }) + .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; + } } }