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 { 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 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 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; 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) { _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 async Task Publish(LocalActor author, StatusDraft draft, CancellationToken token) { var media = await Media(author, draft.MediaIds, default, 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"); 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; } } 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; 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, 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 }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); post.Url = author.PostHtmlUrl(post.ID); 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(); 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 DB.Default.Update().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); } else if (!isLocalOnly) { 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); await Attach(media, post.ID, token); if (parent != default) 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); if (create != default) await _outbox.Publish(author, post, create, token); if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted) await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), 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"); var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, 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); if (media != default) { post.Media = media.Select(ToPostMedia).ToList(); await Attach(media, post.ID, token); } post.Title = draft.Title == default ? post.Title : Clean(draft.Title); 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); 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); 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 DB.Default.DeleteAsync(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); if (!string.IsNullOrEmpty(post.AnsweringToPostId)) await DB.Default.Update().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token); 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 _delivery.Enqueue(author, audience, delete, token); 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 activityId = me.ActivityUri($"like-{post.ID}"); if (on) { try { await DB.Default.SaveAsync(new Favourite { AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID, ActivityURI = activityId }, 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 { var removed = await DB.Default.DeleteAsync(f => f.AccountId == me.Id && f.PostId == post.ID); if (removed.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 AuthorInbox(post, token) is { } inbox) { var like = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = activityId, ["type"] = "Like", ["actor"] = me.Uri, ["object"] = post.ObjectURI }; await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{post.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token); } return new StatusOutcome(post); } 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 existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var announceId = me.ActivityUri($"announce-{original.ID}"); 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 { 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"]) }; 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)); } 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); await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token) is { } inbox ? new[] { inbox } : default); return new StatusOutcome(reblog); } if (existing == default) return new StatusOutcome(original); 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-{original.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token, await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default); return new StatusOutcome(original); } sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox); 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; } async Task AuthorInbox(PostEntity post, CancellationToken token) { if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId)) return default; return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.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) }; } async Task> Media(LocalActor author, IReadOnlyList ids, string postId, 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)) .ExecuteAsync(token); return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default; } static async Task Attach(IEnumerable media, string postId, CancellationToken token) { var ids = media.Select(m => m.ID).ToList(); if (ids.Count > 0) await DB.Default.Update().Match(m => ids.Contains(m.ID)) .Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token); } PostMedia ToPostMedia(MediaAttachment attachment) => new() { AttachmentId = attachment.ID, ContentType = attachment.ContentType, 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(); } }