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
@@ -34,10 +34,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
readonly Domain.Media.IMediaService _media;
|
||||
|
||||
readonly IParticipations _participations;
|
||||
readonly Federation.Actors.IWalls _walls;
|
||||
|
||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
||||
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default)
|
||||
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
|
||||
Federation.Actors.IWalls walls = default)
|
||||
{
|
||||
_walls = walls;
|
||||
_participations = participations;
|
||||
_jobs = jobs;
|
||||
_media = media;
|
||||
@@ -161,7 +164,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (before == default)
|
||||
return NotFoundError();
|
||||
return await OffWall(id, token);
|
||||
var status = await _mapper.Status(before, MyId, token);
|
||||
var outcome = await _statuses.Remove(Me, id, token);
|
||||
if (!outcome.Ok)
|
||||
@@ -174,6 +177,20 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(status);
|
||||
}
|
||||
|
||||
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
|
||||
async Task<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]
|
||||
public async Task<IActionResult> Context(string id, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
public PrivaPubAudio Audio { get; set; }
|
||||
public PrivaPubEvent Event { get; set; }
|
||||
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
|
||||
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
|
||||
public PrivaPubVotes Votes { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var mentionAccounts = mentionUris.Count == 0
|
||||
? new Dictionary<string, ForeignAvatar>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
||||
var accounts = await Accounts(all.Select(AuthorOf), token);
|
||||
var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token);
|
||||
|
||||
var ids = all.Select(p => p.ID).ToList();
|
||||
var favourited = viewerId == default
|
||||
@@ -277,7 +277,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Card = Card(post),
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
Emojis = Emojis(post.Emojis),
|
||||
Privapub = Extension(post),
|
||||
Privapub = Extension(post, accounts),
|
||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
@@ -480,8 +480,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||||
|
||||
PrivaPubStatus Extension(PostEntity post) => new()
|
||||
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts) => new()
|
||||
{
|
||||
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
|
||||
ObjectType = post.ObjectType,
|
||||
Locked = post.LockedAt.HasValue,
|
||||
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
|
||||
|
||||
@@ -43,6 +43,14 @@ namespace PrivaPub.Domain.Timelines
|
||||
if (!string.IsNullOrEmpty(post.AudienceURI))
|
||||
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
recipients.Add(member.AvatarId);
|
||||
// a post on a wall (FEP-400e): its owner, when a persona, and those following the owner
|
||||
if (!string.IsNullOrEmpty(post.WallOwnerURI))
|
||||
{
|
||||
if (await _dbEntities.Avatars.MatchID(post.WallOwnerAccountId).ExecuteAnyAsync(token))
|
||||
recipients.Add(post.WallOwnerAccountId);
|
||||
foreach (var follower in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.WallOwnerURI && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
recipients.Add(follower.AvatarId);
|
||||
}
|
||||
// a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon
|
||||
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId)
|
||||
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Followers { get; init; }
|
||||
public string Following { get; init; }
|
||||
public string Featured { get; init; }
|
||||
public string Wall { get; init; }
|
||||
public string SharedInbox { get; init; }
|
||||
public string Icon { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
@@ -75,6 +76,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Followers = RemoteActorService.Text(root, "followers"),
|
||||
Following = RemoteActorService.Text(root, "following"),
|
||||
Featured = RemoteActorService.Text(root, "featured"),
|
||||
Wall = RemoteActorService.Text(root, "wall") ?? RemoteActorService.Text(root, "sm:wall"),
|
||||
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||
|
||||
@@ -46,6 +46,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Following => $"{Uri}/stalking";
|
||||
public string Featured => $"{Uri}/trophies";
|
||||
public string FeaturedTags => $"{Uri}/tattoos";
|
||||
public string Wall => $"{Uri}/graffiti";
|
||||
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
|
||||
public string Flock => $"{Uri}/flock";
|
||||
public string Wardens => $"{Uri}/wardens";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
|
||||
@@ -205,6 +205,7 @@ namespace PrivaPub.Federation.Actors
|
||||
.Modify(a => a.FollowersURL, actor.Followers)
|
||||
.Modify(a => a.FollowingURL, actor.Following)
|
||||
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
|
||||
.Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default)
|
||||
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
||||
.Modify(a => a.PictureURL, actor.Icon)
|
||||
.Modify(a => a.ThumbnailURL, actor.Header)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
// 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)
|
||||
[HttpGet, Route("{actor}/groupies/roll-call")]
|
||||
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
|
||||
|
||||
@@ -42,11 +42,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly IQuoteService _quotes;
|
||||
readonly IInteractionApprovals _approvals;
|
||||
readonly Relays.IRelays _relays;
|
||||
readonly IWalls _walls;
|
||||
|
||||
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes,
|
||||
IInteractionApprovals approvals = default, Relays.IRelays relays = default)
|
||||
IInteractionApprovals approvals = default, Relays.IRelays relays = default, IWalls walls = default)
|
||||
{
|
||||
_walls = walls;
|
||||
_relays = relays;
|
||||
_approvals = approvals;
|
||||
_quotes = quotes;
|
||||
@@ -189,8 +191,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
// forwarded post)
|
||||
var relayed = !followed && visibility == PostVisibility.Public && _relays != default
|
||||
&& await _relays.Passes(Arrival.Current?.ForwardedBy, token);
|
||||
// written on a persona's wall (FEP-400e): public, not a reply, by an account following the persona, as its actor
|
||||
// tells Smithereen
|
||||
var wall = _walls == default || note.Wall == default ? default : await _walls.OwnerOf(note.Wall, token);
|
||||
if (wall != default && (visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || !string.IsNullOrEmpty(note.InReplyTo)
|
||||
|| note.WallOwner != default && note.WallOwner != wall.Uri || !await _walls.MayWrite(wall, author, token)))
|
||||
{
|
||||
Arrival.Drop("wall-refused");
|
||||
return;
|
||||
}
|
||||
if (visibility == PostVisibility.Direct ? persons.Count == 0
|
||||
: group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
|
||||
: group == default && wall == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed && !inFollowedGroup && !relayed)
|
||||
{
|
||||
Arrival.Drop("not-addressed");
|
||||
return;
|
||||
@@ -212,6 +223,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
post.ActivityURI = Id(activity);
|
||||
post.GroupId = group?.Id;
|
||||
post.ConversationId = conversation?.ID;
|
||||
if (wall != default)
|
||||
{
|
||||
post.WallURI = wall.Wall;
|
||||
post.WallOwnerURI = wall.Uri;
|
||||
post.WallOwnerAccountId = wall.Id;
|
||||
}
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
@@ -235,6 +252,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
|
||||
if (group is { IsCircle: false })
|
||||
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
|
||||
if (wall != default)
|
||||
await _walls.Hosted(post, wall, token);
|
||||
}
|
||||
|
||||
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
|
||||
|
||||
@@ -6,21 +6,38 @@ using System.Text.Json.Nodes;
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
// An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma
|
||||
// send it; inside a community's announce, the community features a post made in it. Any other target (Smithereen's
|
||||
// wall, a community's moderators) is not ours to keep.
|
||||
// send it; inside a community's announce, the community features a post made in it. On its wall (Smithereen's, FEP-400e)
|
||||
// it tells of a post someone wrote there, or takes it off. Any other target (a community's moderators) is not ours to
|
||||
// keep.
|
||||
public class FeaturedHandler : IActivityHandler
|
||||
{
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly IWalls _walls;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly bool _add;
|
||||
|
||||
public FeaturedHandler(IFeaturedPosts featured, bool add)
|
||||
public FeaturedHandler(IFeaturedPosts featured, bool add, IWalls walls = default, IRemoteActorService remoteActors = default)
|
||||
{
|
||||
_featured = featured;
|
||||
_walls = walls;
|
||||
_remoteActors = remoteActors;
|
||||
_add = add;
|
||||
}
|
||||
|
||||
public string Type => _add ? "Add" : "Remove";
|
||||
|
||||
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token);
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var target = Objects.ActivityJson.Id(activity["target"]);
|
||||
// a collection of its own we do not know of: its document read again, at most hourly, in case it was kept before
|
||||
// we read that collection (a wall)
|
||||
if (_remoteActors != default && target != default && target != actor.FeaturedURL && target != actor.WallURL
|
||||
&& Objects.Origin.Same(target, actor.ActorURI) && actor.UpdatedAt < DateTime.UtcNow.AddHours(-1))
|
||||
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
|
||||
if (_walls != default && !string.IsNullOrEmpty(actor.WallURL) && target == actor.WallURL)
|
||||
await _walls.Receive(activity, actor, _add, token);
|
||||
else
|
||||
await _featured.Receive(activity, actor, _add, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ namespace PrivaPub.Federation.Objects
|
||||
public string InReplyTo { get; init; }
|
||||
public string Context { get; init; }
|
||||
public string Audience { get; init; }
|
||||
public string Wall { get; init; }//FEP-400e: the collection it says it was written in (`target`), a wall
|
||||
public string WallOwner { get; init; }//that collection's attributedTo
|
||||
public string QuoteUri { get; init; }
|
||||
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
|
||||
public string QuoteAuthorization { get; init; }
|
||||
@@ -89,6 +91,8 @@ namespace PrivaPub.Federation.Objects
|
||||
InReplyTo = Id(note["inReplyTo"]),
|
||||
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
||||
Audience = Id(note["audience"]),
|
||||
Wall = note["target"] is JsonObject target && Value(target, "type") is "Collection" or "OrderedCollection" ? Id(target) : default,
|
||||
WallOwner = note["target"] is JsonObject wallTarget ? Id(wallTarget["attributedTo"]) : default,
|
||||
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
|
||||
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
|
||||
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
||||
|
||||
@@ -72,7 +72,14 @@ namespace PrivaPub.Federation.Rendering
|
||||
["value"] = "schema:value",
|
||||
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
||||
["lemmy"] = "https://join-lemmy.org/ns#",
|
||||
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
|
||||
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods",
|
||||
// Smithereen's, which it compacts what it reads by: an allowedTo stays a plain string, as it reads them
|
||||
["sm"] = "http://smithereen.software/ns#",
|
||||
["wall"] = new JsonObject { ["@id"] = "sm:wall", ["@type"] = "@id" },
|
||||
["privacySettings"] = "sm:privacySettings",
|
||||
["wallPosting"] = "sm:wallPosting",
|
||||
["wallPostVisibility"] = "sm:wallPostVisibility",
|
||||
["allowedTo"] = "sm:allowedTo"
|
||||
});
|
||||
|
||||
public static string Html(string markdown) =>
|
||||
@@ -128,6 +135,16 @@ namespace PrivaPub.Federation.Rendering
|
||||
["value"] = FieldValue(field.Value)
|
||||
}).ToArray())
|
||||
};
|
||||
// a persona's wall (FEP-400e, owner decision 2026-10-06): its followers write on it, everyone reads it
|
||||
if (actor.HasWall)
|
||||
{
|
||||
document["wall"] = actor.Wall;
|
||||
document["privacySettings"] = new JsonObject
|
||||
{
|
||||
["wallPosting"] = new JsonObject { ["allowedTo"] = new JsonArray(actor.Followers) },
|
||||
["wallPostVisibility"] = new JsonObject { ["allowedTo"] = new JsonArray(Public) }
|
||||
};
|
||||
}
|
||||
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
|
||||
{
|
||||
document["attributedTo"] = actor.Wardens;
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace PrivaPub.Infrastructure.Data
|
||||
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.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.InReplyToURI);
|
||||
// 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, LockHandler>()
|
||||
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
|
||||
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
|
||||
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
|
||||
.AddSingleton<Federation.Actors.IWalls, Federation.Actors.Walls>()
|
||||
.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, CreateHandler>()
|
||||
.AddSingleton<IActivityHandler, DeleteHandler>()
|
||||
|
||||
@@ -14,6 +14,9 @@ namespace PrivaPub.Models.Post
|
||||
public string GroupId { get; set; }
|
||||
public string ConversationId { get; set; }//DmGroup.ID of a direct post
|
||||
public string AudienceURI { get; set; }//a remote community the post was made in
|
||||
public string WallURI { get; set; }//the wall it was written on (FEP-400e): a persona's here, or another account's elsewhere
|
||||
public string WallOwnerURI { get; set; }//that wall's owner, whose followers it reaches
|
||||
public string WallOwnerAccountId { get; set; }//the owner's Avatar.ID or ForeignAvatar.ID
|
||||
public string ReblogOfPostId { get; set; }
|
||||
public PostVisibility Visibility { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
@@ -76,6 +76,7 @@ namespace PrivaPub.Models.User
|
||||
public string InboxURL { get; set; }
|
||||
public string OutboxURL { get; set; }
|
||||
public string FeaturedURL { get; set; }//featured: the posts it pins
|
||||
public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
|
||||
public string MovedToURL { get; set; }
|
||||
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
|
||||
public string PictureURL { get; set; }//icon
|
||||
|
||||
Reference in new issue
Block a user