A remote handle on its own domain, once that domain's WebFinger agrees (FEP-2c59)
An account on a server whose handles are not its host's (Mastodon's LOCAL_DOMAIN apart from WEB_DOMAIN) showed as user@host. The actor's `webfinger` names the handle; its domain is kept once WebFinger there points back to the actor, and asked again when the name changes. A persona's blocked servers match a handle's domain as well as the actor's host, as the lists Mastodon exports name handles' domains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
e708f1ad2e
commit
02928caac1
8 files changed
+182
-15
No files matched your search
+3
-1
@@ -71,7 +71,9 @@ covered by unit tests written in their documents' shape.
|
||||
|
||||
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
|
||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md):
|
||||
every actor names its handle, and a peer's `webfinger` on another domain than its actor's host (Mastodon's
|
||||
`LOCAL_DOMAIN`) is the handle shown once that domain's WebFinger points back to the actor; asked again on a rename
|
||||
- [FEP-d556: Server-Level Actor Discovery Using WebFinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md)
|
||||
(WebFinger for the server's origin links its instance actor as `…#Service`) and
|
||||
[FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2677/fep-2677.md)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
// an account's handle: user@host, or the domain its `webfinger` names (FEP-2c59; Mastodon's LOCAL_DOMAIN apart from
|
||||
// its WEB_DOMAIN) once that domain's WebFinger says the handle is this account
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RemoteHandleTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
RemoteActorService _remote;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
_remote = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
|
||||
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
// the actor at the peer's first address, its handle at the second
|
||||
RemoteActor Actor(string name) => Serve(new RemoteActor(_harness.Peer, name));
|
||||
|
||||
RemoteActor Serve(RemoteActor actor, string preferredUsername = default)
|
||||
{
|
||||
var document = actor.Document();
|
||||
if (preferredUsername != default)
|
||||
document["preferredUsername"] = preferredUsername;
|
||||
document["webfinger"] = $"acct:{preferredUsername ?? actor.Name}@{new Uri(_harness.Peer.B).Authority}";
|
||||
_harness.Peer.Serve(new Uri(actor.Id).AbsolutePath, document.ToJsonString());
|
||||
return actor;
|
||||
}
|
||||
|
||||
void WebFinger(string self) => _harness.Peer.ServeText("/.well-known/webfinger", new JsonObject
|
||||
{
|
||||
["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = self })
|
||||
}.ToJsonString(), "application/jrd+json");
|
||||
|
||||
[Fact]
|
||||
public async Task A_handle_on_another_domain_is_kept_once_its_webfinger_agrees()
|
||||
{
|
||||
var actor = Actor("split");
|
||||
WebFinger(actor.Id);
|
||||
|
||||
var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(new Uri(_harness.Peer.B).Authority, stored.Domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_handle_its_webfinger_does_not_confirm_stays_on_the_actors_host()
|
||||
{
|
||||
var actor = Actor("claims");
|
||||
WebFinger("http://localhost/users/someone-else");
|
||||
|
||||
var stored = await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(new Uri(actor.Id).Authority, stored.Domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_renamed_account_is_asked_again()
|
||||
{
|
||||
var actor = Actor("renames");
|
||||
WebFinger(actor.Id);
|
||||
await _remote.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
|
||||
|
||||
Serve(actor, "renamed");
|
||||
WebFinger("http://localhost/users/someone-else");
|
||||
// (another cache: the first fetch holds the actor back for a while)
|
||||
var later = new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
|
||||
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }));
|
||||
var stored = await later.GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("renamed", stored.UserName);
|
||||
Assert.Equal(new Uri(actor.Id).Authority, stored.Domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_persona_blocking_the_handles_server_hides_the_account()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var split = Actor("split");
|
||||
WebFinger(split.Id);
|
||||
await _remote.GetActor(split.Id, refresh: true, token);
|
||||
var claims = Actor("claims");
|
||||
WebFinger("http://localhost/users/someone-else");
|
||||
await new RemoteActorService(Peer.Http(), _harness.Local, new MemoryCache(new MemoryCacheOptions()), _harness.Db,
|
||||
new StaticOptions<FederationOptions>(new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true }))
|
||||
.GetActor(claims.Id, refresh: true, token);
|
||||
|
||||
await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = alice.Id, Domain = new Uri(_harness.Peer.B).Host }, token);
|
||||
|
||||
Assert.Equal([split.Id], await Hidden.AuthorsHiddenFrom(alice.Id, new[] { split.Id, claims.Id }, forNotifications: false, token));
|
||||
Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, split.Id, token));
|
||||
Assert.Empty(await Hidden.RecipientsHiding(new[] { alice.Id }, claims.Id, token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +392,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var blockedBy = local != default
|
||||
? await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token)
|
||||
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
|
||||
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
|
||||
return new Relationship
|
||||
{
|
||||
Id = id,
|
||||
|
||||
@@ -7,6 +7,7 @@ using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -141,11 +142,14 @@ namespace PrivaPub.Domain.Relationships
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token))
|
||||
if (Hidden.HostMatches(following.TargetActorURI, domain))
|
||||
var followings = await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token);
|
||||
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token);
|
||||
var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
|
||||
foreach (var following in followings)
|
||||
if (Hidden.Blocks(domain, following.TargetActorURI, handles.GetValueOrDefault(following.TargetActorURI)))
|
||||
await _follows.UnfollowAs(me, following.TargetActorURI, token);
|
||||
foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token))
|
||||
if (Hidden.HostMatches(follower.ActorURI, domain))
|
||||
foreach (var follower in followers)
|
||||
if (Hidden.Blocks(domain, follower.ActorURI, handles.GetValueOrDefault(follower.ActorURI)))
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
}
|
||||
|
||||
@@ -167,8 +171,23 @@ namespace PrivaPub.Domain.Relationships
|
||||
public static class Hidden
|
||||
{
|
||||
public static bool HostMatches(string actorUri, string domain) =>
|
||||
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)
|
||||
&& (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase));
|
||||
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) && IsOn(uri.Host, domain);
|
||||
|
||||
static bool IsOn(string host, string domain) =>
|
||||
host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// a server blocked by a persona hides an account whose actor is on it, or whose handle is (its domain apart from its
|
||||
// actor's host, FEP-2c59: the servers Mastodon lists as blocked are its handles' domains)
|
||||
public static bool Blocks(string domain, string actorUri, string handleDomain) =>
|
||||
HostMatches(actorUri, domain) || (!string.IsNullOrEmpty(handleDomain) && IsOn(handleDomain.Split(':')[0], domain));
|
||||
|
||||
// the domains of these accounts' handles, for those known
|
||||
public static async Task<Dictionary<string, string>> HandleDomains(List<string> actorUris, CancellationToken token) =>
|
||||
actorUris.Count == 0
|
||||
? []
|
||||
: (await DB.Default.Find<ForeignAvatar>().Match(a => actorUris.Contains(a.ActorURI))
|
||||
.Project(a => new ForeignAvatar { ActorURI = a.ActorURI, Domain = a.Domain }).ExecuteAsync(token))
|
||||
.GroupBy(a => a.ActorURI).ToDictionary(g => g.Key, g => g.First().Domain);
|
||||
|
||||
public static async Task<HashSet<string>> AuthorsHiddenFrom(string avatarId, IEnumerable<string> actorUris, bool forNotifications, CancellationToken token)
|
||||
{
|
||||
@@ -186,8 +205,11 @@ namespace PrivaPub.Domain.Relationships
|
||||
if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications))
|
||||
hidden.Add(mute.TargetActorURI);
|
||||
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == avatarId).ExecuteAsync(token);
|
||||
if (domains.Count == 0)
|
||||
return hidden;
|
||||
var handles = await HandleDomains(uris, token);
|
||||
foreach (var uri in uris)
|
||||
if (domains.Any(d => HostMatches(uri, d.Domain)))
|
||||
if (domains.Any(d => Blocks(d.Domain, uri, handles.GetValueOrDefault(uri))))
|
||||
hidden.Add(uri);
|
||||
return hidden;
|
||||
}
|
||||
@@ -206,8 +228,12 @@ namespace PrivaPub.Domain.Relationships
|
||||
foreach (var mute in await DB.Default.Find<Mute>().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token))
|
||||
if (mute.ExpiresAt == null || mute.ExpiresAt > now)
|
||||
hiding.Add(mute.AvatarId);
|
||||
foreach (var block in await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token))
|
||||
if (HostMatches(actorUri, block.Domain))
|
||||
var domains = await DB.Default.Find<AccountDomainBlock>().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token);
|
||||
if (domains.Count == 0)
|
||||
return hiding;
|
||||
var handle = (await HandleDomains([actorUri], token)).GetValueOrDefault(actorUri);
|
||||
foreach (var block in domains)
|
||||
if (Blocks(block.Domain, actorUri, handle))
|
||||
hiding.Add(block.AvatarId);
|
||||
return hiding;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Following { get; init; }
|
||||
public string Featured { get; init; }
|
||||
public string Wall { get; init; }
|
||||
public string WebFinger { get; init; }//FEP-2c59: its handle, user@domain, where the domain is not its host's
|
||||
public string SharedInbox { get; init; }
|
||||
public string Icon { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
@@ -78,6 +79,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Following = RemoteActorService.Text(root, "following"),
|
||||
Featured = RemoteActorService.Text(root, "featured"),
|
||||
Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"),
|
||||
WebFinger = RemoteActorService.Text(root, "webfinger")?.Replace("acct:", "", StringComparison.OrdinalIgnoreCase).TrimStart('@'),
|
||||
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||
|
||||
@@ -190,8 +190,25 @@ namespace PrivaPub.Federation.Actors
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
|
||||
// the domain of its handle: its own host, or the one its `webfinger` names (FEP-2c59; a server whose handles are not
|
||||
// its host's, as Mastodon's LOCAL_DOMAIN) once that domain's WebFinger says the handle is this actor. Asked again
|
||||
// only when the handle changes, a rename among them.
|
||||
async Task<string> HandleDomain(ActorDocument actor, CancellationToken token)
|
||||
{
|
||||
var host = new Uri(actor.Id).Authority;
|
||||
var named = actor.WebFinger?.Split('@');
|
||||
if (named is not { Length: 2 } || named[0].Length == 0 || named[1].Length == 0 || named[1].Equals(host, StringComparison.OrdinalIgnoreCase))
|
||||
return host;
|
||||
var domain = named[1].ToLowerInvariant();
|
||||
var stored = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actor.Id).ExecuteFirstAsync(token);
|
||||
if (stored?.Domain == domain && stored.UserName == actor.PreferredUsername)
|
||||
return domain;
|
||||
return await ResolveHandle($"{named[0]}@{domain}", token) == actor.Id ? domain : host;
|
||||
}
|
||||
|
||||
async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
|
||||
{
|
||||
var domain = await HandleDomain(actor, token);
|
||||
var now = DateTime.UtcNow;
|
||||
return await DB.Default.UpdateAndGet<ForeignAvatar>()
|
||||
.Match(a => a.ActorURI == actor.Id)
|
||||
@@ -199,7 +216,7 @@ namespace PrivaPub.Federation.Actors
|
||||
.Modify(a => a.Name, actor.Name)
|
||||
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
|
||||
.Modify(a => a.Url, actor.Url)
|
||||
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
|
||||
.Modify(a => a.Domain, domain)
|
||||
.Modify(a => a.InboxURL, actor.Inbox)
|
||||
.Modify(a => a.OutboxURL, actor.Outbox)
|
||||
.Modify(a => a.FollowersURL, actor.Followers)
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ Priorities, used throughout:
|
||||
- Up to 16 are kept. Mastodon drops anything past 4; Pixelfed albums and Threads carousels go beyond it.
|
||||
- `{type: Link}` attachments are not mistaken for media.
|
||||
- **Deleted posts** answer 410 with a `Tombstone`. Actor `published` is truncated to the day. The `webfinger` property
|
||||
(FEP-2c59) is on every actor.
|
||||
(FEP-2c59) is on every actor, and a peer's is read: a handle on another domain than the actor's host is shown once
|
||||
that domain's WebFinger confirms it (2026-10-06).
|
||||
- **Live check:** the whole follow, post, reply, like, boost, DM, edit and delete set round-trips with GoToSocial
|
||||
0.22.1 (`tools/pasture/`).
|
||||
|
||||
|
||||
+2
-1
@@ -669,7 +669,8 @@ it, raw where it doesn't.
|
||||
- **Accounts and follows:**
|
||||
- inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, the personas' follows,
|
||||
lists, mutes and blocks move to the new one, owner decision; checked live against GoToSocial);
|
||||
- re-run WebFinger on a rename;
|
||||
- re-run WebFinger on a rename: **done 2026-10-06** (FEP-2c59: a handle on another domain than the actor's host is
|
||||
kept once that domain's WebFinger confirms it, and asked again when the name changes);
|
||||
- inbound `Block`; `Add`/`Remove` of pins: **done 2026-10-05** (both ways, a community's pins too, the `featured`
|
||||
collection read with an account's counts; checked live against Mastodon);
|
||||
- FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the
|
||||
|
||||
Reference in new issue
Block a user