Posts reach their audience, edits follow them, deletes leave a tombstone

OutboxPublisher works out who a post goes to, Mastodon's way: followers'
shared inboxes for public, unlisted and followers-only; every mentioned
remote account's own inbox; the recipients only for direct; the parent's
author for a reply; a community's followers for a post in it; nobody for
a circle. Creates, updates and deletes all use it.

- Local posts take a visibility (public, unlisted, followers-only) and a
  spoiler text next to the content-warning flag.
- /clientapi/post/update keeps the previous version as a revision,
  re-renders, and sends Update{Note} with `updated` to the same to/cc.
- Deleting is now soft: the content, title, spoiler, media and revisions
  are cleared, timeline entries removed, the parent's reply count goes
  down, Delete goes to the stored audience, and the object answers 410
  with a Tombstone instead of 404.
- Editing a persona's profile sends Update{Person} to its followers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:34:35 +02:00
1 parent 61f6ca0676
commit a76cc34557
10 files changed
+368 -33

No files matched your search

+102 -32
View File
@@ -7,6 +7,7 @@ using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
@@ -24,6 +25,7 @@ namespace PrivaPub.Services
{
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
@@ -38,6 +40,7 @@ namespace PrivaPub.Services
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IOutboxPublisher _outbox;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger;
@@ -46,6 +49,7 @@ namespace PrivaPub.Services
IRemoteActorService remoteActors,
IDeliveryService delivery,
IContentRenderer content,
IOutboxPublisher outbox,
IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger)
{
@@ -54,6 +58,7 @@ namespace PrivaPub.Services
_remoteActors = remoteActors;
_delivery = delivery;
_content = content;
_outbox = outbox;
_localizer = localizer;
_logger = logger;
}
@@ -86,14 +91,15 @@ namespace PrivaPub.Services
GroupUserId = author.Id,
AuthorAccountId = author.Id,
GroupId = group?.Id,
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
Visibility = isLocalOnly ? PostVisibility.Circle : LocalVisibility(form.Visibility),
Title = form.Title,
SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim(),
Text = form.Text,
ContentHtml = rendered.Html,
ContentFormat = ContentFormat.Markdown,
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
HasContentWarning = form.HasContentWarning,
HasContentWarning = form.HasContentWarning || !string.IsNullOrWhiteSpace(form.SpoilerText),
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
@@ -120,11 +126,8 @@ namespace PrivaPub.Services
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
var addressedInboxes = rendered.Mentions.Where(m => !m.IsLocal).Select(m => m.Inbox).ToList();
if (parent is { IsFederatedCopy: true } && !string.IsNullOrEmpty(parent.AuthorAccountId))
addressedInboxes.Add((await _dbEntities.ForeignAvatars.MatchID(parent.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL);
await _delivery.EnqueueToFollowers(author, create, token, addressedInboxes);
if (group != default)
await _outbox.Publish(author, post, create, token);
if (group is { IsFederated: true } && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
result.Data = ToView(post);
@@ -152,35 +155,28 @@ namespace PrivaPub.Services
if (post == default)
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
await DB.Default.DeleteAsync<PostEntity>(post.ID);
if (post.IsLocalOnly)
if (post.DeletedAt.HasValue)
return result;
var audience = await _outbox.Audience(author, post, token);
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.DeletedAt, DateTime.UtcNow)
.Modify(p => p.Text, null)
.Modify(p => p.ContentHtml, null)
.Modify(p => p.Title, null)
.Modify(p => p.SpoilerText, null)
.Modify(p => p.Media, new List<PostMedia>())
.Modify(p => p.Revisions, new List<PostRevision>())
.ExecuteAsync(token);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
if (post.Visibility == PostVisibility.Direct)
if (audience.Count > 0)
{
var recipients = new List<string>();
foreach (var uri in post.To.Concat(post.Cc).Distinct())
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (foreign != default)
recipients.Add(foreign.InboxURL);
}
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
await _delivery.Enqueue(author, recipients, directDelete, token);
return result;
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
await _delivery.Enqueue(author, audience, delete, token);
}
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
var extraInboxes = Enumerable.Empty<string>();
if (!string.IsNullOrEmpty(post.GroupId))
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group != default)
extraInboxes = await _delivery.FollowerInboxes(group, token);
}
await _delivery.EnqueueToFollowers(author, delete, token, extraInboxes);
return result;
}
catch (Exception ex)
@@ -190,6 +186,73 @@ namespace PrivaPub.Services
}
}
public async Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token)
{
var result = new WebResult();
try
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var post = await _dbEntities.Posts
.Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token);
if (post == default)
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
post.Revisions.Add(new PostRevision
{
Title = post.Title,
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
var rendered = await _content.Markdown(form.Text, token);
post.Title = form.Title;
post.SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim();
post.HasContentWarning = form.HasContentWarning || post.SpoilerText != default;
post.Text = form.Text;
post.ContentHtml = rendered.Html;
post.Mentions = post.Visibility == PostVisibility.Direct
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
: rendered.Mentions.Select(ToMention).ToList();
post.Tags = rendered.Tags.ToList();
post.EditedAt = DateTime.UtcNow;
post.UpdateDate = post.EditedAt;
await DB.Default.SaveAsync(post, token);
if (!post.IsLocalOnly)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
var update = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = note["to"]!.DeepClone(),
["cc"] = note["cc"]!.DeepClone(),
["object"] = note
};
await _outbox.Publish(author, post, update, token);
}
result.Data = ToView(post);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(UpdatePost)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
{
var result = new WebResult();
@@ -387,6 +450,13 @@ namespace PrivaPub.Services
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
}
static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch
{
"unlisted" => PostVisibility.Unlisted,
"followersonly" or "private" => PostVisibility.FollowersOnly,
_ => PostVisibility.Public
};
static PostMention ToMention(ResolvedMention mention) => new()
{
ActorURI = mention.ActorUri,