NodeInfo is read as Mobilizon serves it

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 10:45:18 +02:00
1 parent 206506f5e9
commit 7f6837ccb1
4 files changed
+24 -3

No files matched your search

@@ -27,6 +27,12 @@ namespace PrivaPub.Tests.Infrastructure
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json")); _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("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410)); _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(); await _peer.StartAsync();
_base = _peer.Urls.First(); _base = _peer.Urls.First();
} }
@@ -64,6 +70,13 @@ namespace PrivaPub.Tests.Infrastructure
public async Task Refuses_a_fourth_redirect() => public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken)); 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] [Fact]
public async Task Refuses_html() => public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken)); Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
@@ -44,6 +44,8 @@ namespace PrivaPub.Tests.Statistics
Assert.Equal(7, facts.LocalComments); Assert.Equal(7, facts.LocalComments);
Assert.Equal(new[] { "en", "de" }, facts.Languages); Assert.Equal(new[] { "en", "de" }, facts.Languages);
Assert.Null(InstanceDocuments.NodeInfo(Json("{}"), "2.0").UsersTotal); 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] [Fact]
@@ -47,9 +47,10 @@ namespace PrivaPub.Federation.Objects
var usage = Section(root, "usage"); var usage = Section(root, "usage");
var users = Section(usage, "users"); var users = Section(usage, "users");
var metadata = Section(root, "metadata"); var metadata = Section(root, "metadata");
// (NodeInfo wants the name in lower case; Mobilizon says "Mobilizon")
return new NodeInfoFacts( return new NodeInfoFacts(
version, version,
Text(software, "name", 200), Text(software, "name", 200)?.ToLowerInvariant(),
Text(software, "version", 200), Text(software, "version", 200),
Text(metadata, "nodeName", 200), Text(metadata, "nodeName", 200),
Plain(Text(metadata, "nodeDescription", 2000), 500), Plain(Text(metadata, "nodeDescription", 2000), 500),
@@ -218,7 +218,7 @@ namespace PrivaPub.Infrastructure.Http
exchange.Refused(StatusReason(response)); exchange.Refused(StatusReason(response));
return items; return items;
} }
var mediaType = response.Content.Headers.ContentType?.MediaType; var mediaType = MediaType(response.Content.Headers);
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
{ {
exchange.Refused("content-type"); exchange.Refused("content-type");
@@ -356,7 +356,7 @@ namespace PrivaPub.Infrastructure.Http
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}", exchange, StatusReason(response), return Refuse(negativeKey, url, $"status {(int)response.StatusCode}", exchange, StatusReason(response),
transient: (int)response.StatusCode is >= 500 or 429 or 408); 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)) if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
return Refuse(negativeKey, url, $"content type '{mediaType}'", exchange, "content-type"); return Refuse(negativeKey, url, $"content type '{mediaType}'", exchange, "content-type");
if (response.Content.Headers.ContentLength > MaxResponseBytes) if (response.Content.Headers.ContentLength > MaxResponseBytes)
@@ -629,6 +629,11 @@ namespace PrivaPub.Infrastructure.Http
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri; 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); sealed record Refusal(bool Transient, int Status);
FetchedJson Refuse(string negativeKey, string url, string reason, Exchange exchange, string code, bool transient = false) FetchedJson Refuse(string negativeKey, string url, string reason, Exchange exchange, string code, bool transient = false)