diff --git a/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs b/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs index b4958c9..640f3fb 100644 --- a/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs +++ b/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs @@ -27,6 +27,12 @@ namespace PrivaPub.Tests.Infrastructure _peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json")); _peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}")); _peer.MapGet("/gone", () => Results.StatusCode(410)); + // Mobilizon's NodeInfo: a profile URL left unquoted, which .NET cannot parse as a media type + _peer.MapGet("/nodeinfo", (HttpContext context) => + { + context.Response.Headers.ContentType = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8"; + return context.Response.WriteAsync("{\"software\":{\"name\":\"Mobilizon\"}}"); + }); await _peer.StartAsync(); _base = _peer.Urls.First(); } @@ -64,6 +70,13 @@ namespace PrivaPub.Tests.Infrastructure public async Task Refuses_a_fourth_redirect() => Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken)); + [Fact] + public async Task Reads_json_under_a_content_type_dotnet_cannot_parse() + { + using var fetched = await Client().GetJson($"{_base}/nodeinfo", "application/json", default, TestContext.Current.CancellationToken); + Assert.Equal("Mobilizon", fetched.Root.GetProperty("software").GetProperty("name").GetString()); + } + [Fact] public async Task Refuses_html() => Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken)); diff --git a/PrivaPub.Tests/Statistics/DescribeTests.cs b/PrivaPub.Tests/Statistics/DescribeTests.cs index b34220e..b4de430 100644 --- a/PrivaPub.Tests/Statistics/DescribeTests.cs +++ b/PrivaPub.Tests/Statistics/DescribeTests.cs @@ -44,6 +44,8 @@ namespace PrivaPub.Tests.Statistics Assert.Equal(7, facts.LocalComments); Assert.Equal(new[] { "en", "de" }, facts.Languages); Assert.Null(InstanceDocuments.NodeInfo(Json("{}"), "2.0").UsersTotal); + // NodeInfo wants the name in lower case, which Mobilizon does not keep + Assert.Equal("mobilizon", InstanceDocuments.NodeInfo(Json("""{"software":{"name":"Mobilizon"}}"""), "2.1").Software); } [Fact] diff --git a/PrivaPub/Federation/Objects/InstanceDocuments.cs b/PrivaPub/Federation/Objects/InstanceDocuments.cs index b51f6ea..997b234 100644 --- a/PrivaPub/Federation/Objects/InstanceDocuments.cs +++ b/PrivaPub/Federation/Objects/InstanceDocuments.cs @@ -47,9 +47,10 @@ namespace PrivaPub.Federation.Objects var usage = Section(root, "usage"); var users = Section(usage, "users"); var metadata = Section(root, "metadata"); + // (NodeInfo wants the name in lower case; Mobilizon says "Mobilizon") return new NodeInfoFacts( version, - Text(software, "name", 200), + Text(software, "name", 200)?.ToLowerInvariant(), Text(software, "version", 200), Text(metadata, "nodeName", 200), Plain(Text(metadata, "nodeDescription", 2000), 500), diff --git a/PrivaPub/Infrastructure/Http/FederationHttp.cs b/PrivaPub/Infrastructure/Http/FederationHttp.cs index 1aa03e8..ddf8e57 100644 --- a/PrivaPub/Infrastructure/Http/FederationHttp.cs +++ b/PrivaPub/Infrastructure/Http/FederationHttp.cs @@ -218,7 +218,7 @@ namespace PrivaPub.Infrastructure.Http exchange.Refused(StatusReason(response)); return items; } - var mediaType = response.Content.Headers.ContentType?.MediaType; + var mediaType = MediaType(response.Content.Headers); if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) { exchange.Refused("content-type"); @@ -356,7 +356,7 @@ namespace PrivaPub.Infrastructure.Http return Refuse(negativeKey, url, $"status {(int)response.StatusCode}", exchange, StatusReason(response), transient: (int)response.StatusCode is >= 500 or 429 or 408); - var mediaType = response.Content.Headers.ContentType?.MediaType; + var mediaType = MediaType(response.Content.Headers); if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) return Refuse(negativeKey, url, $"content type '{mediaType}'", exchange, "content-type"); if (response.Content.Headers.ContentLength > MaxResponseBytes) @@ -629,6 +629,11 @@ namespace PrivaPub.Infrastructure.Http static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri; + // the media type, also from a header .NET cannot parse: Mobilizon's NodeInfo leaves its profile URL unquoted + static string MediaType(System.Net.Http.Headers.HttpContentHeaders headers) => + headers.ContentType?.MediaType + ?? (headers.NonValidated.TryGetValues("Content-Type", out var raw) ? raw.ToString().Split(';')[0].Trim() : default); + sealed record Refusal(bool Transient, int Status); FetchedJson Refuse(string negativeKey, string url, string reason, Exchange exchange, string code, bool transient = false)