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:
thepraandClaude Opus 5.5 committed 2026-10-06 08:17:10 +02:00
1 parent e0682fa868
commit c47b6e5533
26 files changed
+582 -36

No files matched your search

+157
View File
@@ -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 };
}
}
}