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:
thepraandClaude Opus 5.5 committed 2026-10-06 11:49:32 +02:00
1 parent e708f1ad2e
commit 02928caac1
8 files changed
+182 -15

No files matched your search

@@ -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)