A sender's digest of its followers here is compared and mended
FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, only when the list is the one the digest describes; a request it
lists is taken as accepted; a persona it lists that follows nothing there sends Undo{Follow}, as Mastodon does. Each
claiming delivery is compared once.
Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.
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
bd18de35a6
commit
e0682fa868
11 files changed
+291
-12
No files matched your search
@@ -562,10 +562,12 @@ tools/pasture/run.sh down # removes e
|
|||||||
- **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post,
|
- **Threads (`scenarios/threads.sh`, needs mastodon):** mastouser follows alice, carol answers alice's public post,
|
||||||
and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy
|
and opening the thread on Mastodon finds carol's reply through alice's `replies` (the scenario makes Mastodon's copy
|
||||||
look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 checks.
|
look ten minutes old: Mastodon reads a thread's replies only five minutes after it first holds the post). 5 checks.
|
||||||
- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** PrivaPub forgets mastouser's follow of
|
- **Followers synchronisation (`scenarios/followsync.sh`, needs mastodon):** sent: PrivaPub forgets mastouser's follow
|
||||||
alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then
|
of alice (mongo), alice's followers-only post naming mastouser makes Mastodon read her roll-call and drop it; then
|
||||||
Mastodon forgets a follow (rails) and alice's next followers-only post makes it send the `Undo` PrivaPub applies.
|
Mastodon forgets a follow (rails) and alice's next followers-only post makes it send the `Undo` PrivaPub applies.
|
||||||
Follows are checked in Mastodon's database: its API caches relationships for a day. 8 checks.
|
Received: alice and bob follow mastouser; Mastodon forgets alice's follow and mastouser's followers-only post makes
|
||||||
|
PrivaPub end it, then PrivaPub forgets it and the next makes PrivaPub undo it there. Follows are checked in Mastodon's
|
||||||
|
database: its API caches relationships for a day. 15 checks.
|
||||||
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
|
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
|
||||||
and unpins while it follows her, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings
|
and unpins while it follows her, a fresh account's earlier pin shows once PrivaPub resolves it, and following it brings
|
||||||
its earlier posts (its outbox). 9 checks.
|
its earlier posts (its outbox). 9 checks.
|
||||||
|
|||||||
+5
-1
@@ -72,7 +72,7 @@ covered by unit tests written in their documents' shape.
|
|||||||
`Like` with content)
|
`Like` with content)
|
||||||
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
||||||
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
||||||
(sent; see "Followers synchronisation")
|
(sent and honoured; see "Followers synchronisation")
|
||||||
|
|
||||||
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
|
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
|
||||||
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
|
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8967 (link
|
||||||
@@ -179,6 +179,10 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
|
|||||||
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
|
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
|
||||||
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
|
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
|
||||||
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
|
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
|
||||||
|
Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas
|
||||||
|
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
|
||||||
|
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
|
||||||
|
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
|
||||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
|
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
|
||||||
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
||||||
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
|
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using PrivaPub.Infrastructure.Http;
|
|||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
using PrivaPub.Tests.Support;
|
using PrivaPub.Tests.Support;
|
||||||
using PrivaPub.Tests.Support.Host;
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
@@ -146,6 +147,87 @@ namespace PrivaPub.Tests.Federation
|
|||||||
|
|
||||||
Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason);
|
Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// received: bob's delivery claims a digest of his followers here, and lists them at his own synchronisation URL
|
||||||
|
async Task Claimed(RemoteActor bob, string digest, params string[] listed)
|
||||||
|
{
|
||||||
|
var path = new Uri(bob.Id).AbsolutePath + "/followers/sync";
|
||||||
|
_harness.Peer.Serve(path, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = bob.Id + "/followers/sync", ["type"] = "OrderedCollection",
|
||||||
|
["orderedItems"] = new JsonArray(listed.Select(l => (JsonNode)l).ToArray())
|
||||||
|
}.ToJsonString());
|
||||||
|
var note = $"{bob.Id}/notes/{Guid.NewGuid():N}";
|
||||||
|
var request = bob.Post(Harness.Host, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = note + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"),
|
||||||
|
["object"] = new JsonObject { ["id"] = note, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["content"] = "hi" }
|
||||||
|
});
|
||||||
|
request.Headers[FollowersSynchronization.Header] = $"collectionId=\"{bob.Id}/followers\", url=\"{bob.Id}/followers/sync\", digest=\"{digest}\"";
|
||||||
|
await _harness.Receiver.Receive(request, default, Token);
|
||||||
|
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SynchronizeFollowing && j.State == JobState.Pending && j.Payload.Contains(bob.Id))
|
||||||
|
.ExecuteSingleAsync(Token);
|
||||||
|
var handler = new FollowingSynchronization(_harness.Db, _harness.Local, _harness.Remote, _harness.Follows, _harness.Delivery);
|
||||||
|
Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<Following> Follows(LocalActor persona, RemoteActor bob, FollowState state)
|
||||||
|
{
|
||||||
|
var following = new Following
|
||||||
|
{
|
||||||
|
AvatarId = persona.Id, TargetActorURI = bob.Id, TargetInboxURL = bob.Id + "/inbox", State = state, FollowActivityURI = persona.ActivityUri($"follow-{Guid.NewGuid():N}")
|
||||||
|
};
|
||||||
|
await DB.Default.SaveAsync(following, Token);
|
||||||
|
return following;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<Following> Following(LocalActor persona, RemoteActor bob) =>
|
||||||
|
DB.Default.Find<Following>().Match(f => f.AvatarId == persona.Id && f.TargetActorURI == bob.Id).ExecuteFirstAsync(Token);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_claim_that_differs_is_mended_from_the_accounts_own_list()
|
||||||
|
{
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var (_, carol) = await _harness.Persona("carol");
|
||||||
|
var (_, dave) = await _harness.Persona("dave");
|
||||||
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||||
|
await Follows(alice, bob, FollowState.Accepted);
|
||||||
|
await Follows(carol, bob, FollowState.Requested);
|
||||||
|
|
||||||
|
await Claimed(bob, FollowersSynchronization.Digest([carol.Uri, dave.Uri]), carol.Uri, dave.Uri);
|
||||||
|
|
||||||
|
Assert.Null(await Following(alice, bob));
|
||||||
|
Assert.Equal(FollowState.Accepted, (await Following(carol, bob)).State);
|
||||||
|
var undos = (await _harness.Outgoing(bob.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Undo").ToList();
|
||||||
|
Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue<string>()).Order());
|
||||||
|
var daves = Assert.Single(undos, u => u["actor"]!.GetValue<string>() == dave.Uri);
|
||||||
|
Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue<string>(), daves["object"]!["object"]!.GetValue<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_list_the_claim_does_not_describe_ends_no_follow()
|
||||||
|
{
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||||
|
await Follows(alice, bob, FollowState.Accepted);
|
||||||
|
|
||||||
|
await Claimed(bob, FollowersSynchronization.Digest(["https://privapub.test/peasants/someone"]));
|
||||||
|
|
||||||
|
Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_claim_that_agrees_reads_nothing()
|
||||||
|
{
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||||
|
await Follows(alice, bob, FollowState.Accepted);
|
||||||
|
|
||||||
|
await Claimed(bob, FollowersSynchronization.Digest([alice.Uri]));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path.EndsWith("/followers/sync"));
|
||||||
|
Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Trait("Category", "Integration")]
|
[Trait("Category", "Integration")]
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public static string HeaderValue(LocalActor actor, string digest) =>
|
public static string HeaderValue(LocalActor actor, string digest) =>
|
||||||
$"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\"";
|
$"collectionId=\"{actor.Followers}\", url=\"{RollCall(actor)}\", digest=\"{digest}\"";
|
||||||
|
|
||||||
|
// what a Collection-Synchronization header claims, written as a Signature header's parameters; default when it
|
||||||
|
// names no collection, list or digest
|
||||||
|
public static SynchronizationClaim Claim(string actorUri, string header)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var part in (header ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var equals = part.IndexOf('=');
|
||||||
|
if (equals > 0)
|
||||||
|
values[part[..equals].Trim()] = part[(equals + 1)..].Trim().Trim('"');
|
||||||
|
}
|
||||||
|
return values.TryGetValue("collectionId", out var collection) && values.TryGetValue("url", out var url) && values.TryGetValue("digest", out var digest)
|
||||||
|
&& digest.Length == 64 && digest.All(Uri.IsHexDigit)
|
||||||
|
? new SynchronizationClaim(actorUri, collection, url, digest.ToLowerInvariant())
|
||||||
|
: default;
|
||||||
|
}
|
||||||
|
|
||||||
// whether the activity, or the object it carries, is addressed to the actor's followers
|
// whether the activity, or the object it carries, is addressed to the actor's followers
|
||||||
public static bool ForFollowers(JsonObject activity, LocalActor actor) =>
|
public static bool ForFollowers(JsonObject activity, LocalActor actor) =>
|
||||||
Addressed(activity, actor.Followers) || activity["object"] is JsonObject inner && Addressed(inner, actor.Followers);
|
Addressed(activity, actor.Followers) || activity["object"] is JsonObject inner && Addressed(inner, actor.Followers);
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Domain.Social;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Federation.Rendering;
|
||||||
|
using PrivaPub.Infrastructure.Http;
|
||||||
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Actors
|
||||||
|
{
|
||||||
|
public sealed record SynchronizationClaim(string Actor, string CollectionId, string Url, string Digest);
|
||||||
|
|
||||||
|
// FEP-8fcf, received: an account elsewhere tells, with its delivery, a digest of its followers here. When the personas
|
||||||
|
// following it here digest otherwise, its partial list (read signed by the instance actor) says who they are: a follow
|
||||||
|
// only PrivaPub remembers ends, a request it answered without our knowing is accepted, and a follow only it remembers
|
||||||
|
// is undone by the persona, as Mastodon does. A follow ends only when the list is the one the digest describes.
|
||||||
|
public class FollowingSynchronization : IJobHandler
|
||||||
|
{
|
||||||
|
const int MaxPages = 10;
|
||||||
|
|
||||||
|
readonly DbEntities _dbEntities;
|
||||||
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly IRemoteActorService _remoteActors;
|
||||||
|
readonly IFollowService _follows;
|
||||||
|
readonly IDeliveryService _delivery;
|
||||||
|
|
||||||
|
public FollowingSynchronization(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IFollowService follows,
|
||||||
|
IDeliveryService delivery)
|
||||||
|
{
|
||||||
|
_dbEntities = dbEntities;
|
||||||
|
_localActors = localActors;
|
||||||
|
_remoteActors = remoteActors;
|
||||||
|
_follows = follows;
|
||||||
|
_delivery = delivery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JobKind Kind => JobKind.SynchronizeFollowing;
|
||||||
|
public int Concurrency => 1;
|
||||||
|
public int MaxAttempts => 3;
|
||||||
|
public int PerHostLimit => 1;
|
||||||
|
|
||||||
|
// once for each delivery that claims it, as Mastodon compares each: cheap while the digests agree
|
||||||
|
public static string DedupeKey(SynchronizationClaim claim, string activityId) =>
|
||||||
|
activityId == default ? default : $"followsync|{activityId}|{claim.Digest}";
|
||||||
|
|
||||||
|
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||||
|
{
|
||||||
|
var claim = JsonSerializer.Deserialize<SynchronizationClaim>(job.Payload);
|
||||||
|
var actor = claim == default ? default : await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == claim.Actor && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
if (actor == default || string.IsNullOrEmpty(actor.FollowersURL) || claim.CollectionId != actor.FollowersURL || !Origin.Same(claim.Url, actor.ActorURI))
|
||||||
|
return JobOutcome.Done;
|
||||||
|
|
||||||
|
var followings = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && !f.TargetIsLocal).ExecuteAsync(token);
|
||||||
|
var personas = new Dictionary<string, (LocalActor Persona, Following Following)>(StringComparer.Ordinal);
|
||||||
|
foreach (var following in followings)
|
||||||
|
if (await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token) is { } persona)
|
||||||
|
personas[persona.Uri] = (persona, following);
|
||||||
|
var accepted = personas.Values.Where(p => p.Following.State == FollowState.Accepted).Select(p => p.Persona.Uri).ToList();
|
||||||
|
if (FollowersSynchronization.Digest(accepted) == claim.Digest)
|
||||||
|
return JobOutcome.Done;
|
||||||
|
|
||||||
|
var listed = await Listed(claim.Url, token);
|
||||||
|
if (listed == default)
|
||||||
|
return JobOutcome.Retry("the partial followers collection could not be read");
|
||||||
|
|
||||||
|
var complete = FollowersSynchronization.Digest(listed) == claim.Digest;
|
||||||
|
var named = listed.ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (complete)
|
||||||
|
foreach (var uri in accepted.Where(uri => !named.Contains(uri)))
|
||||||
|
await _follows.UnfollowAs(personas[uri].Persona, actor.ActorURI, token);
|
||||||
|
|
||||||
|
foreach (var uri in named)
|
||||||
|
{
|
||||||
|
if (personas.TryGetValue(uri, out var known))
|
||||||
|
{
|
||||||
|
if (known.Following.State == FollowState.Requested)
|
||||||
|
await DB.Default.Update<Following>().MatchID(known.Following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (await _localActors.FindByUri(uri, token) is not { Kind: LocalActorKind.Person } stranger)
|
||||||
|
continue;
|
||||||
|
// (it has no id for a follow PrivaPub never made; it is undone by actor and object)
|
||||||
|
var undo = new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||||
|
["id"] = stranger.ActivityUri($"undo-follow-sync-{Guid.NewGuid():N}"),
|
||||||
|
["type"] = "Undo",
|
||||||
|
["actor"] = stranger.Uri,
|
||||||
|
["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = stranger.Uri, ["object"] = actor.ActorURI }
|
||||||
|
};
|
||||||
|
await _delivery.Enqueue(stranger, new[] { string.IsNullOrEmpty(actor.InboxURL) ? actor.SharedInboxURL : actor.InboxURL }, undo, token);
|
||||||
|
}
|
||||||
|
return JobOutcome.Done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ids the collection lists, its pages followed on its own server; default when it cannot be read
|
||||||
|
async Task<List<string>> Listed(string url, CancellationToken token)
|
||||||
|
{
|
||||||
|
using var scope = HttpScope.For("followers-sync");
|
||||||
|
var items = new List<string>();
|
||||||
|
var next = url;
|
||||||
|
for (var page = 0; page < MaxPages && next != default; page++)
|
||||||
|
{
|
||||||
|
using var fetched = await _remoteActors.FetchObject(next, token);
|
||||||
|
if (fetched == default || fetched.Root.ValueKind != JsonValueKind.Object)
|
||||||
|
return page == 0 ? default : items;
|
||||||
|
var root = fetched.Root;
|
||||||
|
if (page == 0 && !Has(root, "orderedItems") && !Has(root, "items") && Link(root, "first") is { } first)
|
||||||
|
{
|
||||||
|
next = Origin.Same(first, url) ? first : default;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (var name in new[] { "orderedItems", "items" })
|
||||||
|
if (root.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array)
|
||||||
|
items.AddRange(list.EnumerateArray().Select(Id).Where(id => id != default));
|
||||||
|
next = Link(root, "next") is { } after && Origin.Same(after, url) ? after : default;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Has(JsonElement node, string name) => node.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array;
|
||||||
|
|
||||||
|
static string Link(JsonElement node, string name) => node.TryGetProperty(name, out var value) ? Id(value) : default;
|
||||||
|
|
||||||
|
static string Id(JsonElement node) => node.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => node.GetString(),
|
||||||
|
JsonValueKind.Object when node.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String => id.GetString(),
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,9 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
// once per follow, so a retry still counts as a copy
|
// once per follow, so a retry still counts as a copy
|
||||||
if (!queued && dedupe != default && type == "Undo" && await FollowAgain(activity, actorUri, token) is { } follow)
|
if (!queued && dedupe != default && type == "Undo" && await FollowAgain(activity, actorUri, token) is { } follow)
|
||||||
queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}|{follow}", token);
|
queued = await _queue.Enqueue(JobKind.ProcessInbox, queuedPayload, host, $"{dedupe}|{Digest(activity)}|{follow}", token);
|
||||||
|
// FEP-8fcf: what the sender says of its followers here (FollowingSynchronization)
|
||||||
|
if (queued && forwardedBy == default && FollowersSynchronization.Claim(actorUri, request.Headers[FollowersSynchronization.Header].ToString()) is { } claim)
|
||||||
|
await _queue.Enqueue(JobKind.SynchronizeFollowing, JsonSerializer.Serialize(claim), host, FollowingSynchronization.DedupeKey(claim, activityId), token);
|
||||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
||||||
return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued");
|
return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||||
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
|
||||||
|
.AddSingleton<IJobHandler, Federation.Actors.FollowingSynchronization>()
|
||||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||||
.AddSingleton<IJobHandler, Domain.Statuses.PublishScheduledJob>()
|
.AddSingleton<IJobHandler, Domain.Statuses.PublishScheduledJob>()
|
||||||
.AddSingleton<InstanceDescriber>()
|
.AddSingleton<InstanceDescriber>()
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ namespace PrivaPub.Models.Jobs
|
|||||||
CountAccount,
|
CountAccount,
|
||||||
PublishScheduled,
|
PublishScheduled,
|
||||||
FetchReplies,
|
FetchReplies,
|
||||||
BackfillOutbox
|
BackfillOutbox,
|
||||||
|
SynchronizeFollowing
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum JobState
|
public enum JobState
|
||||||
|
|||||||
+1
-1
@@ -1165,7 +1165,7 @@ snapshots; `/api/privapub/v1/cdns` and `/cdns/:domain` group servers by CDN, wee
|
|||||||
| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 |
|
| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 |
|
||||||
| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 |
|
| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 |
|
||||||
| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 |
|
| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 |
|
||||||
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 |
|
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. **Done 2026-10-06**, both ways, checked live against Mastodon. | P2 |
|
||||||
| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 |
|
| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 |
|
||||||
| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 |
|
| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 |
|
||||||
| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 |
|
| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 |
|
||||||
|
|||||||
+3
-2
@@ -672,8 +672,9 @@ it, raw where it doesn't.
|
|||||||
- re-run WebFinger on a rename;
|
- re-run WebFinger on a rename;
|
||||||
- inbound `Block`; `Add`/`Remove` of pins: **done 2026-10-05** (both ways, a community's pins too, the `featured`
|
- 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);
|
collection read with an account's counts; checked live against Mastodon);
|
||||||
- FEP-8fcf followers sync: sending **done 2026-10-06** (owner decision; the digest of a persona's followers on the
|
- FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the
|
||||||
receiving server, and a signed roll-call listing only them; Mastodon mends both ways from it, checked live);
|
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
|
||||||
|
mended from its list; checked live against Mastodon, all four ways a follow can be lost);
|
||||||
- `indexable`/`discoverable`/`searchableBy`;
|
- `indexable`/`discoverable`/`searchableBy`;
|
||||||
- edit history from `formerRepresentations`;
|
- edit history from `formerRepresentations`;
|
||||||
- PeerTube reply rules and `ApproveReply`.
|
- PeerTube reply rules and `ApproveReply`.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Followers synchronisation (FEP-8fcf, owner decision 2026-10-06): alice's followers-only post tells Mastodon, in a signed
|
# Followers synchronisation (FEP-8fcf, owner decision 2026-10-06), both ways. Sent: alice's followers-only post tells
|
||||||
# header, a digest of her followers there; when Mastodon's view differs it reads her roll-call and mends itself. Both ways:
|
# Mastodon, in a signed header, a digest of her followers there; when Mastodon's view differs it reads her roll-call and
|
||||||
# a follow PrivaPub lost (Mastodon drops it), and a follow Mastodon lost (it sends the Undo PrivaPub then applies). The
|
# mends itself: a follow PrivaPub lost (Mastodon drops it), and a follow Mastodon lost (it sends the Undo PrivaPub
|
||||||
# roll-call lists Mastodon's accounts only, and only to a signed request. Needs the mastodon peer.
|
# applies). The roll-call answers only a signed request. Received: mastouser's followers-only post tells PrivaPub a digest
|
||||||
|
# of its followers here (alice and bob); PrivaPub reads Mastodon's list and ends a follow Mastodon lost, and undoes one
|
||||||
|
# only Mastodon remembers. Needs the mastodon peer.
|
||||||
M=https://mastodon.test:6443
|
M=https://mastodon.test:6443
|
||||||
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
|
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
|
||||||
. "$here/peers/mastodon.sh"
|
. "$here/peers/mastodon.sh"
|
||||||
@@ -49,3 +51,29 @@ m_rails "a = Account.find_local(\"mastouser\"); t = Account.find_by(uri: \"$alic
|
|||||||
[ "$(m_follows)" = "False" ] && ok "Mastodon forgets the follow" || ko "Mastodon still has the follow"
|
[ "$(m_follows)" = "False" ] && ok "Mastodon forgets the follow" || ko "Mastodon still has the follow"
|
||||||
quiet_post "for my followers again $run"
|
quiet_post "for my followers again $run"
|
||||||
until_true 60 '[ "$(alice_followers)" = "0" ]' && ok "Mastodon reads the roll-call and undoes the follow PrivaPub had" || ko "PrivaPub still counts mastouser"
|
until_true 60 '[ "$(alice_followers)" = "0" ]' && ok "Mastodon reads the roll-call and undoes the follow PrivaPub had" || ko "PrivaPub still counts mastouser"
|
||||||
|
|
||||||
|
echo " received: mastouser's followers here"
|
||||||
|
BT=$(privapub_token bob_sync)
|
||||||
|
BH="Authorization: Bearer $BT"
|
||||||
|
m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
|
||||||
|
p_follows() { curl -s -H "$1" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0]['following'])"; }
|
||||||
|
m_followed_by() { m_rails "puts Follow.exists?(account: Account.find_by(uri: \"${P_BASE:-https://privapub.test}/peasants/$1\"), target_account: Account.find_local(\"mastouser\")) ? \"True\" : \"False\""; }
|
||||||
|
m_quiet() { mcurl -s -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" -d "status=$1&visibility=private"; }
|
||||||
|
for who in "$AH" "$BH"; do
|
||||||
|
[ "$(p_follows "$who")" = "True" ] || curl -s -o /dev/null -X POST -H "$who" "$P/api/v1/accounts/$m_on_p/follow"
|
||||||
|
done
|
||||||
|
until_true 45 '[ "$(p_follows "$AH")" = "True" ] && [ "$(p_follows "$BH")" = "True" ] && [ "$(m_followed_by alice_sync)" = "True" ] && [ "$(m_followed_by bob_sync)" = "True" ]' \
|
||||||
|
&& ok "alice and bob follow mastouser" || ko "alice and bob never both followed mastouser"
|
||||||
|
|
||||||
|
m_rails "Follow.where(account: Account.find_by(uri: \"https://privapub.test/peasants/alice_sync\"), target_account: Account.find_local(\"mastouser\")).destroy_all" >/dev/null
|
||||||
|
[ "$(m_followed_by alice_sync)" = "False" ] && ok "Mastodon forgets alice's follow" || ko "Mastodon still has alice's follow"
|
||||||
|
m_quiet "for my followers $run"
|
||||||
|
until_true 60 '[ "$(p_follows "$AH")" = "False" ]' && ok "PrivaPub reads mastouser's list and ends alice's follow" || ko "alice still follows mastouser on PrivaPub"
|
||||||
|
[ "$(p_follows "$BH")" = "True" ] && ok "bob's follow stays" || ko "bob's follow ended too"
|
||||||
|
|
||||||
|
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow"
|
||||||
|
until_true 45 '[ "$(p_follows "$AH")" = "True" ] && [ "$(m_followed_by alice_sync)" = "True" ]' && ok "alice follows mastouser again" || ko "alice could not follow mastouser again"
|
||||||
|
p_mongo "db.Following.deleteMany({AvatarId:'$alice_id', TargetActorURI:'$mastouser_uri'})" >/dev/null
|
||||||
|
[ "$(p_follows "$AH")" = "False" ] && ok "PrivaPub forgets alice's follow" || ko "PrivaPub still has alice's follow"
|
||||||
|
m_quiet "for my followers again $run"
|
||||||
|
until_true 60 '[ "$(m_followed_by alice_sync)" = "False" ]' && ok "PrivaPub undoes the follow only Mastodon remembered" || ko "Mastodon still has alice following mastouser"
|
||||||
Reference in new issue
Block a user