Personas have walls their followers write on
Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. 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, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.
Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.
Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).
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
e0682fa868
commit
c47b6e5533
26 files changed
+582
-36
No files matched your search
@@ -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
|
`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
|
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/<name>?v=1.0`), with a password grant for a local
|
are renamed in its database; its API is VKontakte's (`/api/method/<name>?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
|
application the peer script inserts. `scenarios/smithereen.sh`, 40 checks, walls both ways (alice's profile is
|
||||||
someone else's wall).
|
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
|
- **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,
|
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
|
the queue's wait and processing times, its drain, and a persona's home timeline meanwhile, and keeps each run in
|
||||||
|
|||||||
+13
-1
@@ -94,6 +94,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
|
|||||||
| Activities | `/peasants/{name}/grunts/{id}` |
|
| Activities | `/peasants/{name}/grunts/{id}` |
|
||||||
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||||
| Direct-message context | `/peasants/{name}/whispers/{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}` |
|
| 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.
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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}`,
|
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
|
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
|
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.
|
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
|
- **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
|
||||||
|
|||||||
@@ -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"] = $"<p>{text}</p>",
|
||||||
|
["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<string, JsonObject> _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<PostEntity> Post(string objectUri) => DB.Default.Find<PostEntity>().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<string>());
|
||||||
|
Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue<string>());
|
||||||
|
Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue<string>());
|
||||||
|
Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue<string>() == "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<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
|
||||||
|
Assert.True(await DB.Default.Find<Notification>().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<string>() == "Add");
|
||||||
|
Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue<string>(), add["object"]!.GetValue<string>(), add["target"]!["id"]!.GetValue<string>()));
|
||||||
|
Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "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<string>() == "Remove");
|
||||||
|
Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue<string>(), remove["target"]!.GetValue<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<string>() == "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<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
|
||||||
|
Assert.False(await DB.Default.Find<RemoteFeatured>().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<string>(), collection.Json["totalItems"]!.GetValue<int>()));
|
||||||
|
var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToList();
|
||||||
|
Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items);
|
||||||
|
Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||||
Relationships = new RelationshipService(Db, Follows, Delivery);
|
Relationships = new RelationshipService(Db, Follows, Delivery);
|
||||||
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
|
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
|
||||||
|
Walls = new Walls(Db, Local, RemotePosts, Delivery, Fanout);
|
||||||
Handlers = new IActivityHandler[]
|
Handlers = new IActivityHandler[]
|
||||||
{
|
{
|
||||||
new FollowHandler(Db, Local, Remote, Delivery),
|
new FollowHandler(Db, Local, Remote, Delivery),
|
||||||
@@ -71,15 +72,15 @@ namespace PrivaPub.Tests.Support
|
|||||||
new DislikeHandler(Db),
|
new DislikeHandler(Db),
|
||||||
new JoinHandler(Db, Local, Delivery),
|
new JoinHandler(Db, Local, Delivery),
|
||||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
|
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 DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
|
||||||
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
|
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
|
||||||
new FlagHandler(Db, Local),
|
new FlagHandler(Db, Local),
|
||||||
new BlockHandler(Db, Local),
|
new BlockHandler(Db, Local),
|
||||||
new LockHandler(Db),
|
new LockHandler(Db),
|
||||||
new MoveHandler(Remote, Db, Local, Follows, Relationships),
|
new MoveHandler(Remote, Db, Local, Follows, Relationships),
|
||||||
new FeaturedHandler(Featured, add: true),
|
new FeaturedHandler(Featured, add: true, Walls, Remote),
|
||||||
new FeaturedHandler(Featured, add: false)
|
new FeaturedHandler(Featured, add: false, Walls, Remote)
|
||||||
};
|
};
|
||||||
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
|
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
|
||||||
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
|
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
|
||||||
@@ -120,6 +121,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public TimelineService Timelines { get; }
|
public TimelineService Timelines { get; }
|
||||||
public RelationshipService Relationships { get; }
|
public RelationshipService Relationships { get; }
|
||||||
public FeaturedPosts Featured { get; }
|
public FeaturedPosts Featured { get; }
|
||||||
|
public Walls Walls { get; }
|
||||||
public Relays Relays { get; }
|
public Relays Relays { get; }
|
||||||
// the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile
|
// the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile
|
||||||
public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new();
|
public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new();
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ namespace PrivaPub.Tests.Support
|
|||||||
{
|
{
|
||||||
readonly RSA _key = RSA.Create(2048);
|
readonly RSA _key = RSA.Create(2048);
|
||||||
readonly bool _namesSharedInbox;
|
readonly bool _namesSharedInbox;
|
||||||
|
readonly bool _hasWall;
|
||||||
|
|
||||||
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do
|
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall:
|
||||||
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false)
|
// 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];
|
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
||||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||||
Type = type;
|
Type = type;
|
||||||
_namesSharedInbox = sharedInbox;
|
_namesSharedInbox = sharedInbox;
|
||||||
|
_hasWall = wall;
|
||||||
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
public string KeyId => Id + "#main-key";
|
public string KeyId => Id + "#main-key";
|
||||||
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
|
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
|
||||||
|
public string Wall => Id + "/wall";
|
||||||
|
|
||||||
public JsonObject Document()
|
public JsonObject Document()
|
||||||
{
|
{
|
||||||
@@ -52,6 +56,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
};
|
};
|
||||||
if (_namesSharedInbox)
|
if (_namesSharedInbox)
|
||||||
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
|
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
|
||||||
|
if (_hasWall)
|
||||||
|
document["wall"] = Wall;
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
readonly Domain.Media.IMediaService _media;
|
readonly Domain.Media.IMediaService _media;
|
||||||
|
|
||||||
readonly IParticipations _participations;
|
readonly IParticipations _participations;
|
||||||
|
readonly Federation.Actors.IWalls _walls;
|
||||||
|
|
||||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
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;
|
_participations = participations;
|
||||||
_jobs = jobs;
|
_jobs = jobs;
|
||||||
_media = media;
|
_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);
|
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||||
if (before == default)
|
if (before == default)
|
||||||
return NotFoundError();
|
return await OffWall(id, token);
|
||||||
var status = await _mapper.Status(before, MyId, token);
|
var status = await _mapper.Status(before, MyId, token);
|
||||||
var outcome = await _statuses.Remove(Me, id, token);
|
var outcome = await _statuses.Remove(Me, id, token);
|
||||||
if (!outcome.Ok)
|
if (!outcome.Ok)
|
||||||
@@ -174,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
return Json(status);
|
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<IActionResult> 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]
|
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
public async Task<IActionResult> Context(string id, CancellationToken token)
|
public async Task<IActionResult> Context(string id, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
public PrivaPubAudio Audio { get; set; }
|
public PrivaPubAudio Audio { get; set; }
|
||||||
public PrivaPubEvent Event { get; set; }
|
public PrivaPubEvent Event { get; set; }
|
||||||
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
|
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();
|
public PrivaPubVotes Votes { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
var mentionAccounts = mentionUris.Count == 0
|
var mentionAccounts = mentionUris.Count == 0
|
||||||
? new Dictionary<string, ForeignAvatar>()
|
? new Dictionary<string, ForeignAvatar>()
|
||||||
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
: (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 ids = all.Select(p => p.ID).ToList();
|
||||||
var favourited = viewerId == default
|
var favourited = viewerId == default
|
||||||
@@ -277,7 +277,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
Card = Card(post),
|
Card = Card(post),
|
||||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||||
Emojis = Emojis(post.Emojis),
|
Emojis = Emojis(post.Emojis),
|
||||||
Privapub = Extension(post),
|
Privapub = Extension(post, accounts),
|
||||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||||
Pleroma = new PleromaStatus { 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(),
|
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<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
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<string, Account> accounts) => new()
|
||||||
{
|
{
|
||||||
|
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
|
||||||
ObjectType = post.ObjectType,
|
ObjectType = post.ObjectType,
|
||||||
Locked = post.LockedAt.HasValue,
|
Locked = post.LockedAt.HasValue,
|
||||||
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
|
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ namespace PrivaPub.Domain.Timelines
|
|||||||
if (!string.IsNullOrEmpty(post.AudienceURI))
|
if (!string.IsNullOrEmpty(post.AudienceURI))
|
||||||
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
|
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||||
recipients.Add(member.AvatarId);
|
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
|
// 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)
|
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId)
|
||||||
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
|
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public string Followers { get; init; }
|
public string Followers { get; init; }
|
||||||
public string Following { get; init; }
|
public string Following { get; init; }
|
||||||
public string Featured { get; init; }
|
public string Featured { get; init; }
|
||||||
|
public string Wall { get; init; }
|
||||||
public string SharedInbox { get; init; }
|
public string SharedInbox { get; init; }
|
||||||
public string Icon { get; init; }
|
public string Icon { get; init; }
|
||||||
public bool Discoverable { get; init; } = true;
|
public bool Discoverable { get; init; } = true;
|
||||||
@@ -75,6 +76,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
Followers = RemoteActorService.Text(root, "followers"),
|
Followers = RemoteActorService.Text(root, "followers"),
|
||||||
Following = RemoteActorService.Text(root, "following"),
|
Following = RemoteActorService.Text(root, "following"),
|
||||||
Featured = RemoteActorService.Text(root, "featured"),
|
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,
|
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public string Following => $"{Uri}/stalking";
|
public string Following => $"{Uri}/stalking";
|
||||||
public string Featured => $"{Uri}/trophies";
|
public string Featured => $"{Uri}/trophies";
|
||||||
public string FeaturedTags => $"{Uri}/tattoos";
|
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 Flock => $"{Uri}/flock";
|
||||||
public string Wardens => $"{Uri}/wardens";
|
public string Wardens => $"{Uri}/wardens";
|
||||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
.Modify(a => a.FollowersURL, actor.Followers)
|
.Modify(a => a.FollowersURL, actor.Followers)
|
||||||
.Modify(a => a.FollowingURL, actor.Following)
|
.Modify(a => a.FollowingURL, actor.Following)
|
||||||
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
|
.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.SharedInboxURL, actor.SharedInbox)
|
||||||
.Modify(a => a.PictureURL, actor.Icon)
|
.Modify(a => a.PictureURL, actor.Icon)
|
||||||
.Modify(a => a.ThumbnailURL, actor.Header)
|
.Modify(a => a.ThumbnailURL, actor.Header)
|
||||||
|
|||||||
@@ -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<LocalActor> OwnerOf(string wallUri, CancellationToken token);
|
||||||
|
Task<bool> 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<bool> 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<LocalActor> 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<bool> 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<PostEntity>().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<bool> 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<IEnumerable<string>> 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<string>() : new[] { inbox };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,30 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
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<IActionResult> 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<Func<PostEntity, bool>> 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)
|
// FEP-8fcf: the persona's followers on the server that signs the request, and nobody else's (FollowersSynchronization)
|
||||||
[HttpGet, Route("{actor}/groupies/roll-call")]
|
[HttpGet, Route("{actor}/groupies/roll-call")]
|
||||||
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
|
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly IQuoteService _quotes;
|
readonly IQuoteService _quotes;
|
||||||
readonly IInteractionApprovals _approvals;
|
readonly IInteractionApprovals _approvals;
|
||||||
readonly Relays.IRelays _relays;
|
readonly Relays.IRelays _relays;
|
||||||
|
readonly IWalls _walls;
|
||||||
|
|
||||||
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
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,
|
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;
|
_relays = relays;
|
||||||
_approvals = approvals;
|
_approvals = approvals;
|
||||||
_quotes = quotes;
|
_quotes = quotes;
|
||||||
@@ -189,8 +191,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
// forwarded post)
|
// forwarded post)
|
||||||
var relayed = !followed && visibility == PostVisibility.Public && _relays != default
|
var relayed = !followed && visibility == PostVisibility.Public && _relays != default
|
||||||
&& await _relays.Passes(Arrival.Current?.ForwardedBy, token);
|
&& 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
|
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");
|
Arrival.Drop("not-addressed");
|
||||||
return;
|
return;
|
||||||
@@ -212,6 +223,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
post.ActivityURI = Id(activity);
|
post.ActivityURI = Id(activity);
|
||||||
post.GroupId = group?.Id;
|
post.GroupId = group?.Id;
|
||||||
post.ConversationId = conversation?.ID;
|
post.ConversationId = conversation?.ID;
|
||||||
|
if (wall != default)
|
||||||
|
{
|
||||||
|
post.WallURI = wall.Wall;
|
||||||
|
post.WallOwnerURI = wall.Uri;
|
||||||
|
post.WallOwnerAccountId = wall.Id;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DB.Default.SaveAsync(post, token);
|
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);
|
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
|
||||||
if (group is { IsCircle: false })
|
if (group is { IsCircle: false })
|
||||||
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
|
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
|
||||||
|
if (wall != default)
|
||||||
|
await _walls.Hosted(post, wall, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
|
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
|
||||||
|
|||||||
@@ -6,21 +6,38 @@ using System.Text.Json.Nodes;
|
|||||||
namespace PrivaPub.Federation.Inbox.Handlers
|
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
|
// 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
|
// send it; inside a community's announce, the community features a post made in it. On its wall (Smithereen's, FEP-400e)
|
||||||
// wall, a community's moderators) is not ours to keep.
|
// 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
|
public class FeaturedHandler : IActivityHandler
|
||||||
{
|
{
|
||||||
readonly IFeaturedPosts _featured;
|
readonly IFeaturedPosts _featured;
|
||||||
|
readonly IWalls _walls;
|
||||||
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly bool _add;
|
readonly bool _add;
|
||||||
|
|
||||||
public FeaturedHandler(IFeaturedPosts featured, bool add)
|
public FeaturedHandler(IFeaturedPosts featured, bool add, IWalls walls = default, IRemoteActorService remoteActors = default)
|
||||||
{
|
{
|
||||||
_featured = featured;
|
_featured = featured;
|
||||||
|
_walls = walls;
|
||||||
|
_remoteActors = remoteActors;
|
||||||
_add = add;
|
_add = add;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Type => _add ? "Add" : "Remove";
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ namespace PrivaPub.Federation.Objects
|
|||||||
public string InReplyTo { get; init; }
|
public string InReplyTo { get; init; }
|
||||||
public string Context { get; init; }
|
public string Context { get; init; }
|
||||||
public string Audience { 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 QuoteUri { get; init; }
|
||||||
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
|
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
|
||||||
public string QuoteAuthorization { get; init; }
|
public string QuoteAuthorization { get; init; }
|
||||||
@@ -89,6 +91,8 @@ namespace PrivaPub.Federation.Objects
|
|||||||
InReplyTo = Id(note["inReplyTo"]),
|
InReplyTo = Id(note["inReplyTo"]),
|
||||||
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
||||||
Audience = Id(note["audience"]),
|
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),
|
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
|
||||||
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
|
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
|
||||||
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
||||||
|
|||||||
@@ -72,7 +72,14 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
["value"] = "schema:value",
|
["value"] = "schema:value",
|
||||||
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
||||||
["lemmy"] = "https://join-lemmy.org/ns#",
|
["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) =>
|
public static string Html(string markdown) =>
|
||||||
@@ -128,6 +135,16 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
["value"] = FieldValue(field.Value)
|
["value"] = FieldValue(field.Value)
|
||||||
}).ToArray())
|
}).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 })
|
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
|
||||||
{
|
{
|
||||||
document["attributedTo"] = actor.Wardens;
|
document["attributedTo"] = actor.Wardens;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
|
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
|
||||||
await Plain<Post>(token, p => p.GroupId, p => p.ID);
|
await Plain<Post>(token, p => p.GroupId, p => p.ID);
|
||||||
await Plain<Post>(token, p => p.ActorURI);
|
await Plain<Post>(token, p => p.ActorURI);
|
||||||
|
await Plain<Post>(token, p => p.WallURI, p => p.ID);//a persona's wall (FEP-400e)
|
||||||
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
||||||
await Plain<Post>(token, p => p.InReplyToURI);
|
await Plain<Post>(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
|
// who boosted what: a persona's boosts of the posts a page shows were a scan of every post under load
|
||||||
|
|||||||
@@ -95,8 +95,11 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IActivityHandler, BlockHandler>()
|
.AddSingleton<IActivityHandler, BlockHandler>()
|
||||||
.AddSingleton<IActivityHandler, LockHandler>()
|
.AddSingleton<IActivityHandler, LockHandler>()
|
||||||
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
|
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
|
||||||
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
|
.AddSingleton<Federation.Actors.IWalls, Federation.Actors.Walls>()
|
||||||
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
|
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true,
|
||||||
|
services.GetRequiredService<Federation.Actors.IWalls>(), services.GetRequiredService<Federation.Actors.IRemoteActorService>()))
|
||||||
|
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false,
|
||||||
|
services.GetRequiredService<Federation.Actors.IWalls>(), services.GetRequiredService<Federation.Actors.IRemoteActorService>()))
|
||||||
.AddSingleton<IActivityHandler, MoveHandler>()
|
.AddSingleton<IActivityHandler, MoveHandler>()
|
||||||
.AddSingleton<IActivityHandler, CreateHandler>()
|
.AddSingleton<IActivityHandler, CreateHandler>()
|
||||||
.AddSingleton<IActivityHandler, DeleteHandler>()
|
.AddSingleton<IActivityHandler, DeleteHandler>()
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ namespace PrivaPub.Models.Post
|
|||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
public string ConversationId { get; set; }//DmGroup.ID of a direct post
|
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 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 string ReblogOfPostId { get; set; }
|
||||||
public PostVisibility Visibility { get; set; }
|
public PostVisibility Visibility { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ namespace PrivaPub.Models.User
|
|||||||
public string InboxURL { get; set; }
|
public string InboxURL { get; set; }
|
||||||
public string OutboxURL { get; set; }
|
public string OutboxURL { get; set; }
|
||||||
public string FeaturedURL { get; set; }//featured: the posts it pins
|
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 string MovedToURL { get; set; }
|
||||||
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
|
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
|
||||||
public string PictureURL { get; set; }//icon
|
public string PictureURL { get; set; }//icon
|
||||||
|
|||||||
+13
-8
@@ -850,10 +850,13 @@ without a port, before it gives out its OAuth client.
|
|||||||
### Smithereen 1.0.3
|
### 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
|
- **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
|
`target` (an object with the owner as `attributedTo`), and the wall's owner tells its followers with `Add{Note}`.
|
||||||
actors have a wall (`Server.Feature.WALL_POSTS`, learnt from an actor's `sm:wall`), so PrivaPub never sees a post on
|
Smithereen sends both only to servers whose actors have a wall (`Server.Feature.WALL_POSTS`, set for a whole domain
|
||||||
someone else's wall (G-0009: a persona's wall waits for the owner). Comments are replies addressed to the post's
|
the first time it reads an actor there with `sm:wall`, never unset). It reads properties through JSON-LD compaction, so
|
||||||
author, never to followers.
|
`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
|
- **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
|
`sm:supportsFriendRequests`; anyone else gets a plain `Follow`, so a persona is followed, and following back makes
|
||||||
them friends there.
|
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
|
`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
|
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.
|
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
|
- **Pasture evidence (2026-10-06, Smithereen 1.0.3, `tools/pasture/scenarios/smithereen.sh`):** 40 checks pass:
|
||||||
gap is expected (G-0009): smuser follows alice and she follows back, which makes them friends there; wall posts both
|
smuser follows alice and she follows back, which makes them friends there; Smithereen learns that PrivaPub's actors
|
||||||
ways; comments both ways; likes both ways; smuser's repost is a quote alice's policy lets in, and alice's boost is a
|
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
|
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
|
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)
|
### Loops (1.0.0-beta.14) and Pixelfed (0.14.4)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -282,7 +282,7 @@ and circles (see Owner decisions).
|
|||||||
|
|
||||||
| Question | Decision |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -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"
|
&& 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" ]' \
|
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"))"
|
&& 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"
|
echo " walls"
|
||||||
sm_post=$(sm wall.post "message=a Smithereen wall post $run" | j "print(d['response'])")
|
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
|
# 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
|
# with Add{Note}, which is no pin
|
||||||
read -r _ sm_on_friend <<< "$(SMTOKEN=$SFT sm_load "https://smithereen.test/users/$sm_self")"
|
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)
|
# (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" \
|
until_true 45 '[ -n "$(p_home_has "a word on the wall of smuser $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)"
|
|| 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" ] \
|
[ "$(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"
|
&& 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"
|
echo " private messages"
|
||||||
sm messages.send "to=$alice_on_sm" "body=a Smithereen message $run" >/dev/null
|
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"
|
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"
|
||||||
|
|||||||
@@ -134,7 +134,8 @@
|
|||||||
"phase": "P3",
|
"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",
|
"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",
|
"opened": "2026-10-05",
|
||||||
"status": "open",
|
"status": "closed",
|
||||||
"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}."
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user