From 7f6837ccb1f3eb6103d5e2d1246a27c1e8de12fa Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 10:45:18 +0200 Subject: [PATCH] NodeInfo is read as Mobilizon serves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobilizon sends its NodeInfo as `application/json; profile=http://…#` with the URL unquoted, which .NET cannot parse, so the document was refused for its content type and the server never described; and it names its software "Mobilizon" where NodeInfo wants lower case. The media type is now read from the raw header when the parsed one is missing, and software names are lowercased, so one software is counted under one name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- .../Infrastructure/FederationHttpTests.cs | 13 +++++++++++++ PrivaPub.Tests/Statistics/DescribeTests.cs | 2 ++ PrivaPub/Federation/Objects/InstanceDocuments.cs | 3 ++- PrivaPub/Infrastructure/Http/FederationHttp.cs | 9 +++++++-- 4 files changed, 24 insertions(+), 3 deletions(-) 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)