using Microsoft.Extensions.Localization; using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Post; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Resources; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; using PrivaPub.Domain.Content; using PrivaPub.Domain.Timelines; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Outbox; namespace PrivaPub.Services { public interface IPostsService { Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token); Task DeletePost(string rootUserId, DeletePostForm form, CancellationToken token); Task UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token); Task GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token); Task InsertDm(string rootUserId, InsertDmForm form, CancellationToken token); Task GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token); } public class PostsService : IPostsService { const int PageSize = 50; readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; readonly IContentRenderer _content; readonly IOutboxPublisher _outbox; readonly IFanout _fanout; readonly IStringLocalizer _localizer; readonly ILogger _logger; public PostsService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IStringLocalizer localizer, ILogger logger) { _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _delivery = delivery; _content = content; _outbox = outbox; _fanout = fanout; _localizer = localizer; _logger = logger; } public async Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token) { var result = new WebResult(); try { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); LocalActor group = default; var isLocalOnly = false; if (!string.IsNullOrEmpty(form.GroupId)) { var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token); if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId)) return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); group = _localActors.FromGroup(groupEntity); isLocalOnly = !group.IsFederated; } var parent = await Parent(form.AnsweringToPostId, token); var rendered = await _content.Markdown(form.Text, token); var post = new PostEntity { GroupUserId = author.Id, AuthorAccountId = author.Id, GroupId = group?.Id, Visibility = isLocalOnly ? PostVisibility.Circle : LocalVisibility(form.Visibility), Title = form.Title, SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim(), Text = form.Text, ContentHtml = rendered.Html, ContentFormat = ContentFormat.Markdown, Mentions = rendered.Mentions.Select(ToMention).ToList(), Tags = rendered.Tags.ToList(), HasContentWarning = form.HasContentWarning || !string.IsNullOrWhiteSpace(form.SpoilerText), AnsweringToPostId = parent?.ID, InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId), InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, IsLocalOnly = isLocalOnly, ActorURI = author.Uri }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); post.Url = author.PostHtmlUrl(post.ID); if (isLocalOnly) { await DB.Default.SaveAsync(post, token); await _fanout.Distribute(post, token); result.Data = ToView(post); return result; } var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI); var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); post.ActivityURI = create["id"]!.GetValue(); post.To = Strings(note["to"]); post.Cc = Strings(note["cc"]); await DB.Default.SaveAsync(post, 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 _outbox.Publish(author, post, create, token); if (group is { IsFederated: true } && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted) await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token); result.Data = ToView(post); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task DeletePost(string rootUserId, DeletePostForm form, CancellationToken token) { var result = new WebResult(); try { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); var post = await _dbEntities.Posts .Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy) .ExecuteFirstAsync(token); if (post == default) return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound); if (post.DeletedAt.HasValue) return result; 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); 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); } return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token) { var result = new WebResult(); try { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); var post = await _dbEntities.Posts .Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue) .ExecuteFirstAsync(token); if (post == default) return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound); post.Revisions.Add(new PostRevision { Title = post.Title, SpoilerText = post.SpoilerText, ContentHtml = post.ContentHtml, HasContentWarning = post.HasContentWarning, EditedAt = post.EditedAt ?? post.CreationDate }); var rendered = await _content.Markdown(form.Text, token); post.Title = form.Title; post.SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim(); post.HasContentWarning = form.HasContentWarning || post.SpoilerText != default; post.Text = form.Text; post.ContentHtml = rendered.Html; 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); } result.Data = ToView(post); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(UpdatePost)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token) { var result = new WebResult(); try { var avatar = await OwnedAvatar(rootUserId, avatarId, token); if (avatar == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); var query = _dbEntities.Posts; if (string.IsNullOrEmpty(groupId)) query.Match(p => p.GroupUserId == avatar.Id && p.Visibility != PostVisibility.Direct); else { var isMember = await _dbEntities.Groups .Match(g => g.ID == groupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id)) .ExecuteAnyAsync(token); if (!isMember) return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); query.Match(p => p.GroupId == groupId); } var posts = await query.Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token); result.Data = posts.Select(ToView).ToList(); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task InsertDm(string rootUserId, InsertDmForm form, CancellationToken token) { var result = new WebResult(); try { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); DmGroup dmGroup; if (!string.IsNullOrEmpty(form.DmGroupId)) { dmGroup = await _dbEntities.DmGroups.MatchID(form.DmGroupId).ExecuteFirstAsync(token); if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound); } else { if (form.Recipients.Count == 0) return result.Invalidate(_localizer["At least one recipient is required."]); var members = new List { new() { AvatarId = author.Id } }; foreach (var recipient in form.Recipients.Distinct(StringComparer.OrdinalIgnoreCase)) { var member = await ResolveRecipient(recipient, token); if (member == default) return result.Invalidate(_localizer["Recipient '{0}' not found.", recipient], StatusCodes.Status404NotFound); if (members.All(m => m.AvatarId != member.AvatarId)) members.Add(member); } 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 dm = new PostEntity { GroupUserId = author.Id, AuthorAccountId = author.Id, ConversationId = dmGroup.ID, Visibility = PostVisibility.Direct, ContextURI = dmGroup.ConversationURI, Text = form.Text, ContentHtml = (await _content.Markdown(form.Text, token)).Html, ContentFormat = ContentFormat.Markdown, HasContentWarning = form.HasContentWarning, ActorURI = author.Uri }; dm.ID = (string)dm.GenerateNewID(); dm.ObjectURI = author.PostUri(dm.ID); dm.Url = author.PostHtmlUrl(dm.ID); var remote = new List<(string Uri, string Handle)>(); var inboxes = new List(); foreach (var member in dmGroup.Members.Where(m => m.IsForeign)) { var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token); if (foreign == default) continue; remote.Add((foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}")); inboxes.Add(foreign.InboxURL); } foreach (var member in dmGroup.Members.Where(m => !m.IsForeign && m.AvatarId != author.Id)) { var local = await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token); if (local != default) remote.Add((local.Uri, local.Handle)); } var directNote = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI); var directCreate = ActivityPubRenderer.Create(author, directNote, $"create-{dm.ID}"); dm.ActivityURI = directCreate["id"]!.GetValue(); dm.To = Strings(directNote["to"]); dm.Mentions = remote.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.Uri.StartsWith(author.BaseAddress + "/") }).ToList(); await DB.Default.SaveAsync(dm, token); await DB.Default.Update().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); await _fanout.Distribute(dm, token); if (inboxes.Count > 0) await _delivery.Enqueue(author, inboxes, directCreate, token); result.Data = ToView(dm); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token) { var result = new WebResult(); try { var avatar = await OwnedAvatar(rootUserId, avatarId, token); if (avatar == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); if (string.IsNullOrEmpty(dmGroupId)) { var groups = await _dbEntities.DmGroups .Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id)) .Sort(g => g.UpdatedAt, Order.Descending) .ExecuteAsync(token); result.Data = groups.Select(g => new ViewDmGroup { Id = g.ID, Members = g.Members.Select(m => m.AvatarId).ToList(), UpdatedAt = g.UpdatedAt }).ToList(); return result; } var isMember = await _dbEntities.DmGroups .Match(g => g.ID == dmGroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id)) .ExecuteAnyAsync(token); if (!isMember) return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound); var dms = await _dbEntities.Posts .Match(p => p.ConversationId == dmGroupId && p.Visibility == PostVisibility.Direct) .Sort(p => p.CreationDate, Order.Descending) .Limit(PageSize) .ExecuteAsync(token); result.Data = dms.Select(ToView).ToList(); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } 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); if (actorUri == default) return default; var foreign = await _remoteActors.GetActor(actorUri, refresh: false, token); return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; } async Task Parent(string answeringTo, CancellationToken token) { if (string.IsNullOrEmpty(answeringTo)) return default; return answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? await _dbEntities.Posts.Match(p => p.ObjectURI == answeringTo).ExecuteFirstAsync(token) : await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token); } static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch { "unlisted" => PostVisibility.Unlisted, "followersonly" or "private" => PostVisibility.FollowersOnly, _ => PostVisibility.Public }; static PostMention ToMention(ResolvedMention mention) => new() { ActorURI = mention.ActorUri, Handle = "@" + mention.Handle, IsLocal = mention.IsLocal, AccountId = mention.AccountId }; static string RemoteUri(string answeringTo) => answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default; static List Strings(JsonNode node) => node is JsonArray array ? array.Select(n => n?.GetValue()).Where(s => s != default).ToList() : new List(); async Task OwnedAvatar(string rootUserId, string avatarId, CancellationToken token) { if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId)) return default; if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token)) return default; return await _localActors.FindById(LocalActorKind.Person, avatarId, token); } static ViewPost ToView(PostEntity post) => new() { Id = post.ID, ObjectURI = post.ObjectURI, AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId, AuthorActorURI = post.ActorURI, GroupId = post.GroupId, DmGroupId = post.ConversationId, Visibility = post.Visibility.ToString(), AnsweringToPostId = post.AnsweringToPostId, Title = post.Title, Text = post.Text, ContentHtml = post.ContentHtml, HasContentWarning = post.HasContentWarning, IsFederatedCopy = post.IsFederatedCopy, CreationDate = post.CreationDate }; } }