diff --git a/CLAUDE.md b/CLAUDE.md index eda6765..4843c35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -608,8 +608,9 @@ tools/pasture/run.sh down # removes e `log_bin_trust_function_creators`, and a first start that failed there leaves the database without triggers (drop it and start again). Accounts come from its signup form (opened, with a short description, which its NodeInfo needs) and are renamed in its database; its API is VKontakte's (`/api/method/?v=1.0`), with a password grant for a local - application the peer script inserts. `scenarios/smithereen.sh`, 30 checks and one known gap (G-0009, posts on - someone else's wall). + application the peer script inserts. `scenarios/smithereen.sh`, 40 checks, walls both ways (alice's profile is + told again first, so Smithereen reads her `sm:wall`; `servers.features & 1` says it took PrivaPub for a server with + walls). Its scenario texts avoid apostrophes: `p_home_has` puts them inside a Python string. - **Load (`load.sh`, needs the `flood` peer):** `flood` (`flood/flood.cs`, published once into `.flood`) answers as twenty fake servers and sends signed activities at a set rate; `load.sh --rate=N --seconds=N` measures the answers, the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in diff --git a/FEDERATION.md b/FEDERATION.md index a8ec410..ffad14a 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -94,6 +94,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the | Activities | `/peasants/{name}/grunts/{id}` | | Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` | | Direct-message context | `/peasants/{name}/whispers/{id}` | +| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` | +| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` | | Profile and post pages | `/@{name}`, `/@{name}/{id}` | The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path. @@ -159,7 +161,7 @@ Received: | `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 | | `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over | -| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. Any other target (Smithereen's wall, a community's moderators) is dropped | +| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly | | `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}`, @@ -183,6 +185,16 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T 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. +- **Walls** (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its `wall` + (`…/graffiti`, `sm:wall`) and Smithereen's `privacySettings`: its followers may write on it (`wallPosting`), everyone + may read it (`wallPostVisibility`). A public post that is not a reply, sent with the wall as its `target` by an account + following the persona, is hosted: the persona is notified (as a mention), it reaches the persona's home and its + followers' here, and the persona's followers' servers and the author's are told with `Add{Note}` (the wall as the + target). Anything else written there is dropped. The persona may delete it (`DELETE /api/v1/statuses/:id`), which + sends `Remove{Note}` with the wall as a plain id; Smithereen deletes the post then, as it does when an owner deletes a + post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest + first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there + has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away. - **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. - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it diff --git a/PrivaPub.Tests/Federation/WallTests.cs b/PrivaPub.Tests/Federation/WallTests.cs new file mode 100644 index 0000000..9ce8a50 --- /dev/null +++ b/PrivaPub.Tests/Federation/WallTests.cs @@ -0,0 +1,213 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +using static PrivaPub.Tests.Support.Host.FederationHelpers; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Tests.Federation +{ + // walls (FEP-400e, Smithereen's; owner decision 2026-10-06): a persona's followers write on its wall, PrivaPub hosts what + // they write and tells the persona's followers with Add{Note}; the persona takes it off with Remove; an account elsewhere + // that tells of a post on its own wall has it shown to its followers here + [Trait("Category", "Integration")] + public sealed class WallTests : IAsyncLifetime + { + const string Public = "https://www.w3.org/ns/activitystreams#Public"; + + 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 CancellationToken Token => TestContext.Current.CancellationToken; + + // a public note by author, written on the wall of owner (whose actor and wall are given), served at its id + string WallNote(RemoteActor author, string ownerUri, string wall, string text, string inReplyTo = default, string to = Public) + { + var path = $"/notes/{Guid.NewGuid():N}"; + var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority); + var note = new JsonObject + { + ["id"] = origin + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", + ["to"] = new JsonArray(to, ownerUri), ["published"] = DateTime.UtcNow.ToString("O"), + ["target"] = new JsonObject { ["type"] = "Collection", ["id"] = wall, ["attributedTo"] = ownerUri } + }; + if (inReplyTo != default) + note["inReplyTo"] = inReplyTo; + _harness.Peer.Serve(path, note.ToJsonString()); + _notes[origin + path] = note; + return origin + path; + } + + readonly Dictionary _notes = new(); + + // as Smithereen sends it: the whole note, to the wall's owner + JsonObject Create(RemoteActor author, string noteId) => new() + { + ["id"] = noteId + "/activityCreate", ["type"] = "Create", ["actor"] = author.Id, ["to"] = _notes[noteId]["to"]!.DeepClone(), + ["object"] = _notes[noteId].DeepClone() + }; + + Task Post(string objectUri) => DB.Default.Find().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(Token); + + [Fact] + public void A_persona_names_its_wall_and_lets_its_followers_write_on_it() + { + var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Person }); + var circle = ActivityPubRenderer.Actor(new LocalActor { Id = "c", UserName = "circle", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Group, IsCircle = true }); + + Assert.Equal($"{Harness.Base}/peasants/alice/graffiti", actor["wall"]!.GetValue()); + Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue()); + Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue()); + Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue() == "sm:wall"); + Assert.Null(circle["wall"]); + } + + [Fact] + public async Task A_followers_post_on_the_wall_is_hosted_shown_and_told_to_the_personas_followers() + { + var (_, alice) = await _harness.Persona("alice"); + var smuser = new RemoteActor(_harness.Peer, "smuser", wall: true); + await _harness.FollowedBy(alice, smuser); + var noteId = WallNote(smuser, alice.Uri, alice.Wall, "on alice's wall"); + + await _harness.Deliver(smuser, "/human-centipede", Create(smuser, noteId)); + + var post = await Post(noteId); + Assert.NotNull(post); + Assert.Equal((alice.Wall, alice.Uri, alice.Id), (post.WallURI, post.WallOwnerURI, post.WallOwnerAccountId)); + Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token)); + Assert.True(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.PostId == post.ID && n.Type == NotificationType.Mention).ExecuteAnyAsync(Token)); + var add = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Add"); + Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue(), add["object"]!.GetValue(), add["target"]!["id"]!.GetValue())); + Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue() == "Add"); + + Assert.True(await _harness.Walls.Remove(alice, post, Token)); + + Assert.Null(await Post(noteId)); + var remove = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Remove"); + Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue(), remove["target"]!.GetValue())); + } + + [Fact] + public async Task A_wall_post_from_a_stranger_a_reply_or_a_quiet_one_is_refused() + { + var (_, alice) = await _harness.Persona("alice"); + var stranger = new RemoteActor(_harness.Peer, "stranger", wall: true); + var follower = new RemoteActor(_harness.Peer, "follower", wall: true); + await _harness.FollowedBy(alice, follower); + var fromStranger = WallNote(stranger, alice.Uri, alice.Wall, "from a stranger"); + var reply = WallNote(follower, alice.Uri, alice.Wall, "a reply", inReplyTo: "https://elsewhere.example/notes/1"); + var quiet = WallNote(follower, alice.Uri, alice.Wall, "quietly", to: follower.Id + "/followers"); + + await _harness.Deliver(stranger, "/human-centipede", Create(stranger, fromStranger)); + await _harness.Deliver(follower, "/human-centipede", Create(follower, reply)); + await _harness.Deliver(follower, "/human-centipede", Create(follower, quiet)); + + Assert.Null(await Post(fromStranger)); + Assert.Null(await Post(reply)); + Assert.Null(await Post(quiet)); + Assert.DoesNotContain(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Add"); + } + + [Fact] + public async Task A_post_on_a_followed_accounts_wall_elsewhere_reaches_its_followers_here_until_it_is_taken_off() + { + var (_, alice) = await _harness.Persona("alice"); + var owner = new RemoteActor(_harness.Peer, "owner", wall: true); + var writer = new RemoteActor(_harness.Peer, "writer"); + await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); + var noteId = WallNote(writer, owner.Id, owner.Wall, "on the owner's wall"); + // (kept before PrivaPub read walls: its document is read again when it names one) + await DB.Default.SaveAsync(new PrivaPub.Models.User.ForeignAvatar + { + ActorURI = owner.Id, UserName = owner.Name, PublicKeyId = owner.KeyId, PublicKey = owner.PublicKeyPem, InboxURL = owner.Id + "/inbox", + FollowersURL = owner.Id + "/followers", UpdatedAt = DateTime.UtcNow.AddDays(-2) + }, Token); + + await _harness.Deliver(owner, "/human-centipede", new JsonObject + { + ["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityAdd", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = noteId, + ["target"] = new JsonObject { ["type"] = "Collection", ["id"] = owner.Wall, ["attributedTo"] = owner.Id }, + ["to"] = new JsonArray(Public, owner.Id + "/followers") + }); + + var post = await Post(noteId); + Assert.NotNull(post); + Assert.Equal((writer.Id, owner.Id), (post.ActorURI, post.WallOwnerURI)); + Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token)); + Assert.False(await DB.Default.Find().Match(f => f.ObjectURI == noteId).ExecuteAnyAsync(Token)); + + await _harness.Deliver(owner, "/human-centipede", new JsonObject + { + ["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityRemove", ["type"] = "Remove", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = owner.Wall + }); + + Assert.Null(await Post(noteId)); + } + } + + [Trait("Category", "Integration")] + public sealed class WallCollectionTests : IAsyncLifetime + { + PrivaPubHost _host; + HttpClient _client; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _client = _host.Client(); + } + + public ValueTask DisposeAsync() + { + _client?.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task The_wall_lists_the_personas_public_posts_and_what_others_wrote_on_it() + { + var token = TestContext.Current.CancellationToken; + var persona = await _host.Persona(await _host.SignUp(), "wall"); + var open = await _host.Publish(persona, "for everyone", PostVisibility.Public); + var quiet = await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly); + var wall = persona.ActorUri() + "/graffiti"; + var written = new PostEntity + { + ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", ActorURI = "https://elsewhere.example/users/writer", IsFederatedCopy = true, + Visibility = PostVisibility.Public, WallURI = wall, WallOwnerURI = persona.ActorUri(), WallOwnerAccountId = persona.Id + }; + await DB.Default.SaveAsync(written, token); + + var collection = await _client.Fetch($"/peasants/{persona.UserName}/graffiti"); + var page = await _client.Fetch($"/peasants/{persona.UserName}/graffiti?page=true"); + + Assert.Equal(HttpStatusCode.OK, collection.Status); + Assert.Equal((wall, 2), (collection.Json["id"]!.GetValue(), collection.Json["totalItems"]!.GetValue())); + var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue()).ToList(); + Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items); + Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID)); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 95849d6..a1dfdcb 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -59,6 +59,7 @@ namespace PrivaPub.Tests.Support Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Relationships = new RelationshipService(Db, Follows, Delivery); Featured = new FeaturedPosts(Db, Remote, RemotePosts); + Walls = new Walls(Db, Local, RemotePosts, Delivery, Fanout); Handlers = new IActivityHandler[] { new FollowHandler(Db, Local, Remote, Delivery), @@ -71,15 +72,15 @@ namespace PrivaPub.Tests.Support new DislikeHandler(Db), new JoinHandler(Db, Local, Delivery), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays), - new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays), + new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays, Walls), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery), new FlagHandler(Db, Local), new BlockHandler(Db, Local), new LockHandler(Db), new MoveHandler(Remote, Db, Local, Follows, Relationships), - new FeaturedHandler(Featured, add: true), - new FeaturedHandler(Featured, add: false) + new FeaturedHandler(Featured, add: true, Walls, Remote), + new FeaturedHandler(Featured, add: false, Walls, Remote) }; ((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers; Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger, Local); @@ -120,6 +121,7 @@ namespace PrivaPub.Tests.Support public TimelineService Timelines { get; } public RelationshipService Relationships { get; } public FeaturedPosts Featured { get; } + public Walls Walls { get; } public Relays Relays { get; } // the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new(); diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs index 6c5ff09..5415592 100644 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -14,14 +14,17 @@ namespace PrivaPub.Tests.Support { readonly RSA _key = RSA.Create(2048); readonly bool _namesSharedInbox; + readonly bool _hasWall; - // sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do - public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false) + // sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall: + // whether it has a wall (sm:wall, Smithereen's) + public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false) { Name = $"{name}{Guid.NewGuid():N}"[..20]; Id = $"{origin ?? peer.A}/users/{Name}"; Type = type; _namesSharedInbox = sharedInbox; + _hasWall = wall; peer.Serve($"/users/{Name}", Document().ToJsonString()); } @@ -31,6 +34,7 @@ namespace PrivaPub.Tests.Support public string Id { get; } public string KeyId => Id + "#main-key"; public string SharedInbox => Id.Split("/users/")[0] + "/inbox"; + public string Wall => Id + "/wall"; public JsonObject Document() { @@ -52,6 +56,8 @@ namespace PrivaPub.Tests.Support }; if (_namesSharedInbox) document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox }; + if (_hasWall) + document["wall"] = Wall; return document; } diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 20e91a6..8960f45 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -34,10 +34,13 @@ namespace PrivaPub.Api.Mastodon.Controllers readonly Domain.Media.IMediaService _media; readonly IParticipations _participations; + readonly Federation.Actors.IWalls _walls; public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes, - IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default) + IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default, + Federation.Actors.IWalls walls = default) { + _walls = walls; _participations = participations; _jobs = jobs; _media = media; @@ -161,7 +164,7 @@ namespace PrivaPub.Api.Mastodon.Controllers { var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); if (before == default) - return NotFoundError(); + return await OffWall(id, token); var status = await _mapper.Status(before, MyId, token); var outcome = await _statuses.Remove(Me, id, token); if (!outcome.Ok) @@ -174,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers return Json(status); } + // someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it + async Task OffWall(string id, CancellationToken token) + { + var walled = _walls == default + ? default + : await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + if (walled == default) + return NotFoundError(); + var status = await _mapper.Status(walled, MyId, token); + if (!await _walls.Remove(Me, walled, token)) + return NotFoundError(); + return Json(status); + } + [HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Context(string id, CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index fefa922..6bb439c 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -77,6 +77,7 @@ public PrivaPubAudio Audio { get; set; } public PrivaPubEvent Event { get; set; } public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only + public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's public PrivaPubVotes Votes { get; set; } = new(); } diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index d2021d4..7646ea3 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -203,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers var mentionAccounts = mentionUris.Count == 0 ? new Dictionary() : (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI); - var accounts = await Accounts(all.Select(AuthorOf), token); + var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token); var ids = all.Select(p => p.ID).ToList(); var favourited = viewerId == default @@ -277,7 +277,7 @@ namespace PrivaPub.Api.Mastodon.Mappers Card = Card(post), Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)), Emojis = Emojis(post.Emojis), - Privapub = Extension(post), + Privapub = Extension(post, accounts), EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(), Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() }, Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), @@ -480,8 +480,9 @@ namespace PrivaPub.Api.Mastodon.Mappers List Emojis(IEnumerable emojis) => emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new(); - PrivaPubStatus Extension(PostEntity post) => new() + PrivaPubStatus Extension(PostEntity post, Dictionary accounts) => new() { + Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId), ObjectType = post.ObjectType, Locked = post.LockedAt.HasValue, Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default, diff --git a/PrivaPub/Domain/Timelines/Fanout.cs b/PrivaPub/Domain/Timelines/Fanout.cs index fa0682d..0f3a42b 100644 --- a/PrivaPub/Domain/Timelines/Fanout.cs +++ b/PrivaPub/Domain/Timelines/Fanout.cs @@ -43,6 +43,14 @@ namespace PrivaPub.Domain.Timelines if (!string.IsNullOrEmpty(post.AudienceURI)) foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token)) recipients.Add(member.AvatarId); + // a post on a wall (FEP-400e): its owner, when a persona, and those following the owner + if (!string.IsNullOrEmpty(post.WallOwnerURI)) + { + if (await _dbEntities.Avatars.MatchID(post.WallOwnerAccountId).ExecuteAnyAsync(token)) + recipients.Add(post.WallOwnerAccountId); + foreach (var follower in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.WallOwnerURI && f.State == FollowState.Accepted).ExecuteAsync(token)) + recipients.Add(follower.AvatarId); + } // a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId)) diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index 4dfbd72..cc8244d 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -24,6 +24,7 @@ namespace PrivaPub.Federation.Actors public string Followers { get; init; } public string Following { get; init; } public string Featured { get; init; } + public string Wall { get; init; } public string SharedInbox { get; init; } public string Icon { get; init; } public bool Discoverable { get; init; } = true; @@ -75,6 +76,7 @@ namespace PrivaPub.Federation.Actors Followers = RemoteActorService.Text(root, "followers"), Following = RemoteActorService.Text(root, "following"), Featured = RemoteActorService.Text(root, "featured"), + Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"), 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/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 635d103..795855e 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -46,6 +46,8 @@ namespace PrivaPub.Federation.Actors public string Following => $"{Uri}/stalking"; public string Featured => $"{Uri}/trophies"; public string FeaturedTags => $"{Uri}/tattoos"; + public string Wall => $"{Uri}/graffiti"; + public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated; public string Flock => $"{Uri}/flock"; public string Wardens => $"{Uri}/wardens"; public string SharedInbox => $"{BaseAddress}/human-centipede"; diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index d77a58b..0faf927 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -205,6 +205,7 @@ namespace PrivaPub.Federation.Actors .Modify(a => a.FollowersURL, actor.Followers) .Modify(a => a.FollowingURL, actor.Following) .Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default) + .Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default) .Modify(a => a.SharedInboxURL, actor.SharedInbox) .Modify(a => a.PictureURL, actor.Icon) .Modify(a => a.ThumbnailURL, actor.Header) diff --git a/PrivaPub/Federation/Actors/Walls.cs b/PrivaPub/Federation/Actors/Walls.cs new file mode 100644 index 0000000..36f1ed8 --- /dev/null +++ b/PrivaPub/Federation/Actors/Walls.cs @@ -0,0 +1,157 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Domain.Timelines; +using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Federation.Actors +{ + public interface IWalls + { + Task OwnerOf(string wallUri, CancellationToken token); + Task MayWrite(LocalActor owner, ForeignAvatar author, CancellationToken token); + Task Hosted(PostEntity post, LocalActor owner, CancellationToken token); + Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token); + Task Remove(LocalActor owner, PostEntity post, CancellationToken token); + } + + // Walls (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its wall (`…/graffiti`), on which + // the accounts following it may write: their public post, sent with the wall as its `target`, is hosted here, shown to + // the persona and its followers, and told to its followers' servers with Add{Note}. The persona may take it off again + // (Remove, which Smithereen takes for a deletion, as it is on its walls). Another server's account that tells its + // followers here of a post on its wall (Add) has it shown to them as on its wall. + public class Walls : IWalls + { + public const string Path = "graffiti"; + + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemotePosts _remotePosts; + readonly IDeliveryService _delivery; + readonly IFanout _fanout; + + public Walls(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IDeliveryService delivery, IFanout fanout) + { + _dbEntities = dbEntities; + _localActors = localActors; + _remotePosts = remotePosts; + _delivery = delivery; + _fanout = fanout; + } + + public async Task OwnerOf(string wallUri, CancellationToken token) + { + if (string.IsNullOrEmpty(wallUri) || !wallUri.EndsWith("/" + Path, StringComparison.Ordinal)) + return default; + var owner = await _localActors.FindByUri(wallUri[..^(Path.Length + 1)], token); + return owner is { HasWall: true } && owner.Wall == wallUri ? owner : default; + } + + // its followers, as the actor says to Smithereen (wallPosting), unless the persona hides them + public async Task MayWrite(LocalActor owner, ForeignAvatar author, CancellationToken token) => + await _dbEntities.Followers.Match(f => f.LocalActorId == owner.Id && f.LocalActorKind == owner.Kind && f.ActorURI == author.ActorURI && f.IsAccepted) + .ExecuteAnyAsync(token) + && !(await Domain.Relationships.Hidden.RecipientsHiding(new[] { owner.Id }, author.ActorURI, token)).Contains(owner.Id); + + public async Task Hosted(PostEntity post, LocalActor owner, CancellationToken token) + { + await Notifications.Add(owner.Id, NotificationType.Mention, post.AuthorAccountId, post.ActorURI, post.ID, token); + var add = new JsonObject + { + ["@context"] = ActivityPubRenderer.Context(), + ["id"] = owner.ActivityUri($"wall-{post.ID}"), + ["type"] = "Add", + ["actor"] = owner.Uri, + ["object"] = post.ObjectURI, + ["target"] = new JsonObject { ["type"] = "OrderedCollection", ["id"] = owner.Wall, ["attributedTo"] = owner.Uri }, + ["to"] = new JsonArray(ActivityPubRenderer.Public, owner.Followers, post.ActorURI) + }; + await _delivery.EnqueueToFollowers(owner, add, token, await AuthorInbox(post, token)); + } + + public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token) + { + var objectUri = Id(activity["object"]); + if (objectUri == default) + { + Arrival.Drop("unparseable"); + return; + } + if (!add) + { + var walled = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.WallOwnerURI == owner.ActorURI).ExecuteFirstAsync(token); + if (walled == default) + { + Arrival.Drop("unknown-object"); + return; + } + await RemoteDeletes.Remove(walled, objectUri, token); + Arrival.Accept("unwalled"); + return; + } + + var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) + ?? await _remotePosts.StoreContext(objectUri, 0, token); + if (post is not { IsFederatedCopy: true, ReblogOfPostId: null, Visibility: PostVisibility.Public or PostVisibility.Unlisted } || !string.IsNullOrEmpty(post.InReplyToURI)) + { + Arrival.Drop("unknown-object"); + return; + } + Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate); + if (post.WallOwnerURI == owner.ActorURI) + { + Arrival.Drop("duplicate"); + return; + } + post.WallURI = owner.WallURL; + post.WallOwnerURI = owner.ActorURI; + post.WallOwnerAccountId = owner.ID; + await DB.Default.Update().MatchID(post.ID) + .Modify(p => p.WallURI, post.WallURI) + .Modify(p => p.WallOwnerURI, post.WallOwnerURI) + .Modify(p => p.WallOwnerAccountId, post.WallOwnerAccountId) + .ExecuteAsync(token); + await _fanout.Distribute(post, token); + Arrival.Accept("walled"); + } + + public async Task Remove(LocalActor owner, PostEntity post, CancellationToken token) + { + if (post == default || !owner.HasWall || post.WallURI != owner.Wall) + return false; + var remove = new JsonObject + { + ["@context"] = ActivityPubRenderer.Context(), + ["id"] = owner.ActivityUri($"unwall-{post.ID}"), + ["type"] = "Remove", + ["actor"] = owner.Uri, + ["object"] = post.ObjectURI, + // (a plain id: Smithereen refuses a Remove whose target is an object) + ["target"] = owner.Wall, + ["to"] = new JsonArray(ActivityPubRenderer.Public, owner.Followers, post.ActorURI) + }; + await _delivery.EnqueueToFollowers(owner, remove, token, await AuthorInbox(post, token)); + await RemoteDeletes.Remove(post, post.ObjectURI, token); + return true; + } + + async Task> AuthorInbox(PostEntity post, CancellationToken token) + { + var author = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == post.ActorURI).ExecuteFirstAsync(token); + var inbox = string.IsNullOrEmpty(author?.SharedInboxURL) ? author?.InboxURL : author.SharedInboxURL; + return inbox == default ? Array.Empty() : new[] { inbox }; + } + } +} diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index d531366..7df51d3 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -143,6 +143,30 @@ namespace PrivaPub.Federation.Controllers return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default)); } + // a persona's wall (FEP-400e): its own public posts that start a thread, and what its followers wrote on it, newest + // first, as links; Smithereen reads it when one of its accounts starts following the persona + [HttpGet, Route("{actor}/" + Walls.Path)] + public async Task Wall(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, CancellationToken token) + { + var local = await _localActors.FindByUserName(actor, token); + if (local is not { HasWall: true }) + return NotFound(); + var wall = local.Wall; + System.Linq.Expressions.Expression> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly + && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted) + && (p.WallURI == wall + || p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null); + if (!page) + return Activity(ActivityPubRenderer.OrderedCollection(wall, (int)await DB.Default.CountAsync(onWall, token), default, $"{wall}?page=true")); + var query = _dbEntities.Posts.Match(onWall); + if (!string.IsNullOrEmpty(maxId)) + query.Match(f => f.Lt(p => p.ID, maxId)); + var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token); + var pageId = string.IsNullOrEmpty(maxId) ? $"{wall}?page=true" : $"{wall}?page=true&max_id={maxId}"; + var next = latest.Count == OutboxSize ? $"{wall}?page=true&max_id={latest[^1].ID}" : default; + return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, wall, latest.Select(p => (JsonNode)(p.ObjectURI ?? local.PostUri(p.ID))), next, default)); + } + // FEP-8fcf: the persona's followers on the server that signs the request, and nobody else's (FollowersSynchronization) [HttpGet, Route("{actor}/groupies/roll-call")] public async Task RollCall(string actor, CancellationToken token) diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index 5bf73ac..3f6d7a2 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -42,11 +42,13 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly IQuoteService _quotes; readonly IInteractionApprovals _approvals; readonly Relays.IRelays _relays; + readonly IWalls _walls; public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes, - IInteractionApprovals approvals = default, Relays.IRelays relays = default) + IInteractionApprovals approvals = default, Relays.IRelays relays = default, IWalls walls = default) { + _walls = walls; _relays = relays; _approvals = approvals; _quotes = quotes; @@ -189,8 +191,17 @@ namespace PrivaPub.Federation.Inbox.Handlers // forwarded post) var relayed = !followed && visibility == PostVisibility.Public && _relays != default && await _relays.Passes(Arrival.Current?.ForwardedBy, token); + // written on a persona's wall (FEP-400e): public, not a reply, by an account following the persona, as its actor + // tells Smithereen + var wall = _walls == default || note.Wall == default ? default : await _walls.OwnerOf(note.Wall, token); + if (wall != default && (visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || !string.IsNullOrEmpty(note.InReplyTo) + || note.WallOwner != default && note.WallOwner != wall.Uri || !await _walls.MayWrite(wall, author, token))) + { + Arrival.Drop("wall-refused"); + return; + } if (visibility == PostVisibility.Direct ? persons.Count == 0 - : group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed) + : group == default && wall == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed) { Arrival.Drop("not-addressed"); return; @@ -212,6 +223,12 @@ namespace PrivaPub.Federation.Inbox.Handlers post.ActivityURI = Id(activity); post.GroupId = group?.Id; post.ConversationId = conversation?.ID; + if (wall != default) + { + post.WallURI = wall.Wall; + post.WallOwnerURI = wall.Uri; + post.WallOwnerAccountId = wall.Id; + } try { await DB.Default.SaveAsync(post, token); @@ -235,6 +252,8 @@ namespace PrivaPub.Federation.Inbox.Handlers await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token); if (group is { IsCircle: false }) await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token); + if (wall != default) + await _walls.Hosted(post, wall, token); } async Task IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) => diff --git a/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs index 4b8cebc..8a5bf10 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs @@ -6,21 +6,38 @@ using System.Text.Json.Nodes; namespace PrivaPub.Federation.Inbox.Handlers { // An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma - // send it; inside a community's announce, the community features a post made in it. Any other target (Smithereen's - // wall, a community's moderators) is not ours to keep. + // send it; inside a community's announce, the community features a post made in it. On its wall (Smithereen's, FEP-400e) + // it tells of a post someone wrote there, or takes it off. Any other target (a community's moderators) is not ours to + // keep. public class FeaturedHandler : IActivityHandler { readonly IFeaturedPosts _featured; + readonly IWalls _walls; + readonly IRemoteActorService _remoteActors; readonly bool _add; - public FeaturedHandler(IFeaturedPosts featured, bool add) + public FeaturedHandler(IFeaturedPosts featured, bool add, IWalls walls = default, IRemoteActorService remoteActors = default) { _featured = featured; + _walls = walls; + _remoteActors = remoteActors; _add = add; } public string Type => _add ? "Add" : "Remove"; - public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token); + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var target = Objects.ActivityJson.Id(activity["target"]); + // a collection of its own we do not know of: its document read again, at most hourly, in case it was kept before + // we read that collection (a wall) + if (_remoteActors != default && target != default && target != actor.FeaturedURL && target != actor.WallURL + && Objects.Origin.Same(target, actor.ActorURI) && actor.UpdatedAt < DateTime.UtcNow.AddHours(-1)) + actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor; + if (_walls != default && !string.IsNullOrEmpty(actor.WallURL) && target == actor.WallURL) + await _walls.Receive(activity, actor, _add, token); + else + await _featured.Receive(activity, actor, _add, token); + } } } diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index 11a308c..e7214d3 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -24,6 +24,8 @@ namespace PrivaPub.Federation.Objects public string InReplyTo { get; init; } public string Context { get; init; } public string Audience { get; init; } + public string Wall { get; init; }//FEP-400e: the collection it says it was written in (`target`), a wall + public string WallOwner { get; init; }//that collection's attributedTo public string QuoteUri { get; init; } public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21) public string QuoteAuthorization { get; init; } @@ -89,6 +91,8 @@ namespace PrivaPub.Federation.Objects InReplyTo = Id(note["inReplyTo"]), Context = Id(note["context"]) ?? Value(note, "conversation"), Audience = Id(note["audience"]), + Wall = note["target"] is JsonObject target && Value(target, "type") is "Collection" or "OrderedCollection" ? Id(target) : default, + WallOwner = note["target"] is JsonObject wallTarget ? Id(wallTarget["attributedTo"]) : default, QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note), ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]), QuoteAuthorization = Id(note["quoteAuthorization"]), diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index 538aa26..a9ada19 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -72,7 +72,14 @@ namespace PrivaPub.Federation.Rendering ["value"] = "schema:value", ["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger", ["lemmy"] = "https://join-lemmy.org/ns#", - ["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods" + ["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods", + // Smithereen's, which it compacts what it reads by: an allowedTo stays a plain string, as it reads them + ["sm"] = "http://smithereen.software/ns#", + ["wall"] = new JsonObject { ["@id"] = "sm:wall", ["@type"] = "@id" }, + ["privacySettings"] = "sm:privacySettings", + ["wallPosting"] = "sm:wallPosting", + ["wallPostVisibility"] = "sm:wallPostVisibility", + ["allowedTo"] = "sm:allowedTo" }); public static string Html(string markdown) => @@ -128,6 +135,16 @@ namespace PrivaPub.Federation.Rendering ["value"] = FieldValue(field.Value) }).ToArray()) }; + // a persona's wall (FEP-400e, owner decision 2026-10-06): its followers write on it, everyone reads it + if (actor.HasWall) + { + document["wall"] = actor.Wall; + document["privacySettings"] = new JsonObject + { + ["wallPosting"] = new JsonObject { ["allowedTo"] = new JsonArray(actor.Followers) }, + ["wallPostVisibility"] = new JsonObject { ["allowedTo"] = new JsonArray(Public) } + }; + } if (actor is { Kind: LocalActorKind.Group, IsCircle: false }) { document["attributedTo"] = actor.Wardens; diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 9baf353..8b1e668 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -22,6 +22,7 @@ namespace PrivaPub.Infrastructure.Data await Plain(token, p => p.GroupUserId, p => p.ID); await Plain(token, p => p.GroupId, p => p.ID); await Plain(token, p => p.ActorURI); + await Plain(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e) await Plain(token, p => p.ConversationId, p => p.ID); await Plain(token, p => p.InReplyToURI); // who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index deb8881..bea07af 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -95,8 +95,11 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(services => new FeaturedHandler(services.GetRequiredService(), add: true)) - .AddSingleton(services => new FeaturedHandler(services.GetRequiredService(), add: false)) + .AddSingleton() + .AddSingleton(services => new FeaturedHandler(services.GetRequiredService(), add: true, + services.GetRequiredService(), services.GetRequiredService())) + .AddSingleton(services => new FeaturedHandler(services.GetRequiredService(), add: false, + services.GetRequiredService(), services.GetRequiredService())) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index d3a353b..b3a1675 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -14,6 +14,9 @@ namespace PrivaPub.Models.Post public string GroupId { get; set; } public string ConversationId { get; set; }//DmGroup.ID of a direct post public string AudienceURI { get; set; }//a remote community the post was made in + public string WallURI { get; set; }//the wall it was written on (FEP-400e): a persona's here, or another account's elsewhere + public string WallOwnerURI { get; set; }//that wall's owner, whose followers it reaches + public string WallOwnerAccountId { get; set; }//the owner's Avatar.ID or ForeignAvatar.ID public string ReblogOfPostId { get; set; } public PostVisibility Visibility { get; set; } public string Title { get; set; } diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index 6d9c35f..5566799 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -76,6 +76,7 @@ namespace PrivaPub.Models.User public string InboxURL { get; set; } public string OutboxURL { get; set; } public string FeaturedURL { get; set; }//featured: the posts it pins + public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls public string MovedToURL { get; set; } public List AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is public string PictureURL { get; set; }//icon diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 10f6c64..f1db5e1 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -850,10 +850,13 @@ without a port, before it gives out its OAuth client. ### Smithereen 1.0.3 - **Walls:** a post is a `Note`; one written on someone else's wall carries the wall (`sm:wall`, FEP-400e) as its - `target`, and the wall's owner tells its followers with `Add{Note}`. Smithereen sends both only to servers whose - actors have a wall (`Server.Feature.WALL_POSTS`, learnt from an actor's `sm:wall`), so PrivaPub never sees a post on - someone else's wall (G-0009: a persona's wall waits for the owner). Comments are replies addressed to the post's - author, never to followers. + `target` (an object with the owner as `attributedTo`), and the wall's owner tells its followers with `Add{Note}`. + Smithereen sends both only to servers whose actors have a wall (`Server.Feature.WALL_POSTS`, set for a whole domain + the first time it reads an actor there with `sm:wall`, never unset). It reads properties through JSON-LD compaction, so + `wall`, `privacySettings` and its keys need their `sm:` terms in the context, and an `allowedTo` must stay a plain + string. Its posting rule takes one base rule (followers, or following, or friends), so a persona says followers. An + owner deleting someone else's post on its wall sends nothing; a `Remove` from the owner (its target a plain id) + deletes the post there. Comments are replies addressed to the post's author, never to followers. - **Friends are mutual follows.** A request is an `Offer{Follow}` sent only to actors with `sm:supportsFriendRequests`; anyone else gets a plain `Follow`, so a persona is followed, and following back makes them friends there. @@ -865,12 +868,14 @@ without a port, before it gives out its OAuth client. `requireNonNullElse` was meant); its HTTP client refuses private addresses; its schema updater makes stored functions, which MySQL takes only with `log_bin_trust_function_creators`. A database whose first update failed is left without its triggers, and the server then answers 404 to activities about anything it knows. -- **Pasture evidence (2026-10-05, Smithereen 1.0.3, `tools/pasture/scenarios/smithereen.sh`):** 30 checks pass and one - gap is expected (G-0009): smuser follows alice and she follows back, which makes them friends there; wall posts both - ways; comments both ways; likes both ways; smuser's repost is a quote alice's policy lets in, and alice's boost is a +- **Pasture evidence (2026-10-06, Smithereen 1.0.3, `tools/pasture/scenarios/smithereen.sh`):** 40 checks pass: + smuser follows alice and she follows back, which makes them friends there; Smithereen learns that PrivaPub's actors + have walls; wall posts both ways; smfriend's post on smuser's wall reaches alice as on that wall (G-0009 closed); + smuser writes on alice's wall, which she sees and her wall lists, her followers' servers are told, and taking it off + deletes it on Smithereen; comments both ways; likes both ways; smuser's repost is a quote alice's policy lets in, and alice's boost is a repost there; smuser's poll and alice's vote; edits and deletions both ways; private messages both ways; the unfollow; statistics. PrivaPub changed for it: a server description that failed is asked again on the server's next - arrival instead of a week later. + arrival instead of a week later; walls (owner decision 2026-10-06). ### Loops (1.0.0-beta.14) and Pixelfed (0.14.4) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ef019c5..179cd60 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -282,7 +282,7 @@ and circles (see Owner decisions). | Question | Decision | |---|---| -| A persona's wall (G-0009) | **Yes, publish walls.** A persona's actor names its wall (`sm:wall`, FEP-400e); Smithereen's users may write on it, PrivaPub hosts their posts there and tells the persona's followers with `Add{Note}`. | +| A persona's wall (G-0009) | **Yes, publish walls.** A persona's actor names its wall (`sm:wall`, FEP-400e); Smithereen's users may write on it, PrivaPub hosts their posts there and tells the persona's followers with `Add{Note}`. **Done 2026-10-06:** its followers write on it, the persona takes posts off; posts on walls elsewhere reach their owners' followers here; checked live against Smithereen. | | Personas' public posts and relays | **Send public posts too.** A persona's public post (not unlisted, not followers-only, not local-only) also goes to the relays PrivaPub subscribes to, as Mastodon sends them. | | A post's `replies` and `context` | **Publish public threads.** Public and unlisted posts name their `replies` and `context` collections, which list only the public and unlisted replies PrivaPub holds; followers-only, circle, direct and local-only posts never do. | | FEP-8fcf followers synchronisation | **Send digests.** A delivery to a server that the persona's followers there would get carries a `Collection-Synchronization` header: a digest of the persona's followers on that server only, and where that server reads that partial list. | diff --git a/tools/pasture/scenarios/smithereen.sh b/tools/pasture/scenarios/smithereen.sh index fd170cb..87e2ce3 100644 --- a/tools/pasture/scenarios/smithereen.sh +++ b/tools/pasture/scenarios/smithereen.sh @@ -40,6 +40,10 @@ until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$sm_ && ok "alice follows smuser (Accept arrived)" || ko "smuser's Accept never arrived" until_true 30 '[ "$(sm friends.areFriends "user_ids=$alice_on_sm" | j "print(d[\"response\"][0][\"friend_status\"])")" = "friends" ]' \ && ok "the two follows make them friends on Smithereen" || ko "Smithereen does not count them as friends ($(sm friends.areFriends "user_ids=$alice_on_sm"))" +# (alice's profile, told again: Smithereen learns of her wall, and that PrivaPub's actors have walls, when it reads her) +curl -s -o /dev/null -X PATCH -H "$PH" "$P/api/v1/accounts/update_credentials" -d "note=alice of the pasture" +until_true 30 '[ -n "$(sm_sql "select 1 from servers where host='"'privapub.test'"' and features & 1")" ]' \ + && ok "Smithereen learns that PrivaPub's actors have walls" || ko "Smithereen never took PrivaPub for a server with walls" echo " walls" sm_post=$(sm wall.post "message=a Smithereen wall post $run" | j "print(d['response'])") @@ -104,13 +108,37 @@ echo " someone else's wall" # smfriend writes on smuser's wall: the post carries the wall as its target, and smuser tells its followers' servers # with Add{Note}, which is no pin read -r _ sm_on_friend <<< "$(SMTOKEN=$SFT sm_load "https://smithereen.test/users/$sm_self")" -smf wall.post "owner_id=$sm_self" "message=a word on smuser's wall $run" >/dev/null +smf wall.post "owner_id=$sm_self" "message=a word on the wall of smuser $run" >/dev/null # (Smithereen sends a post on someone else's wall, and its owner's Add, only to servers whose actors have a wall: G-0009) -until_true 20 '[ -n "$(p_home_has "a word on smuser'"'"'s wall $run")" ]' && ok "a post on smuser's wall reaches alice" \ - || xf "a post on smuser's wall never reaches alice: Smithereen sends it only to servers with walls (G-0009)" +until_true 45 '[ -n "$(p_home_has "a word on the wall of smuser $run")" ]' && ok "a post on smuser's wall reaches alice" \ + || ko "a post on smuser's wall never reaches alice" +on_wall=$(p_home_has "a word on the wall of smuser $run") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$on_wall" | j "print((d['privapub'].get('wall') or {}).get('acct'))")" = "smuser@smithereen.test" ] \ + && ok "as written on smuser's wall" || ko "PrivaPub does not say it is on smuser's wall" [ "$(curl -s -H "$PH" "$P/api/v1/accounts/$sm_on_p/statuses?pinned=true" | j "print(len(d))")" = "0" ] \ && ok "and is not taken for a pin" || ko "PrivaPub took the wall's Add for a pin" +echo " alice's wall" +# smuser follows alice, so it may write on her wall (her actor says her followers may): PrivaPub hosts the post, tells +# alice, lists it on her wall and tells her followers' servers with Add; alice takes it off, which deletes it on Smithereen +sm_wall_post=$(sm wall.post "owner_id=$alice_on_sm" "message=a word on alice's wall $run" | j "print(d.get('response', ''))") +[ -n "$sm_wall_post" ] && ok "Smithereen lets smuser write on alice's wall" || ko "Smithereen refused smuser's post on alice's wall ($(sm wall.post "owner_id=$alice_on_sm" "message=x" | head -c 200))" +p_mention() { curl -s -H "$PH" "$P/api/v1/notifications?types[]=mention&limit=40" | j "print(next((n['status']['id'] for n in d if 'a word on alice' in n['status']['content'] and '$run' in n['status']['content']), ''))"; } +until_true 45 '[ -n "$(p_mention)" ]' && ok "the post on alice's wall reaches her" || ko "the post on alice's wall never reached her" +walled=$(p_mention) +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$walled" | j "print((d['privapub'].get('wall') or {}).get('username'))")" = "alice_smithereen" ] \ + && ok "as written on her wall" || ko "PrivaPub does not say it is on alice's wall" +walled_uri=$(curl -s -H "$PH" "$P/api/v1/statuses/$walled" | j "print(d['uri'])") +pfetch -s -H 'Accept: application/activity+json' "$alice_uri/graffiti?page=true" | grep -q "$walled_uri" \ + && ok "her wall lists it" || ko "her wall does not list it" +# (the Add delivered to Smithereen, done: Smithereen holds the post already, its author's; the body's quotes are \u0022) +p_adds_done() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'print(db.Job.countDocuments({Host:"smithereen.test", State:2, Payload:/type\\u0022:\\u0022Add\\u0022.*graffiti/}))'; } +until_true 30 '[ "$(p_adds_done)" -ge 1 ]' && ok "alice tells her followers' servers with Add" || ko "no Add reached Smithereen" +curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$walled" +until_true 45 '[ "$(sm_sql "select count(*) from wall_posts where id=$sm_wall_post")" = "0" ]' \ + && ok "alice takes it off her wall, and Smithereen deletes it" || ko "the post is still on Smithereen after alice took it off" +[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$walled")" = "404" ] && ok "and it is gone here" || ko "it is still here" + echo " private messages" sm messages.send "to=$alice_on_sm" "body=a Smithereen message $run" >/dev/null until_true 45 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a Smithereen message $run"' && ok "smuser's message arrives as a DM" || ko "smuser's message never reached alice" diff --git a/tools/pasture/town/gaps.json b/tools/pasture/town/gaps.json index bd827a5..f7358fb 100644 --- a/tools/pasture/town/gaps.json +++ b/tools/pasture/town/gaps.json @@ -134,7 +134,8 @@ "phase": "P3", "code": "smithereen 1.0.3 ActivityPubWorker.sendAddPostToWallActivity and sendActivityForPost: requireFeature(Server.Feature.WALL_POSTS), which ObjectLinkResolver.maybeUpdateServerFeaturesFromActor sets only for a server whose actors have sm:wall", "opened": "2026-10-05", - "status": "open", - "note": "Waits for the owner: a persona would publish a wall (sm:wall, FEP-400e), which also offers Smithereen's users to write on it, and PrivaPub would host their posts and announce them with Add{Note}." + "status": "closed", + "note": "Closed by the owner's decision of 2026-10-06: a persona's actor names its wall (sm:wall, FEP-400e), so Smithereen sends PrivaPub what is written on its users' walls (Add{Note}, shown to their followers here as on that wall), and its users may write on a persona's wall when they follow it. Checked live: Smithereen 1.0.3 scenario, 40 checks.", + "closed": "2026-10-06" } ]