using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Domain.Content; using PrivaPub.Domain.Media; using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Social; using PrivaPub.Domain.Timelines; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Media; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.Models.Social; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Domain.Statuses { // an attachment's description or focal point changed by an edit (Mastodon's media_attributes) public sealed record MediaChange(string Id, bool HasDescription, string Description, string Focus); public sealed class StatusDraft { public string Text { get; init; } public bool PlainText { get; init; } public string Title { get; init; } public string SpoilerText { get; init; } public bool Sensitive { get; init; } public PostVisibility Visibility { get; init; } = PostVisibility.Public; public string InReplyTo { get; init; } public string GroupId { get; init; } public string Language { get; init; } public string ConversationId { get; init; } public IReadOnlyList Recipients { get; init; } = Array.Empty(); public IReadOnlyList MediaIds { get; init; } public IReadOnlyList MediaChanges { get; init; } = Array.Empty(); // the scheduled post being published, whose media are reserved for it alone public string ScheduledStatusId { get; init; } public double? Latitude { get; init; } public double? Longitude { get; init; } public double? RangeKm { get; init; } public PollDraft Poll { get; init; } public string QuotedStatusId { get; init; } public string QuotePolicy { get; init; } } public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) { public bool Ok => Error == default; public static StatusOutcome Fail(int status, string error) => new(default, status, error); } public interface IStatusService { Task Publish(LocalActor author, StatusDraft draft, CancellationToken token); Task Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token); Task Remove(LocalActor author, string postId, CancellationToken token); Task Favourite(LocalActor me, string postId, bool on, CancellationToken token); Task Downvote(LocalActor me, string postId, bool on, CancellationToken token); Task Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token); } public class StatusService : IStatusService { const int MaxRevisions = 20; readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; readonly IContentRenderer _content; readonly IOutboxPublisher _outbox; readonly IFanout _fanout; readonly IMediaService _media; readonly IGroupDistributor _groups; readonly IPollService _polls; readonly ILinkPreviews _previews; readonly IQuoteService _quotes; readonly IInteractionApprovals _approvals; public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls, ILinkPreviews previews, IQuoteService quotes, IInteractionApprovals approvals = default) { _approvals = approvals; _previews = previews; _quotes = quotes; _polls = polls; _media = media; _groups = groups; _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _delivery = delivery; _content = content; _outbox = outbox; _fanout = fanout; } public const int MaxCharacters = 5000;//what the instance API advertises as max_characters, and enforced here static StatusOutcome TooLong(StatusDraft draft) => (draft.Text?.Length ?? 0) > MaxCharacters ? StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Text character limit of {MaxCharacters} exceeded") : default; public async Task Publish(LocalActor author, StatusDraft draft, CancellationToken token) { if (TooLong(draft) is { } tooLong) return tooLong; var media = await Media(author, draft.MediaIds, default, draft.ScheduledStatusId, token); if (media == default) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0 && draft.Poll == default) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); if (draft.Poll != default && _polls.Invalid(draft.Poll) is { } invalidPoll) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, invalidPoll); LocalActor group = default; if (!string.IsNullOrEmpty(draft.GroupId)) { var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token); if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found"); group = _localActors.FromGroup(groupEntity); } var parent = await Parent(draft.InReplyTo, token); if (!string.IsNullOrEmpty(draft.InReplyTo) && parent == default && !IsRemoteUri(draft.InReplyTo)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); // its community's moderators locked the thread if (parent is { LockedAt: not null }) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This thread is locked"); // its author takes our reply at once, once asked, or not at all (GoToSocial's canReply) var replyPermission = await Permission(parent, author, InteractionKind.Reply, token); if (replyPermission == QuotePermission.Denied) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take replies from you"); // a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId) && await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle && await MayPost(parentCircle, author, token)) group = _localActors.FromGroup(parentCircle); PostEntity quoted = default; var quotePermission = QuotePermission.Denied; if (!string.IsNullOrEmpty(draft.QuotedStatusId)) { quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); quotePermission = await _quotes.Permission(quoted, author, token); if (quotePermission == QuotePermission.Denied) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted"); } var located = draft.Latitude.HasValue || draft.Longitude.HasValue; if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default || draft.Visibility == PostVisibility.Direct)) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients"); var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); string audienceUri = default; if (group == default && !located && draft.Visibility is PostVisibility.Public or PostVisibility.Unlisted) foreach (var mention in rendered.Mentions) { if (mention.IsLocal) { var mentioned = await _dbEntities.Groups.MatchID(mention.AccountId).ExecuteFirstAsync(token); if (mentioned is { DeletionAt: null, Kind: GroupKind.Community } && await MayPost(mentioned, author, token)) { group = _localActors.FromGroup(mentioned); break; } } else if (await _dbEntities.ForeignAvatars.MatchID(mention.AccountId).ExecuteFirstAsync(token) is { AvatarType: Models.User.AvatarType.Group } remoteGroup) { audienceUri = remoteGroup.ActorURI; break; } } // a community that banned the persona takes nothing from it, a post or a reply var community = audienceUri ?? parent?.AudienceURI; if (community != default && await DB.Default.Find().Match(b => b.AvatarId == author.Id && b.ActorURI == community).ExecuteAnyAsync(token)) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This community banned you"); var isLocalOnly = located; var visibility = located ? PostVisibility.LocalGeo : group is { IsCircle: true } ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility; // asking a quoted post's author for permission would show them the circle post if (visibility == PostVisibility.Circle && quoted != default && quotePermission != QuotePermission.Granted) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A circle post can only quote a post that needs no permission"); var post = new PostEntity { GroupUserId = author.Id, AuthorAccountId = author.Id, GroupId = group?.Id, Visibility = visibility, Title = Clean(draft.Title), SpoilerText = Clean(draft.SpoilerText), HasContentWarning = draft.Sensitive || Clean(draft.SpoilerText) != default, Text = draft.Text, ContentHtml = rendered.Html, ContentFormat = draft.PlainText ? ContentFormat.Plain : ContentFormat.Markdown, Language = Clean(draft.Language), Mentions = rendered.Mentions.Select(ToMention).ToList(), Tags = rendered.Tags.ToList(), Media = media.Select(ToPostMedia).ToList(), AudienceURI = audienceUri, AnsweringToPostId = parent?.ID, InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default), InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, InReplyToActorURI = parent is { IsFederatedCopy: true } ? parent.ActorURI : default, IsLocalOnly = isLocalOnly, ActorURI = author.Uri, Poll = draft.Poll == default ? default : _polls.Create(draft.Poll), QuoteURI = quoted?.ObjectURI, QuotedPostId = quoted?.ID, QuoteByConsent = quoted != default && (quoted.QuotePolicy != default || !quoted.IsFederatedCopy), QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending, Approval = replyPermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); post.Url = author.PostHtmlUrl(post.ID); // the media are this post's before anything is written: another post claiming them meanwhile wins, and this one is refused if (!await Claim(media, post.ID, draft.ScheduledStatusId, token)) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public; if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted && await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor) post.QuoteAuthorizationURI = await _quotes.Grant(quotedAuthor, quoted, post.ObjectURI, author.Uri, token); if (located) { post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2)); post.RangeKm = (float)Math.Clamp(draft.RangeKm ?? 5, 1, 50); } JsonObject create = default; if (visibility == PostVisibility.Direct) { var conversation = await Conversation(author, draft, rendered.Mentions, token); if (!conversation.Ok) return StatusOutcome.Fail(conversation.Status, conversation.Error); var (dmGroup, recipients) = (conversation.Group, conversation.Recipients); post.ConversationId = dmGroup.ID; post.ContextURI = dmGroup.ConversationURI; post.Mentions = recipients.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.LocalId != default, AccountId = r.LocalId ?? r.ForeignId }) .Concat(post.Mentions) .DistinctBy(m => m.ActorURI) .ToList(); // to one account elsewhere, as a ChatMessage when it writes to us that way, or when its server takes a private message // no other way (Lemmy 0.19 and Mbin) post.AsChatMessage = recipients is [{ LocalId: null } recipient] && (await TakesOnlyChatMessages(recipient.Uri, token) || await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token)); var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI); create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); post.To = Strings(note["to"]); await ConversationStates.Posted(dmGroup.ID, post.ID, author.Id, token); } else if (!isLocalOnly) { // the conversation it belongs to (FEP-7888): its parent's, or its own when it starts one if (group == default && visibility is PostVisibility.Public or PostVisibility.Unlisted) post.ContextURI = parent == default ? post.ObjectURI + "/context" : parent.ContextURI; var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI); create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); post.To = Strings(note["to"]); post.Cc = Strings(note["cc"]); } post.ActivityURI = create?["id"]?.GetValue(); await DB.Default.SaveAsync(post, token); if (parent != default && Counted.Reply(post)) await DB.Default.Update().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await _fanout.Distribute(post, token); await _polls.Scheduled(post, token); if (post.QuoteState == QuoteState.Accepted) await DB.Default.Update().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token); if (post.QuoteState == QuoteState.Pending && create?["object"] is JsonObject quotingNote) await _quotes.Request(author, post, quoted, quotingNote, token); await _previews.Wanted(post, token); // a reply its parent's author must approve goes to that author alone, as a request, until it is approved if (create != default && post.Approval == ApprovalState.Pending) await _approvals.Ask(author, parent, InteractionKind.Reply, create["object"].AsObject(), post.ID, token); else if (create != default) await _outbox.Publish(author, post, create, token); if (create != default && post.Approval != ApprovalState.Pending && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted) await _groups.Announce(group, create, post.ObjectURI, isNewPost: true, token); return new StatusOutcome(post); } public async Task Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token) { var post = await Own(author, postId, token); if (post == default) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); if (TooLong(draft) is { } tooLong) return tooLong; var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, default, token); if (draft.MediaIds != default && media == default) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List()).Count == 0 && post.Media.Count == 0) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); 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 plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain; var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); // descriptions and focal points changed by the edit, on the media it keeps or those the post already has var described = media ?? (draft.MediaChanges.Count > 0 ? await DB.Default.Find().Match(m => m.PostId == post.ID && m.TrashedAt == null).ExecuteAsync(token) : default); foreach (var change in draft.MediaChanges) { if (described?.FirstOrDefault(m => m.ID == change.Id) is not { } attachment) continue; if (change.HasDescription) attachment.Description = string.IsNullOrWhiteSpace(change.Description) ? default : change.Description.Trim()[..Math.Min(change.Description.Trim().Length, 1500)]; if (Domain.Media.FocalPoint.Parse(change.Focus) is { } focus) attachment.Focus = focus; await DB.Default.Update().MatchID(attachment.ID) .Modify(m => m.Description, attachment.Description).Modify(m => m.Focus, attachment.Focus).ExecuteAsync(token); } if (media == default && described != default) post.Media = post.Media.Select(copy => described.FirstOrDefault(m => m.ID == copy.AttachmentId) is { } changed ? ToPostMedia(changed) : copy).ToList(); if (media != default) { if (!await Claim(media, post.ID, default, token)) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); post.Media = media.Select(ToPostMedia).ToList(); // what the edit left out is no longer held by anything var kept = media.Select(m => m.ID).ToList(); await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token); } post.Title = draft.Title == default ? post.Title : Clean(draft.Title); // as on Mastodon, an edit sends the whole post: no warning sent is no warning (the language alone falls back) post.SpoilerText = Clean(draft.SpoilerText); post.HasContentWarning = draft.Sensitive || post.SpoilerText != default; post.Text = draft.Text; post.ContentHtml = rendered.Html; post.Language = Clean(draft.Language) ?? post.Language; 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); await Fanout.Edited(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).ToUnixTimeMilliseconds()}"), ["type"] = "Update", ["actor"] = author.Uri, ["to"] = note["to"]!.DeepClone(), ["cc"] = note["cc"]!.DeepClone(), ["object"] = note }; await _outbox.Publish(author, post, update, token); if (group is { IsCircle: false }) await _groups.Announce(group, update, post.ObjectURI, isNewPost: false, token); } return new StatusOutcome(post); } public async Task Remove(LocalActor author, string postId, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy).ExecuteFirstAsync(token); if (post == default || post.DeletedAt.HasValue) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); if (post.ReblogOfPostId != default) return await Reblog(author, post.ReblogOfPostId, false, post.Visibility, token); var audience = await _outbox.Audience(author, post, token); await DB.Default.Update().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()) .Modify(p => p.Revisions, new List()) .ExecuteAsync(token); await Fanout.Deleting(post, token); await _media.Trash(m => m.PostId == post.ID, "post deleted", token); await DB.Default.DeleteAsync(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); // boosts of it end with it, as on Mastodon, so no count keeps them await DB.Default.Update().Match(p => p.ReblogOfPostId == post.ID && !p.DeletedAt.HasValue) .Modify(p => p.DeletedAt, DateTime.UtcNow).ExecuteAsync(token); if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Counted.Reply(post)) await DB.Default.Update().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token); await DB.Default.DeleteAsync(p => p.PostId == post.ID);//a deleted post no longer takes a pin if (audience.Count > 0) { 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 _outbox.Publish(author, post, delete, token);//the post in hand still has its group and mentions var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); if (group is { IsCircle: false }) await _groups.Announce(group, delete, post.ObjectURI, isNewPost: false, token); } return new StatusOutcome(post); } public async Task Favourite(LocalActor me, string postId, bool on, CancellationToken token) { var post = await Visible(me, postId, token); if (post == default) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); var likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted; if (likePermission == QuotePermission.Denied) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you"); // one vote a post: an upvote replaces the downvote, here and, by its Like alone, there if (on) await Forget(d => d.PostId == post.ID && d.ActorURI == me.Uri, post, p => p.DownvotesCount, token); // each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no // server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends var favourite = default(Favourite); if (on) { favourite = new Favourite { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID, Approval = likePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None }; favourite.ActivityURI = me.ActivityUri($"like-{favourite.ID}"); try { await DB.Default.SaveAsync(favourite, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return new StatusOutcome(post); } await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); post.FavouritesCount++; if (!post.IsFederatedCopy) await Notifications.Add(post.GroupUserId, NotificationType.Favourite, me.Id, me.Uri, post.ID, token); } else { favourite = await DB.Default.Find().Match(f => f.AccountId == me.Id && f.PostId == post.ID).ExecuteFirstAsync(token); if (favourite == default || (await DB.Default.DeleteAsync(favourite.ID)).DeletedCount == 0) return new StatusOutcome(post); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); post.FavouritesCount--; } if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes) { var like = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = favourite.ActivityURI ?? me.ActivityUri($"like-{post.ID}"),//a favourite from before every Like had its own id ["type"] = "Like", ["actor"] = me.Uri, ["object"] = post.ObjectURI }; if (on && favourite.Approval == ApprovalState.Pending) await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token); else await _delivery.Enqueue(me, inboxes, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token); } return new StatusOutcome(post); } // A downvote, as the threadiverse counts them (Lemmy, PieFed): a `Dislike` to the post's author and its community, // `Undo{Dislike}` to take it back. One vote a post: a downvote replaces the upvote by its Dislike alone, as Lemmy // sends a changed vote, since an Undo{Like} sent beside it could arrive after it and take the downvote away (Mbin, // which keeps a favourite apart, keeps it). Public, as a favourite is (owner decision: the client tells each // persona once). public async Task Downvote(LocalActor me, string postId, bool on, CancellationToken token) { var post = await Visible(me, postId, token); if (post?.ReblogOfPostId != default) post = await Visible(me, post.ReblogOfPostId, token); if (post == default) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); Downvote downvote; if (on) { await Forget(f => f.AccountId == me.Id && f.PostId == post.ID, post, p => p.FavouritesCount, token); downvote = new Downvote { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID }; downvote.ActivityURI = me.ActivityUri($"dislike-{downvote.ID}"); try { await DB.Default.SaveAsync(downvote, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return new StatusOutcome(post); } await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); post.DownvotesCount++; } else { downvote = await DB.Default.Find().Match(d => d.PostId == post.ID && d.ActorURI == me.Uri).ExecuteFirstAsync(token); if (downvote == default || (await DB.Default.DeleteAsync(downvote.ID)).DeletedCount == 0) return new StatusOutcome(post); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); post.DownvotesCount--; } if (post.IsFederatedCopy && await VoteInboxes(post, token) is { Count: > 0 } inboxes) { var dislike = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = downvote.ActivityURI, ["type"] = "Dislike", ["actor"] = me.Uri, ["object"] = post.ObjectURI }; await _delivery.Enqueue(me, inboxes, on ? dislike : Undo(me, dislike, $"undo-dislike-{downvote.ID}"), token); } return new StatusOutcome(post); } // the persona's other vote on the post, dropped here alone: the vote that replaces it says so there async Task Forget(System.Linq.Expressions.Expression> mine, PostEntity post, System.Linq.Expressions.Expression> count, CancellationToken token) where TVote : Entity { var vote = await DB.Default.Find().Match(mine).ExecuteFirstAsync(token); if (vote == default || (await DB.Default.DeleteAsync(vote.ID)).DeletedCount == 0) return; await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(count, -1)).ExecuteAsync(token); if (typeof(TVote) == typeof(Favourite)) post.FavouritesCount--; else post.DownvotesCount--; } // where a vote on a remote post goes: the community it was made in, which counts the votes and passes them on, as // Lemmy sends one; and its author when on another server (a Mastodon account's post in a Lemmy community). One copy a // server: PieFed drops the second it is sent within a minute, though its first may yet fail. async Task> VoteInboxes(PostEntity post, CancellationToken token) { var inboxes = new List(); if (await Federation.Inbox.Communities.Of(post, _dbEntities, token) is { } communityUri && await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == communityUri && f.AvatarType == AvatarType.Group).ExecuteFirstAsync(token) is { } community && !string.IsNullOrEmpty(community.InboxURL)) inboxes.Add(community.InboxURL); if (await AuthorInbox(post, token) is { } author && !inboxes.Any(i => string.Equals(new Uri(i).Authority, new Uri(author).Authority, StringComparison.OrdinalIgnoreCase))) inboxes.Add(author); return inboxes; } // how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all async Task Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) => target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted; public async Task Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token) { var original = await Visible(me, postId, token); if (original?.ReblogOfPostId != default) original = await Visible(me, original.ReblogOfPostId, token); if (original == default) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted"); var announcePermission = on ? await Permission(original, me, InteractionKind.Announce, token) : QuotePermission.Granted; if (announcePermission == QuotePermission.Denied) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take boosts from you"); var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); // each boost is an activity of its own, as Mastodon's are: a boost after an unboost is a new Announce, which no // server (and no delivery queue) takes for the one already undone; an unboost undoes the boost it ends var reblogId = existing?.ID ?? ObjectId.GenerateNewId().ToString(); var announceId = existing?.ActivityURI ?? me.ActivityUri($"announce-{reblogId}"); var announce = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = announceId, ["type"] = "Announce", ["actor"] = me.Uri, ["published"] = ActivityPubRenderer.Timestamp(DateTime.UtcNow), ["to"] = new JsonArray(visibility == PostVisibility.Unlisted ? me.Followers : ActivityPubRenderer.Public), ["cc"] = new JsonArray(visibility == PostVisibility.Unlisted ? ActivityPubRenderer.Public : me.Followers, original.ActorURI), ["object"] = original.ObjectURI }; if (on) { if (existing != default) return new StatusOutcome(existing); var reblog = new PostEntity { ID = reblogId, GroupUserId = me.Id, AuthorAccountId = me.Id, ReblogOfPostId = original.ID, Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public, ObjectURI = announceId, ActivityURI = announceId, ActorURI = me.Uri, To = Strings(announce["to"]), Cc = Strings(announce["cc"]), Approval = announcePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None }; try { await DB.Default.SaveAsync(reblog, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return new StatusOutcome(await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteFirstAsync(token)); } // two boosts at once: the first one saved stands, and the other goes var boosts = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue) .Sort(p => p.ID, Order.Ascending).ExecuteAsync(token); if (boosts.Count > 1 && boosts[0].ID != reblog.ID) { await DB.Default.DeleteAsync(reblog.ID); return new StatusOutcome(boosts[0]); } await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); if (!original.IsFederatedCopy) await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token); await _fanout.Distribute(reblog, token); if (reblog.Approval == ApprovalState.Pending) await _approvals.Ask(me, original, InteractionKind.Announce, announce, reblog.ID, token); else await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default); return new StatusOutcome(reblog); } if (existing == default) return new StatusOutcome(original); await Fanout.Deleting(existing, token); await DB.Default.DeleteAsync(existing.ID); await DB.Default.DeleteAsync(e => e.PostId == existing.ID); await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{existing.ID}"), token, await AuthorInbox(original, token, shared: true) is { } authorInbox ? new[] { authorInbox } : default); return new StatusOutcome(original); } sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox); // The one place PrivaPub decides by a server's software (owner decision 2026-10-05, G-0008): Lemmy before 1.0 and // Mbin answer a direct Note 400 or drop it, and their actors say nothing of what they take, so the first message to // one of their accounts goes as a ChatMessage, as NodeInfo names them async Task TakesOnlyChatMessages(string actorUri, CancellationToken token) { if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri)) return false; var host = uri.Host; var instance = await DB.Default.Find().Match(i => i.Host == host).ExecuteFirstAsync(token); return instance?.Software?.ToLowerInvariant() switch { "mbin" => true, "lemmy" => instance.SoftwareVersion is { } version && version.StartsWith("0.", StringComparison.Ordinal), _ => false }; } sealed record ConversationResult(DmGroup Group, IReadOnlyList Recipients, int Status = StatusCodes.Status200OK, string Error = default) { public bool Ok => Error == default; } async Task Conversation(LocalActor author, StatusDraft draft, IReadOnlyList mentioned, CancellationToken token) { DmGroup dmGroup = default; if (!string.IsNullOrEmpty(draft.ConversationId)) { dmGroup = await _dbEntities.DmGroups.MatchID(draft.ConversationId).ExecuteFirstAsync(token); if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) return new(default, default, StatusCodes.Status404NotFound, "Conversation not found"); } else { var members = new List { new() { AvatarId = author.Id } }; foreach (var handle in draft.Recipients.Distinct(StringComparer.OrdinalIgnoreCase)) { var member = await ResolveRecipient(handle, token); if (member == default) return new(default, default, StatusCodes.Status404NotFound, $"Recipient '{handle}' not found"); if (members.All(m => m.AvatarId != member.AvatarId)) members.Add(member); } foreach (var mention in mentioned.Where(m => m.AccountId != author.Id)) { var member = mention.IsLocal ? new GroupMember { AvatarId = mention.AccountId } : new GroupMember { AvatarId = mention.ActorUri, IsForeign = true }; if (members.All(m => m.AvatarId != member.AvatarId)) members.Add(member); } if (members.Count < 2) return new(default, default, StatusCodes.Status422UnprocessableEntity, "Validation failed: A direct message needs at least one recipient"); var key = DmGroup.KeyOf(members); dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); if (dmGroup == default) { dmGroup = new DmGroup { Members = members, ParticipantsKey = key }; dmGroup.ID = (string)dmGroup.GenerateNewID(); dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID); await DB.Default.SaveAsync(dmGroup, token); } } var recipients = new List(); foreach (var member in dmGroup.Members.Where(m => !(m.AvatarId == author.Id && !m.IsForeign))) { if (member.IsForeign) { var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token); if (foreign != default) recipients.Add(new Recipient(foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}", default, foreign.ID, foreign.InboxURL)); } else if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } local) recipients.Add(new Recipient(local.Uri, local.Handle, local.Id, default, default)); } return new(dmGroup, recipients); } async Task ResolveRecipient(string recipient, CancellationToken token) { var handle = recipient.Trim().TrimStart('@'); if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase)) { var local = await _localActors.FindByUserName(handle.Split('@')[0], token); return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default; } var actorUri = await _remoteActors.ResolveHandle(handle, token); var foreign = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token); return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; } async Task Parent(string reference, CancellationToken token) { if (string.IsNullOrEmpty(reference)) return default; return IsRemoteUri(reference) ? await _dbEntities.Posts.Match(p => p.ObjectURI == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) : await _dbEntities.Posts.Match(p => p.ID == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); } async Task Own(LocalActor author, string postId, CancellationToken token) => await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) .ExecuteFirstAsync(token); async Task Visible(LocalActor me, string postId, CancellationToken token) { var post = string.IsNullOrEmpty(postId) ? default : await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); return post != default && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default; } // shared: what goes to the author's followers too (a boost) goes to its server's shared inbox, so the server gets // one copy, as Mastodon sends it; two copies at once race in servers that count as they process (Misskey) async Task AuthorInbox(PostEntity post, CancellationToken token, bool shared = false) { if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId)) return default; var author = await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token); return shared && !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.InboxURL; } async Task MayPost(Models.Group.Group group, LocalActor author, CancellationToken token) { var member = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == author.Id); if (group.Kind == GroupKind.Circle) return member != default; return group.PostingPolicy switch { PostingPolicy.Anyone => true, PostingPolicy.Moderators => member?.Role is GroupRole.Owner or GroupRole.Moderator, _ => member != default || await _dbEntities.Followings .Match(f => f.AvatarId == author.Id && f.TargetAccountId == group.ID && f.State == FollowState.Accepted).ExecuteAnyAsync(token) }; } // the author's own uploads, not yet another post's (nor held by another scheduled post), nor trashed, nor pictures async Task> Media(LocalActor author, IReadOnlyList ids, string postId, string scheduledId, CancellationToken token) { if (ids == default || ids.Count == 0) return new List(); if (ids.Count > 4) return default; var wanted = ids.Distinct().ToList(); var found = await DB.Default.Find() .Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId) && (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId) && m.TrashedAt == null && m.ProfileOfAvatarId == null && m.ProcessingState == null) .ExecuteAsync(token); return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default; } // attaches the media in one conditional update, true when all of them are the post's; when another post took one // meanwhile, the ones this call took go back as they were static async Task Claim(IReadOnlyList media, string postId, string scheduledId, CancellationToken token) { var ids = media.Select(m => m.ID).ToList(); if (ids.Count == 0) return true; var claimed = await DB.Default.Update() .Match(m => ids.Contains(m.ID) && (m.PostId == null || m.PostId == postId) && m.TrashedAt == null && (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId)) .Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token); if (claimed.MatchedCount == ids.Count) return true; var taken = media.Where(m => m.PostId == null).Select(m => m.ID).ToList(); await DB.Default.Update().Match(m => taken.Contains(m.ID) && m.PostId == postId) .Modify(m => m.PostId, null).Modify(m => m.AttachedAt, null).ExecuteAsync(token); return false; } PostMedia ToPostMedia(MediaAttachment attachment) => new() { AttachmentId = attachment.ID, ContentType = attachment.ContentType, Kind = attachment.Kind, DurationSeconds = attachment.DurationSeconds, URL = _media.Url(attachment.FilePath), PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath), Description = attachment.Description, Blurhash = attachment.Blurhash, Width = attachment.Width, Height = attachment.Height, Focus = attachment.Focus }; static JsonObject Undo(LocalActor actor, JsonObject inner, string activityId) => new() { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = actor.ActivityUri(activityId), ["type"] = "Undo", ["actor"] = actor.Uri, ["object"] = inner.DeepClone() }; static PostMention ToMention(ResolvedMention mention) => new() { ActorURI = mention.ActorUri, Handle = "@" + mention.Handle, IsLocal = mention.IsLocal, AccountId = mention.AccountId }; static bool IsRemoteUri(string reference) => reference != default && (reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase)); static string Clean(string value) => string.IsNullOrWhiteSpace(value) ? default : value.Trim(); static List Strings(JsonNode node) => node is JsonArray array ? array.Select(n => n?.GetValue()).Where(s => s != default).ToList() : new List(); } }