Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.
- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
Link.
- The instance API advertises what is enforced:
- max_characters, now enforced with a 422;
- max_pinned_statuses = MaxPins;
- the media types and limits MediaService and MediaOptions accept;
- PollService's limits;
- the configured languages;
- no streaming URL until streaming exists.
domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
- directory, tags/{name}, timelines/link and identity_proofs;
- instance/languages, translation_languages, domain_blocks and privacy_policy;
- the v1 and v2 notification policy, and notification requests.
Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.
671 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
114 lines
4.5 KiB
C#
114 lines
4.5 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 = 20;
|
|
|
|
//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.Video = note.Video ?? post.Video;
|
|
post.Audio = note.Audio ?? post.Audio;
|
|
post.Event = note.Event ?? post.Event;
|
|
if (!IsEdit(note, post))
|
|
{
|
|
await DB.Default.SaveAsync(post, token);
|
|
await records.Revise(note, activityId, token);
|
|
await Requote(post, note, quotes, token);
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
|
|
var mentions = new List<PostMention>();
|
|
foreach (var mention in note.Mentions)
|
|
{
|
|
var local = await localActors.FindByUri(mention.ActorURI, token);
|
|
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
|
|
}
|
|
|
|
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 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 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);
|
|
}
|
|
}
|
|
}
|