diff --git a/FEDERATION.md b/FEDERATION.md index 62afea7..a8e0199 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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) diff --git a/PrivaPub.Tests/Federation/RemoteHandleTests.cs b/PrivaPub.Tests/Federation/RemoteHandleTests.cs new file mode 100644 index 0000000..4ac6990 --- /dev/null +++ b/PrivaPub.Tests/Federation/RemoteHandleTests.cs @@ -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(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(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(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)); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 637bb40..0e47668 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -392,7 +392,8 @@ namespace PrivaPub.Api.Mastodon.Controllers var blockedBy = local != default ? await DB.Default.Find().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token) : remote != default && await DB.Default.Find().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token); - var domainBlocked = remote != default && await DB.Default.Find().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token); + var domainBlocked = remote != default && (await DB.Default.Find().Match(b => b.AvatarId == MyId).ExecuteAsync(token)) + .Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain)); return new Relationship { Id = id, diff --git a/PrivaPub/Domain/Relationships/RelationshipService.cs b/PrivaPub/Domain/Relationships/RelationshipService.cs index 144b2b2..e021123 100644 --- a/PrivaPub/Domain/Relationships/RelationshipService.cs +++ b/PrivaPub/Domain/Relationships/RelationshipService.cs @@ -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.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> HandleDomains(List actorUris, CancellationToken token) => + actorUris.Count == 0 + ? [] + : (await DB.Default.Find().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> AuthorsHiddenFrom(string avatarId, IEnumerable 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().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().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().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token)) - if (HostMatches(actorUri, block.Domain)) + var domains = await DB.Default.Find().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; } diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index 38d9d1b..fab4026 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -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, diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index f7101b4..4806649 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -190,8 +190,25 @@ namespace PrivaPub.Federation.Actors return true; } - static async Task 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 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 Upsert(ActorDocument actor, ActorKey key, CancellationToken token) + { + var domain = await HandleDomain(actor, token); var now = DateTime.UtcNow; return await DB.Default.UpdateAndGet() .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) diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 942b788..95193d3 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -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/`). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 81f23ad..5582785 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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