Skip to content

Commit df7e33a

Browse files
committed
Better URI validation and error handling
1 parent 726ea64 commit df7e33a

File tree

4 files changed

+169
-85
lines changed

4 files changed

+169
-85
lines changed

Koware.Cli/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ static IHost BuildHost(string[] args)
9696
builder.Services.AddSingleton<IMangaListStore, SqliteMangaListStore>();
9797
builder.Logging.SetMinimumLevel(LogLevel.Warning);
9898
builder.Logging.AddFilter("koware", LogLevel.Information);
99-
builder.Logging.AddFilter("Koware.Infrastructure.Scraping.AllAnimeCatalog", LogLevel.Error);
99+
builder.Logging.AddFilter("Koware.Infrastructure.Scraping.AllAnimeCatalog", LogLevel.Debug);
100100

101101
return builder.Build();
102102
}

Koware.Cli/appsettings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"PreferredMatchIndex": null
66
},
77
"AllAnime": {
8+
"Enabled": true,
89
"BaseHost": "allanime.day",
910
"ApiBase": "https://api.allanime.day",
1011
"Referer": "https://allmanga.to",
@@ -13,6 +14,7 @@
1314
"SearchLimit": 20
1415
},
1516
"AllManga": {
17+
"Enabled": true,
1618
"BaseHost": "allmanga.to",
1719
"ApiBase": "https://api.allanime.day",
1820
"Referer": "https://allmanga.to",

Koware.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using Microsoft.Extensions.Configuration;
77
using Microsoft.Extensions.DependencyInjection;
88
using Microsoft.Extensions.Options;
9-
using Microsoft.Extensions.Logging;
109
using System.Net;
1110

1211
namespace Koware.Infrastructure.DependencyInjection;
@@ -18,29 +17,25 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
1817
if (configuration is not null)
1918
{
2019
services.Configure<AllAnimeOptions>(configuration.GetSection("AllAnime"));
21-
services.Configure<GogoAnimeOptions>(configuration.GetSection("GogoAnime"));
2220
services.Configure<AllMangaOptions>(configuration.GetSection("AllManga"));
23-
services.Configure<ProviderToggleOptions>(configuration.GetSection("Providers"));
2421
}
2522
else
2623
{
2724
services.Configure<AllAnimeOptions>(_ => { });
28-
services.Configure<GogoAnimeOptions>(_ => { });
2925
services.Configure<AllMangaOptions>(_ => { });
30-
services.Configure<ProviderToggleOptions>(_ => { });
3126
}
3227

3328
services.AddHttpClient<AllAnimeCatalog>((sp, client) =>
3429
{
3530
var options = sp.GetRequiredService<IOptions<AllAnimeOptions>>().Value;
3631
// Only configure if source is properly set up (user must provide config)
37-
if (!string.IsNullOrWhiteSpace(options.ApiBase))
32+
if (!string.IsNullOrWhiteSpace(options.ApiBase) && Uri.TryCreate(options.ApiBase.Trim(), UriKind.Absolute, out var apiBaseUri))
3833
{
39-
client.BaseAddress = new Uri(options.ApiBase);
34+
client.BaseAddress = apiBaseUri;
4035
}
41-
if (!string.IsNullOrWhiteSpace(options.Referer))
36+
if (!string.IsNullOrWhiteSpace(options.Referer) && Uri.TryCreate(options.Referer.Trim(), UriKind.Absolute, out var refererUri))
4237
{
43-
client.DefaultRequestHeaders.Referrer = new Uri(options.Referer);
38+
client.DefaultRequestHeaders.Referrer = refererUri;
4439
}
4540
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
4641
client.DefaultRequestHeaders.Accept.ParseAdd("application/json, */*");
@@ -52,29 +47,17 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
5247
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
5348
});
5449

55-
services.AddHttpClient<GogoAnimeCatalog>((sp, client) =>
56-
{
57-
var options = sp.GetRequiredService<IOptions<GogoAnimeOptions>>().Value;
58-
// Only configure if source is properly set up (user must provide config)
59-
if (!string.IsNullOrWhiteSpace(options.ApiBase))
60-
{
61-
client.BaseAddress = new Uri(options.ApiBase);
62-
}
63-
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
64-
client.DefaultRequestHeaders.Accept.ParseAdd("application/json, */*");
65-
});
66-
6750
services.AddHttpClient<AllMangaCatalog>((sp, client) =>
6851
{
6952
var options = sp.GetRequiredService<IOptions<AllMangaOptions>>().Value;
7053
// Only configure if source is properly set up (user must provide config)
71-
if (!string.IsNullOrWhiteSpace(options.ApiBase))
54+
if (!string.IsNullOrWhiteSpace(options.ApiBase) && Uri.TryCreate(options.ApiBase.Trim(), UriKind.Absolute, out var apiBaseUri))
7255
{
73-
client.BaseAddress = new Uri(options.ApiBase);
56+
client.BaseAddress = apiBaseUri;
7457
}
75-
if (!string.IsNullOrWhiteSpace(options.Referer))
58+
if (!string.IsNullOrWhiteSpace(options.Referer) && Uri.TryCreate(options.Referer.Trim(), UriKind.Absolute, out var refererUri))
7659
{
77-
client.DefaultRequestHeaders.Referrer = new Uri(options.Referer);
60+
client.DefaultRequestHeaders.Referrer = refererUri;
7861
}
7962
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
8063
client.DefaultRequestHeaders.Accept.ParseAdd("application/json, */*");
@@ -87,17 +70,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
8770
});
8871

8972
services.AddSingleton<AllAnimeCatalog>();
90-
services.AddSingleton<GogoAnimeCatalog>();
9173
services.AddSingleton<AllMangaCatalog>();
74+
services.AddSingleton<IAnimeCatalog>(sp => sp.GetRequiredService<AllAnimeCatalog>());
9275
services.AddSingleton<IMangaCatalog>(sp => sp.GetRequiredService<AllMangaCatalog>());
93-
services.AddSingleton<IAnimeCatalog>(sp =>
94-
{
95-
var primary = sp.GetRequiredService<AllAnimeCatalog>();
96-
var secondary = sp.GetRequiredService<GogoAnimeCatalog>();
97-
var toggles = sp.GetRequiredService<IOptions<ProviderToggleOptions>>();
98-
var logger = sp.GetRequiredService<ILogger<MultiSourceAnimeCatalog>>();
99-
return new MultiSourceAnimeCatalog(primary, secondary, toggles, logger);
100-
});
10176
return services;
10277
}
10378
}

Koware.Infrastructure/Scraping/AllAnimeCatalog.cs

Lines changed: 157 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -41,58 +41,77 @@ public AllAnimeCatalog(HttpClient httpClient, IOptions<AllAnimeOptions> options,
4141

4242
public async Task<IReadOnlyCollection<Anime>> SearchAsync(string query, CancellationToken cancellationToken = default)
4343
{
44-
if (!_options.IsConfigured)
44+
try
4545
{
46-
_logger.LogWarning("AllAnime source not configured. Add configuration to ~/.config/koware/appsettings.user.json");
47-
return Array.Empty<Anime>();
48-
}
46+
_logger.LogDebug("SearchAsync called. IsConfigured={IsConfigured}, ApiBase='{ApiBase}', BaseHost='{BaseHost}'",
47+
_options.IsConfigured, _options.ApiBase ?? "(null)", _options.BaseHost ?? "(null)");
4948

50-
// Note: Removed translationType from search to show all anime regardless of translation availability.
51-
// Translation type is still used when fetching episodes and streams.
52-
var gql = "query( $search: SearchInput $limit: Int $page: Int $countryOrigin: VaildCountryOriginEnumType ) { shows( search: $search limit: $limit page: $page countryOrigin: $countryOrigin ) { edges { _id name thumbnail description availableEpisodes __typename } }}";
53-
var variables = new
54-
{
55-
search = new { allowAdult = false, allowUnknown = false, query },
56-
limit = _options.SearchLimit,
57-
page = 1,
58-
countryOrigin = "ALL"
59-
};
49+
if (!_options.IsConfigured)
50+
{
51+
_logger.LogWarning("AllAnime source not configured. Add configuration to ~/.config/koware/appsettings.user.json");
52+
return Array.Empty<Anime>();
53+
}
6054

61-
var uri = BuildApiUri(gql, variables);
62-
using var response = await SendWithRetryAsync(uri, cancellationToken);
63-
response.EnsureSuccessStatusCode();
55+
// Note: Removed translationType from search to show all anime regardless of translation availability.
56+
// Translation type is still used when fetching episodes and streams.
57+
var gql = "query( $search: SearchInput $limit: Int $page: Int $countryOrigin: VaildCountryOriginEnumType ) { shows( search: $search limit: $limit page: $page countryOrigin: $countryOrigin ) { edges { _id name thumbnail description availableEpisodes __typename } }}";
58+
var variables = new
59+
{
60+
search = new { allowAdult = false, allowUnknown = false, query },
61+
limit = _options.SearchLimit,
62+
page = 1,
63+
countryOrigin = "ALL"
64+
};
65+
66+
var uri = BuildApiUri(gql, variables);
67+
using var response = await SendWithRetryAsync(uri, cancellationToken);
68+
response.EnsureSuccessStatusCode();
6469

65-
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
66-
var edges = json.RootElement
67-
.GetProperty("data")
68-
.GetProperty("shows")
69-
.GetProperty("edges");
70+
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
71+
var edges = json.RootElement
72+
.GetProperty("data")
73+
.GetProperty("shows")
74+
.GetProperty("edges");
7075

71-
var results = new List<Anime>();
72-
foreach (var edge in edges.EnumerateArray())
73-
{
74-
var id = edge.GetProperty("_id").GetString()!;
75-
var title = edge.GetProperty("name").GetString() ?? id;
76-
var synopsis = edge.TryGetProperty("description", out var desc) ? desc.GetString() : null;
77-
Uri? coverImage = null;
78-
if (edge.TryGetProperty("thumbnail", out var thumb) && thumb.ValueKind == JsonValueKind.String)
76+
var results = new List<Anime>();
77+
foreach (var edge in edges.EnumerateArray())
7978
{
80-
var thumbUrl = thumb.GetString();
81-
if (!string.IsNullOrWhiteSpace(thumbUrl))
79+
var id = edge.GetProperty("_id").GetString()!;
80+
var title = edge.GetProperty("name").GetString() ?? id;
81+
var synopsis = edge.TryGetProperty("description", out var desc) ? desc.GetString() : null;
82+
Uri? coverImage = null;
83+
if (edge.TryGetProperty("thumbnail", out var thumb) && thumb.ValueKind == JsonValueKind.String)
8284
{
83-
coverImage = new Uri(thumbUrl);
85+
var thumbUrl = thumb.GetString();
86+
if (!string.IsNullOrWhiteSpace(thumbUrl))
87+
{
88+
var absoluteThumb = EnsureAbsolute(thumbUrl);
89+
if (Uri.TryCreate(absoluteThumb, UriKind.Absolute, out var parsedThumb))
90+
{
91+
coverImage = parsedThumb;
92+
}
93+
else
94+
{
95+
_logger.LogDebug("Skipping invalid thumbnail '{Thumb}' for anime {AnimeId}", thumbUrl, id);
96+
}
97+
}
8498
}
99+
results.Add(new Anime(
100+
new AnimeId(id),
101+
title,
102+
synopsis: synopsis,
103+
coverImage: coverImage,
104+
detailPage: BuildDetailUri(id),
105+
episodes: Array.Empty<Episode>()));
85106
}
86-
results.Add(new Anime(
87-
new AnimeId(id),
88-
title,
89-
synopsis: synopsis,
90-
coverImage: coverImage,
91-
detailPage: BuildDetailUri(id),
92-
episodes: Array.Empty<Episode>()));
93-
}
94107

95-
return results;
108+
return results;
109+
}
110+
catch (UriFormatException ex)
111+
{
112+
_logger.LogError(ex, "Invalid URI during AllAnime search. ApiBase={ApiBase}, BaseHost={BaseHost}, Referer={Referer}", _options.ApiBase, _options.BaseHost, _options.Referer);
113+
return Array.Empty<Anime>();
114+
}
96115
}
97116

98117
public async Task<IReadOnlyCollection<Episode>> GetEpisodesAsync(Anime anime, CancellationToken cancellationToken = default)
@@ -199,7 +218,7 @@ public async Task<IReadOnlyCollection<StreamLink>> GetStreamsAsync(Episode episo
199218
private async Task ResolveSourceAsync(ProviderSource source, ConcurrentBag<StreamLink> collector, ConcurrentBag<string> attempts, CancellationToken cancellationToken)
200219
{
201220
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
202-
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
221+
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
203222
var host = "unknown";
204223

205224
try
@@ -210,7 +229,16 @@ private async Task ResolveSourceAsync(ProviderSource source, ConcurrentBag<Strea
210229

211230
using var response = await SendWithRetryAsync(new Uri(absoluteUrl), timeoutCts.Token);
212231
response.EnsureSuccessStatusCode();
213-
var payload = await response.Content.ReadAsStringAsync(timeoutCts.Token);
232+
const int maxBytes = 10 * 1024 * 1024; // 10 MB safety cap
233+
var length = response.Content.Headers.ContentLength;
234+
if (length.HasValue && length.Value > maxBytes)
235+
{
236+
attempts.Add($"{source.Name}@{host}: payload-too-large");
237+
_logger.LogDebug("Skipping source {Source} because payload length {Length} exceeds cap {Cap}", source.Name, length, maxBytes);
238+
return;
239+
}
240+
241+
var payload = await ReadContentWithLimitAsync(response.Content, maxBytes, timeoutCts.Token);
214242

215243
var links = await ExtractLinksAsync(payload, absoluteUrl, source.Name, timeoutCts.Token);
216244
foreach (var link in links)
@@ -237,6 +265,30 @@ private async Task ResolveSourceAsync(ProviderSource source, ConcurrentBag<Strea
237265
}
238266
}
239267

268+
private static async Task<string> ReadContentWithLimitAsync(HttpContent content, int maxBytes, CancellationToken cancellationToken)
269+
{
270+
await using var stream = await content.ReadAsStreamAsync(cancellationToken);
271+
await using var buffer = new MemoryStream();
272+
var temp = new byte[8192];
273+
try
274+
{
275+
int read;
276+
while ((read = await stream.ReadAsync(temp.AsMemory(0, temp.Length), cancellationToken)) > 0)
277+
{
278+
if (buffer.Length + read > maxBytes)
279+
{
280+
throw new HttpRequestException($"Content exceeded limit of {maxBytes} bytes");
281+
}
282+
buffer.Write(temp, 0, read);
283+
}
284+
285+
return Encoding.UTF8.GetString(buffer.ToArray());
286+
}
287+
finally
288+
{
289+
}
290+
}
291+
240292
private async Task<IReadOnlyCollection<StreamLink>> ExtractLinksAsync(string payload, string sourceUrl, string provider, CancellationToken cancellationToken)
241293
{
242294
var links = new List<StreamLink>();
@@ -604,11 +656,32 @@ private static (string showId, int episodeNumber) ParseEpisodeId(Episode episode
604656

605657
private Uri BuildApiUri(string gql, object variables)
606658
{
659+
if (string.IsNullOrWhiteSpace(_options.ApiBase))
660+
{
661+
throw new InvalidOperationException("AllAnime ApiBase is not configured. Check your appsettings.json or appsettings.user.json.");
662+
}
663+
664+
var apiBase = _options.ApiBase.Trim();
665+
if (!Uri.TryCreate(apiBase, UriKind.Absolute, out var baseUri))
666+
{
667+
throw new InvalidOperationException($"AllAnime ApiBase '{apiBase}' is not a valid URI. It should be like 'https://api.example.com'.");
668+
}
669+
670+
var builder = new UriBuilder(baseUri)
671+
{
672+
Path = $"{baseUri.AbsolutePath.TrimEnd('/')}/api"
673+
};
674+
607675
var query = $"query={Uri.EscapeDataString(gql)}&variables={Uri.EscapeDataString(JsonSerializer.Serialize(variables, _serializerOptions))}";
608-
return new Uri($"{_options.ApiBase.TrimEnd('/')}/api?{query}");
676+
builder.Query = query;
677+
return builder.Uri;
609678
}
610679

611-
private Uri BuildDetailUri(string id) => new($"https://{_options.BaseHost}/anime/{id}");
680+
private Uri BuildDetailUri(string id)
681+
{
682+
var host = ResolveBaseHost();
683+
return new UriBuilder("https", host, -1, $"anime/{id}").Uri;
684+
}
612685

613686
private static int ParseQualityScore(string quality)
614687
{
@@ -627,15 +700,49 @@ private string EnsureAbsolute(string path)
627700
return path;
628701
}
629702

630-
var baseUrl = $"https://{_options.BaseHost}";
631-
return path.StartsWith('/') ? $"{baseUrl}{path}" : $"{baseUrl}/{path}";
703+
var host = ResolveBaseHost();
704+
var builder = new UriBuilder("https", host)
705+
{
706+
Path = path.StartsWith('/') ? path : $"/{path}"
707+
};
708+
return builder.Uri.ToString();
709+
}
710+
711+
private string ResolveBaseHost()
712+
{
713+
if (!string.IsNullOrWhiteSpace(_options.BaseHost))
714+
{
715+
var hostText = _options.BaseHost.Trim();
716+
if (hostText.Contains("://", StringComparison.Ordinal))
717+
{
718+
if (Uri.TryCreate(hostText, UriKind.Absolute, out var parsed))
719+
{
720+
return parsed.Host;
721+
}
722+
}
723+
hostText = hostText.TrimEnd('/');
724+
if (!string.IsNullOrWhiteSpace(hostText))
725+
{
726+
return hostText;
727+
}
728+
}
729+
730+
if (!string.IsNullOrWhiteSpace(_options.ApiBase) && Uri.TryCreate(_options.ApiBase, UriKind.Absolute, out var api))
731+
{
732+
return api.Host;
733+
}
734+
735+
throw new InvalidOperationException("AllAnime BaseHost is not configured and ApiBase is invalid.");
632736
}
633737

634738
private HttpRequestMessage BuildRequest(Uri uri)
635739
{
636740
var request = new HttpRequestMessage(HttpMethod.Get, uri);
637-
request.Headers.Referrer = new Uri(_options.Referer);
638-
request.Headers.TryAddWithoutValidation("Origin", _options.Referer.TrimEnd('/'));
741+
if (Uri.TryCreate(_options.Referer, UriKind.Absolute, out var refUri))
742+
{
743+
request.Headers.Referrer = refUri;
744+
request.Headers.TryAddWithoutValidation("Origin", refUri.GetLeftPart(UriPartial.Authority));
745+
}
639746
request.Headers.UserAgent.ParseAdd(_options.UserAgent);
640747
request.Headers.Accept.ParseAdd("application/json, */*");
641748
request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9");

0 commit comments

Comments
 (0)