LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.
Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
126 lines
5.3 KiB
C#
126 lines
5.3 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,
|
|
Emojis = post.Emojis.ToList()
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
}
|