Split tracker grabbing and parsing logic.
Split grab and parse logic, recheck cache after semaphore lock, timeouts, add URL validation check, add torrent file tests, replace test hash with valid SHA1 format, tracker URL validation, fix token disposal issue
This commit is contained in:
parent
408c475af5
commit
452d8cc1cf
3 changed files with 220 additions and 26 deletions
|
|
@ -23,7 +23,8 @@ public class EnricherTest : IDisposable
|
||||||
_mockRepository.VerifyAll();
|
_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]
|
[Fact]
|
||||||
public async Task EnrichMagnetLink_AddsNoTrackers_WhenNoTrackersFromTrackerGrabber()
|
public async Task EnrichMagnetLink_AddsNoTrackers_WhenNoTrackersFromTrackerGrabber()
|
||||||
|
|
@ -46,10 +47,10 @@ public class EnricherTest : IDisposable
|
||||||
// Arrange
|
// Arrange
|
||||||
SetupTrackerListGrabber(["http://new-tracker.com/announce"]);
|
SetupTrackerListGrabber(["http://new-tracker.com/announce"]);
|
||||||
|
|
||||||
var Enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var enriched = await Enricher.EnrichMagnetLink(TestMagnetLink);
|
var enriched = await enricher.EnrichMagnetLink(TestMagnetLink);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(TestMagnetLink + $"&tr={Uri.EscapeDataString("http://new-tracker.com/announce")}", enriched);
|
Assert.Equal(TestMagnetLink + $"&tr={Uri.EscapeDataString("http://new-tracker.com/announce")}", enriched);
|
||||||
|
|
@ -84,7 +85,6 @@ public class EnricherTest : IDisposable
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => enricher.EnrichMagnetLink(TestMagnetLink));
|
await Assert.ThrowsAsync<InvalidOperationException>(() => enricher.EnrichMagnetLink(TestMagnetLink));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnrichTorrentBytes_AddsTrackers_WhenTrackersFromTrackerGrabber()
|
public async Task EnrichTorrentBytes_AddsTrackers_WhenTrackersFromTrackerGrabber()
|
||||||
{
|
{
|
||||||
|
|
@ -96,14 +96,17 @@ public class EnricherTest : IDisposable
|
||||||
{
|
{
|
||||||
["announce"] = new BEncodedString(originalTracker),
|
["announce"] = new BEncodedString(originalTracker),
|
||||||
["announce-list"] = new BEncodedList
|
["announce-list"] = new BEncodedList
|
||||||
|
{
|
||||||
|
new BEncodedList
|
||||||
{
|
{
|
||||||
new BEncodedList { new BEncodedString(originalTracker) }
|
new BEncodedString(originalTracker)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var originalTorrentBytes = torrentDict.Encode();
|
var originalTorrentBytes = torrentDict.Encode();
|
||||||
|
|
||||||
SetupTrackerListGrabber(new[] { newTracker });
|
SetupTrackerListGrabber([newTracker]);
|
||||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -131,4 +134,138 @@ public class EnricherTest : IDisposable
|
||||||
.ReturnsAsync(trackerList)
|
.ReturnsAsync(trackerList)
|
||||||
.Verifiable();
|
.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<BEncodedDictionary>(enrichedBytes);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(enrichedDict.ContainsKey("announce"));
|
||||||
|
Assert.True(enrichedDict.ContainsKey("announce-list"));
|
||||||
|
|
||||||
|
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||||
|
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().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<BEncodedDictionary>(enrichedBytes);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||||
|
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().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<BEncodedDictionary>(enrichedBytes);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(enrichedDict.ContainsKey("announce-list"));
|
||||||
|
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||||
|
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().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<InvalidOperationException>(() => enricher.EnrichTorrentBytes(originalTorrentBytes));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ public class Enricher(ILogger<Enricher> logger, ITrackerListGrabber trackerListG
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add trackers from the tracker list grabber to the magnet link.
|
/// Add trackers from the tracker list grabber to the magnet link.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="magnetLink">Magnet link to add trackres to. Is not modified</param>
|
/// <param name="magnetLink">Magnet link to add trackers to. Is not modified</param>
|
||||||
/// <returns>Magnet link with additional trackers</returns>
|
/// <returns>Magnet link with additional trackers</returns>
|
||||||
public async Task<String> EnrichMagnetLink(String magnetLink)
|
public async Task<String> EnrichMagnetLink(String magnetLink)
|
||||||
{
|
{
|
||||||
|
|
@ -63,7 +63,11 @@ public class Enricher(ILogger<Enricher> logger, ITrackerListGrabber trackerListG
|
||||||
{
|
{
|
||||||
var newTrackers = await trackerListGrabber.GetTrackers();
|
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<BEncodedDictionary>(torrentBytes);
|
var torrentDict = BEncodedValue.Decode<BEncodedDictionary>(torrentBytes);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,21 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac
|
||||||
|
|
||||||
public async Task<String[]> GetTrackers()
|
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.LogError("Invalid tracker list URL: {Url}", trackerUrlList);
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Invalid tracker list URL.");
|
||||||
|
}
|
||||||
|
|
||||||
var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration;
|
var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration;
|
||||||
var useCache = currentExpiration > 0;
|
var useCache = currentExpiration > 0;
|
||||||
|
|
||||||
|
|
@ -39,29 +54,23 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Semaphore.WaitAsync();
|
await Semaphore.WaitAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list.");
|
if (useCache)
|
||||||
var trackerUrlList = Settings.Get.General.TrackerEnrichmentList;
|
|
||||||
|
|
||||||
if (String.IsNullOrWhiteSpace(trackerUrlList))
|
|
||||||
{
|
{
|
||||||
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();
|
logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list.");
|
||||||
var response = await httpClient.GetAsync(trackerUrlList);
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
var trackers = await FetchAndParseTrackersAsync(trackerUri).ConfigureAwait(false);
|
||||||
|
|
||||||
var result = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
var trackers = result
|
|
||||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (useCache)
|
if (useCache)
|
||||||
{
|
{
|
||||||
|
|
@ -75,6 +84,12 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac
|
||||||
|
|
||||||
return trackers;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Unable to fetch tracker list.");
|
logger.LogError(ex, "Unable to fetch tracker list.");
|
||||||
|
|
@ -86,4 +101,42 @@ public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCac
|
||||||
Semaphore.Release();
|
Semaphore.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<String[]> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue