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 }; } } }