Files
SocialPub/PrivaPub/Federation/Controllers/WellKnownController.cs
T
thepraandClaude Opus 5.5 9ab87b2779 Personas prove what goes to relays; the server says what it reads
FEP-521a and FEP-8b32. Every persona has an Ed25519 key of its own (Avatar.SigningKey; migration 014 gives the earlier
ones theirs), named in its actor's assertionMethod as a Multikey, the terms defined in the actor's own context. A
persona's activity going to a relay carries an eddsa-jcs-2022 proof (JSON canonicalised by RFC 8785, Jcs), so what
Activity-Relay forwards reaches Mastodon, which verifies it with its own code. Nothing else carries one: Mitra takes a
proof over the HTTP signature and refuses one by a key it has not read, without reading the actor again. Received: an
actor's own Multikeys are kept, and a forwarded activity whose proof one of them verifies is taken as it came instead of
being read again from its origin.

Discovery: WebFinger for the server's origin links its instance actor (FEP-d556), NodeInfo links it as the application
actor (FEP-2677), and actors name RFC 9421 under implements (FEP-844e).

Checked live: relay 16 (Activity-Relay's forward of alice's post reaches Mastodon), Mitra, GoToSocial and Mastodon
unchanged (165 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 09:01:21 +02:00

153 lines
5.5 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers
{
[ApiController]
public class WellKnownController : ControllerBase
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<RegistrationOptions> _registrations;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> registrations)
{
_registrations = registrations;
_localActors = localActors;
_dbEntities = dbEntities;
}
[HttpGet, Route("/.well-known/webfinger")]
public async Task<IActionResult> WebFinger([FromQuery] string resource, CancellationToken token)
{
if (string.IsNullOrEmpty(resource))
return BadRequest();
// the server itself (FEP-d556): its instance actor
if (resource.TrimEnd('/') == _localActors.BaseAddress)
{
var instance = await _localActors.GetInstanceActor(token);
return Content(new JsonObject
{
["subject"] = resource,
["links"] = new JsonArray(new JsonObject
{
["rel"] = "https://www.w3.org/ns/activitystreams#Service", ["type"] = "application/activity+json", ["href"] = instance.Uri
})
}.ToJsonString(), "application/jrd+json; charset=utf-8");
}
LocalActor actor;
// Mastodon, GoToSocial and Pleroma also take a bare user@domain or @user@domain, so we do too.
var acct = resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase) ? resource[5..]
: !resource.Contains("://", StringComparison.Ordinal) && resource.Contains('@') ? resource
: default;
if (acct != default)
{
var parts = acct.TrimStart('@').Split('@');
var domain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
return NotFound();
actor = await _localActors.FindByUserName(parts[0], token);
if (actor == default && await _localActors.Gone(parts[0], token) != default)
return StatusCode(StatusCodes.Status410Gone);
}
else
actor = await _localActors.FindByUri(resource, token);
if (actor is not { IsFederated: true })
return NotFound();
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.Kind == LocalActorKind.Application
? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
: new JsonArray(
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl },
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
};
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
}
[HttpGet, Route("/.well-known/nodeinfo")]
public async Task<IActionResult> NodeInfoLinks(CancellationToken token)
{
var document = new JsonObject
{
["links"] = new JsonArray(
new JsonObject
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.1"
},
new JsonObject
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
},
// the application actor (FEP-2677)
new JsonObject
{
["rel"] = "https://www.w3.org/ns/activitystreams#Application",
["href"] = (await _localActors.GetInstanceActor(token)).Uri
})
};
return Content(document.ToJsonString(), "application/json; charset=utf-8");
}
[HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")]
public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
{
var users = await Counted.Users(token);
var posts = await Counted.LocalPosts(token);
var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref };
if (version == "2.1")
{
software["repository"] = "https://git.thepra.dev/thepra/SocialPub";
software["homepage"] = "https://git.thepra.dev/thepra/SocialPub";
}
var document = new JsonObject
{
["version"] = version,
["software"] = software,
["protocols"] = new JsonArray("activitypub"),
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
["openRegistrations"] = _registrations.CurrentValue.IsOpen,
["usage"] = new JsonObject
{
["users"] = new JsonObject
{
["total"] = users,
["activeMonth"] = await Counted.ActiveUsers(30, token),
["activeHalfyear"] = await Counted.ActiveUsers(180, token)
},
["localPosts"] = posts
},
["metadata"] = new JsonObject
{
["nodeName"] = "PrivaPub",
["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.",
["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" }
}
};
return Content(document.ToJsonString(),
$"application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"; charset=utf-8");
}
}
}