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:
1 parent
61f6ca0676
commit
a76cc34557
10 files changed
+368
-33
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user