Files
SocialPub/PrivaPub/Federation/Inbox/RemoteEdits.cs
T
thepraandClaude Opus 5.5 fb4949b511
Build / Build (push) Successful in 7m58s
P7: FEP-268d searchableBy decides what search finds
A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a
public post, the author's followers only those who follow it, anything else nobody but those it already reaches
(their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub
does not emit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:17:36 +02:00

125 lines
5.2 KiB
C#

using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
public static class RemoteEdits
{
public const int MaxRevisions = NoteParser.MaxRevisions;
//newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are
//whole seconds and an edit made in the second the post was published carries updated == published
public static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated
&& (updated > (post.EditedAt ?? post.CreationDate)
|| post.EditedAt == default && updated >= post.CreationDate && Changed(note, post));
static bool Changed(NoteDocument note, PostEntity post) =>
note.ContentHtml != post.ContentHtml || note.SpoilerText != post.SpoilerText || note.Title != post.Title || note.Sensitive != post.HasContentWarning;
public static async Task<bool> Apply(PostEntity post, NoteDocument note, string activityId, ILocalActorService localActors, IObjectRecords records,
IQuoteService quotes, CancellationToken token)
{
post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy;
post.ReplyPolicy = note.ReplyPolicy;
post.LikePolicy = note.LikePolicy;
post.AnnouncePolicy = note.AnnouncePolicy;
post.ReplyApprovals = note.ReplyApprovals;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
post.Place = note.Place;
post.Flairs = await RemotePosts.Flairs(note, post.AudienceURI, token);
post.SearchableBy = note.SearchableBy;
if (note.CommentsEnabled is { } enabled)
post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow;
if (!IsEdit(note, post))
{
await DB.Default.SaveAsync(post, token);
await records.Revise(note, activityId, token);
await Requote(post, note, quotes, token);
return false;
}
//the author's server's own history (Pleroma's, Akkoma's) holds the version we had, and the edits we missed
if (note.FormerRepresentations.Count > 0)
post.Revisions = note.FormerRepresentations.ToList();
else
post.Revisions.Add(new PostRevision
{
Title = post.Title,
SpoilerText = post.SpoilerText,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
EditedAt = post.EditedAt ?? post.CreationDate
});
if (post.Revisions.Count > MaxRevisions)
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
//an edit never narrows who a post was for: what it was addressed to before still holds
var mentions = await RemotePosts.Mentions(note, post.To.Concat(post.Cc).Concat(note.To).Concat(note.Cc), localActors, token);
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt;
post.Source = note.Source;
post.Emojis = note.Emojis.ToList();
post.CoverURL = note.CoverURL;
post.Link = note.Link;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
post.Language = note.Language;
post.Tags = note.Tags.ToList();
post.Mentions = mentions;
post.Media = note.Attachments.ToList();
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
await Domain.Timelines.Fanout.Edited(post, token);
await records.Revise(note, activityId, token);
await Requote(post, note, quotes, token);
return true;
}
static async Task Requote(PostEntity post, NoteDocument note, IQuoteService quotes, CancellationToken token)
{
if (note.QuoteUri != post.QuoteURI || note.QuoteAuthorization != post.QuoteAuthorizationURI || post.QuoteState != QuoteState.Accepted)
await quotes.Resolve(post, note, token);
}
}
public static class RemoteDeletes
{
public static async Task Tombstone(string objectUri, CancellationToken token) =>
await DB.Default.Update<DeletedObject>()
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
public static async Task Remove(PostEntity post, string objectUri, CancellationToken token)
{
await Tombstone(objectUri, token);
await Domain.Timelines.Fanout.Deleting(post, token);
await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Domain.Privacy.Counted.Reply(post))
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
if (post.QuoteState == QuoteState.Accepted && !string.IsNullOrEmpty(post.QuotedPostId))
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
}
}
}