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
158 lines
6.3 KiB
C#
158 lines
6.3 KiB
C#
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 };
|
|
}
|
|
}
|
|
}
|