From d40526952654b21dadd98a3da36798c2411137ce Mon Sep 17 00:00:00 2001 From: thepra Date: Sun, 4 Oct 2026 23:49:15 +0200 Subject: [PATCH] A remote account's Block of a persona is enforced Inbound Block was dropped as an unknown type (the pasture's last Mastodon expected failure, G-0002). Now, as Mastodon does it: the follows between the blocker and the persona end here too (nothing is sent back; the blocker already ended its side), the blocker's posts and notifications are hidden from the persona and kept out of its home, the persona's posts are no longer addressed to the blocker by mention or reply, and the relationship says blocked_by. Undo{Block} lifts it. The block is kept in BlockedBy, unique per persona and blocker. The Mastodon scenario checks blocked_by instead of expecting a failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- FEDERATION.md | 1 + .../Federation/InboundBlockTests.cs | 77 +++++++++++++++++++ PrivaPub.Tests/Support/Harness.cs | 3 +- .../Controllers/AccountsController.cs | 4 +- .../Relationships/RelationshipService.cs | 5 ++ .../Federation/Inbox/Handlers/BlockHandler.cs | 72 +++++++++++++++++ .../Federation/Inbox/Handlers/UndoHandler.cs | 2 + PrivaPub/Federation/Outbox/OutboxPublisher.cs | 10 ++- PrivaPub/Infrastructure/Data/Indexes.cs | 2 + .../Middleware/SocialPubConfigurations.cs | 1 + PrivaPub/Models/Social/Relationships.cs | 11 +++ docs/INTEROP.md | 5 +- tools/pasture/scenarios/mastodon.sh | 3 +- 13 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 PrivaPub.Tests/Federation/InboundBlockTests.cs create mode 100644 PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs diff --git a/FEDERATION.md b/FEDERATION.md index 7f3ce44..0fdb703 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -118,6 +118,7 @@ Received: | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Flag` | becomes a report for this server's moderators | +| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it | Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, `Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`. diff --git a/PrivaPub.Tests/Federation/InboundBlockTests.cs b/PrivaPub.Tests/Federation/InboundBlockTests.cs new file mode 100644 index 0000000..fabc301 --- /dev/null +++ b/PrivaPub.Tests/Federation/InboundBlockTests.cs @@ -0,0 +1,77 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Relationships; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + // A remote account's Block of a persona, as Mastodon sends it (the pasture's last Mastodon expected failure, G-0002) + [Trait("Category", "Integration")] + public sealed class InboundBlockTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + + [Fact] + public async Task A_block_ends_the_follows_both_ways_hides_the_blocker_and_its_undo_lifts_it() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var carol = new RemoteActor(_harness.Peer, "carol"); + await _harness.FollowedBy(alice, carol); + await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token); + var block = new JsonObject + { + ["id"] = $"{Origin(carol)}/blocks/{Guid.NewGuid():N}", ["type"] = "Block", ["actor"] = carol.Id, ["object"] = alice.Uri + }; + + await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", block); + + Assert.True(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == carol.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == carol.Id).ExecuteAnyAsync(token)); + Assert.Contains(carol.Id, await Hidden.AuthorsHiddenFrom(alice.Id, new[] { carol.Id }, forNotifications: false, token)); + Assert.Contains(alice.Id, await Hidden.RecipientsHiding(new[] { alice.Id }, carol.Id, token)); + + await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", new JsonObject + { + ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = block + }); + + Assert.False(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == carol.Id).ExecuteAnyAsync(token)); + Assert.Empty(await Hidden.AuthorsHiddenFrom(alice.Id, new[] { carol.Id }, forNotifications: false, token)); + } + + [Fact] + public async Task A_block_of_someone_who_is_not_ours_is_dropped() + { + var token = TestContext.Current.CancellationToken; + var carol = new RemoteActor(_harness.Peer, "carol"); + + await _harness.Deliver(carol, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(carol)}/blocks/{Guid.NewGuid():N}", ["type"] = "Block", ["actor"] = carol.Id, + ["object"] = $"{Origin(carol)}/users/somebody-else" + }); + + Assert.False(await DB.Default.Find().Match(b => b.ActorURI == carol.Id).ExecuteAnyAsync(token)); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index a7e5178..9c87bef 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -67,7 +67,8 @@ namespace PrivaPub.Tests.Support new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), - new FlagHandler(Db, Local) + new FlagHandler(Db, Local), + new BlockHandler(Db, Local) }; Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 158777b..62af29e 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -385,7 +385,9 @@ namespace PrivaPub.Api.Mastodon.Controllers var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token); var blocking = uri != default && await DB.Default.Find().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token); var mute = uri == default ? default : await DB.Default.Find().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token); - var blockedBy = local != default && await DB.Default.Find().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token); + 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); return new Relationship { diff --git a/PrivaPub/Domain/Relationships/RelationshipService.cs b/PrivaPub/Domain/Relationships/RelationshipService.cs index e98bb50..144b2b2 100644 --- a/PrivaPub/Domain/Relationships/RelationshipService.cs +++ b/PrivaPub/Domain/Relationships/RelationshipService.cs @@ -179,6 +179,9 @@ namespace PrivaPub.Domain.Relationships var now = DateTime.UtcNow; foreach (var block in await DB.Default.Find().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token)) hidden.Add(block.TargetActorURI); + //an account that blocked the persona is hidden from it too, as Mastodon hides it from the blocked account's home + foreach (var blocker in await DB.Default.Find().Match(b => b.AvatarId == avatarId && uris.Contains(b.ActorURI)).ExecuteAsync(token)) + hidden.Add(blocker.ActorURI); foreach (var mute in await DB.Default.Find().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token)) if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications)) hidden.Add(mute.TargetActorURI); @@ -198,6 +201,8 @@ namespace PrivaPub.Domain.Relationships var now = DateTime.UtcNow; foreach (var block in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token)) hiding.Add(block.AvatarId); + foreach (var blocked in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId) && b.ActorURI == actorUri).ExecuteAsync(token)) + hiding.Add(blocked.AvatarId); 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); diff --git a/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs b/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs new file mode 100644 index 0000000..41152e9 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/BlockHandler.cs @@ -0,0 +1,72 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + // A remote account blocks one of our personas, as Mastodon does it: the follows between them end here too (the blocker + // already ended its side, so nothing is sent back), the persona stops seeing the blocker's posts and notifications, the + // relationship says blocked_by, and nothing of the persona's is addressed to the blocker any more. Undo{Block} lifts it. + public class BlockHandler : IActivityHandler + { + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + + public BlockHandler(DbEntities dbEntities, ILocalActorService localActors) + { + _dbEntities = dbEntities; + _localActors = localActors; + } + + public string Type => "Block"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var target = Id(activity["object"]) is { } uri ? await _localActors.FindByUri(uri, token) : default; + if (target is not { Kind: LocalActorKind.Person }) + { + Arrival.Drop("unknown-object"); + return; + } + try + { + await DB.Default.SaveAsync(new BlockedBy { AvatarId = target.Id, ActorURI = actor.ActorURI, AccountId = actor.ID, ActivityURI = Id(activity) }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + Arrival.Drop("duplicate"); + return; + } + Arrival.Accept("stored"); + + await DB.Default.DeleteAsync(f => f.AvatarId == target.Id && f.TargetActorURI == actor.ActorURI); + await DB.Default.DeleteAsync(f => f.LocalActorId == target.Id && f.ActorURI == actor.ActorURI); + await DB.Default.DeleteAsync(e => e.AvatarId == target.Id && e.AuthorAccountId == actor.ID); + await DB.Default.DeleteAsync(n => n.AvatarId == target.Id && n.FromAccountId == actor.ID); + } + + // Undo{Block}: the block it names by id, or else the blocker's block of the persona the inner Block names + public static async Task Undo(JsonNode inner, string innerId, ForeignAvatar actor, ILocalActorService localActors, CancellationToken token) + { + var block = innerId == default + ? default + : await DB.Default.Find().Match(b => b.ActivityURI == innerId && b.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + if (block == default && inner is JsonObject && Id(inner["object"]) is { } objectUri + && await localActors.FindByUri(objectUri, token) is { Kind: LocalActorKind.Person } persona) + block = await DB.Default.Find().Match(b => b.AvatarId == persona.Id && b.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + if (block == default) + return false; + await DB.Default.DeleteAsync(block.ID); + return true; + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs index 1362b60..c5545f8 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs @@ -47,6 +47,8 @@ namespace PrivaPub.Federation.Inbox.Handlers undone |= await UndoAnnounce(innerId, actor, token); if (innerType is null or "Dislike") undone |= await UndoDislike(inner, innerId, actor, token); + if (innerType is null or "Block") + undone |= await BlockHandler.Undo(inner, innerId, actor, _localActors, token); if (innerType is null or "Like" or "EmojiReact") undone |= await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default, inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token); diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs index e0fba61..73e192a 100644 --- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -1,7 +1,10 @@ +using MongoDB.Entities; + using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; @@ -43,9 +46,12 @@ namespace PrivaPub.Federation.Outbox if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly) inboxes.AddRange(await _delivery.FollowerInboxes(author, token)); + //nothing is addressed to an account that blocked the author (it would refuse it, and Mastodon counts on that) + var blockers = (await DB.Default.Find().Match(b => b.AvatarId == author.Id).ExecuteAsync(token)) + .Select(b => b.ActorURI).ToHashSet(StringComparer.Ordinal); var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI) .Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty()) - .Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) + .Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri)) .Distinct(StringComparer.Ordinal) .ToList(); foreach (var uri in addressed) @@ -58,7 +64,7 @@ namespace PrivaPub.Federation.Outbox if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId)) { var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token); - if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL)) + if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL) && !blockers.Contains(parentAuthor.ActorURI)) inboxes.Add(parentAuthor.InboxURL); } diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 1bcdb97..70d67f1 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -95,6 +95,7 @@ namespace PrivaPub.Infrastructure.Data foreach (var pair in new (Func, string)[] { (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"), + (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "blocked by"), (() => DB.Default.Index().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"), (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"), (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"), @@ -102,6 +103,7 @@ namespace PrivaPub.Infrastructure.Data }) await pair.Item1(); await Plain(token, b => b.TargetActorURI); + await Plain(token, b => b.ActorURI); await Plain(token, m => m.TargetActorURI); await Plain(token, r => r.IsResolved, r => r.ID); await Unique(b => b.Domain, Builders.Filter.Type(b => b.Domain, BsonType.String), token); diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 3e3977d..3b8a4f6 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -92,6 +92,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Social/Relationships.cs b/PrivaPub/Models/Social/Relationships.cs index 263ee69..fd463ed 100644 --- a/PrivaPub/Models/Social/Relationships.cs +++ b/PrivaPub/Models/Social/Relationships.cs @@ -10,6 +10,17 @@ namespace PrivaPub.Models.Social public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } + //a remote account that blocked one of our personas (an inbound Block): its posts and notifications are hidden from the + //persona, the follows between them are gone, and the relationship says blocked_by, until it sends Undo{Block} + public class BlockedBy : Entity + { + public string AvatarId { get; set; } + public string ActorURI { get; set; } + public string AccountId { get; set; }//the blocker's ForeignAvatar.ID + public string ActivityURI { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + public class Mute : Entity { public string AvatarId { get; set; } diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 3803f70..2cd16e1 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -144,7 +144,7 @@ Priorities, used throughout: | Link attachments as the card source | P1 | `Status.card` | | Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account, 7-day lock); move each persona's follow | P1 | `Account.moved` | | Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` | -| Inbound `Block`: stop delivering, hide | P2 | `relationship.blocked_by` | +| Inbound `Block`: stop delivering, hide (**done** 2026-10-04) | P2 | `relationship.blocked_by` | | `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` | | Remote like and boost totals | P2 | counts | | Publish `context` and a paged `replies` | P2 | — | @@ -182,7 +182,8 @@ Findings: makes Mastodon delete its copy. Reporting the numeric-inbox recipient loss upstream is still worth doing. - **With SecureMode on** (all six peers, 2026-10-04) every federation check passes: Mastodon, GoToSocial, Misskey, Sharkey, Akkoma and Lemmy all sign their fetches, and fetch our instance actor's key unsigned first. -- Inbound `Block` from Mastodon is not enforced yet (P7). +- Inbound `Block` from Mastodon is enforced since 2026-10-04 (`BlockHandler`, `BlockedBy`): follows end both ways, the + blocker is hidden from the persona, and the relationship says `blocked_by`. ### GoToSocial: 0.22.1 (2026-07-20) diff --git a/tools/pasture/scenarios/mastodon.sh b/tools/pasture/scenarios/mastodon.sh index c360f4a..4ffdef9 100644 --- a/tools/pasture/scenarios/mastodon.sh +++ b/tools/pasture/scenarios/mastodon.sh @@ -174,7 +174,8 @@ curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$masto_on_p/block" until_true 30 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/relationships?id[]=$alice_on_m" | j "print(d[0][\"blocked_by\"])")" = "True" ]' && ok "alice_masto's block reaches Mastodon" || ko "block not applied on Mastodon" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$masto_on_p/unblock" mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/block" -sleep 5; xf "Mastodon's block is enforced on PrivaPub (inbound Block is P7)" +until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$masto_on_p" | j "print(d[0][\"blocked_by\"])")" = "True" ]' \ + && ok "Mastodon's block reaches PrivaPub (blocked_by)" || ko "Mastodon's block not recorded on PrivaPub" echo " statistics" stats_check mastodon.test mastodon