using Microsoft.Extensions.Localization; using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Post; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Resources; using PrivaPub.Services.Federation; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using DmPostEntity = PrivaPub.Models.Post.DmPost; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Services { public interface IPostsService { Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token); Task DeletePost(string rootUserId, DeletePostForm 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 IStringLocalizer _localizer; readonly ILogger _logger; public PostsService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, IStringLocalizer localizer, ILogger logger) { _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _delivery = delivery; _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; 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); } var post = new PostEntity { GroupUserId = author.Id, GroupId = group?.Id, Title = form.Title, Text = form.Text, HasContentWarning = form.HasContentWarning, AnsweringToPostId = form.AnsweringToPostId, ActorURI = author.Uri }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); await DB.Default.SaveAsync(post, token); var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token); var note = ActivityPubRenderer.Note(post, author, group, inReplyTo); var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); await _delivery.EnqueueToFollowers(author, create, token); if (group != default) 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["Error: {0}", ex.Message], 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); await DB.Default.DeleteAsync(post.ID); var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}", new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers)); var extraInboxes = Enumerable.Empty(); if (!string.IsNullOrEmpty(post.GroupId)) { var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); if (group != default) extraInboxes = await _delivery.FollowerInboxes(group, token); } await _delivery.EnqueueToFollowers(author, delete, token, extraInboxes); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}"); return result.Invalidate(_localizer["Error: {0}", ex.Message], 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); 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["Error: {0}", ex.Message], 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); } dmGroup = new DmGroup { Members = members }; dmGroup.ID = (string)dmGroup.GenerateNewID(); dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}"; await DB.Default.SaveAsync(dmGroup, token); } var dm = new DmPostEntity { GroupUserId = author.Id, GroupId = dmGroup.ID, Text = form.Text, HasContentWarning = form.HasContentWarning, ActorURI = author.Uri }; dm.ID = (string)dm.GenerateNewID(); dm.ObjectURI = author.PostUri(dm.ID); await DB.Default.SaveAsync(dm, token); await DB.Default.Update().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); 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)); } if (inboxes.Count > 0) { var note = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI); var create = ActivityPubRenderer.Create(author, note, $"create-{dm.ID}"); await _delivery.Enqueue(author, inboxes, create, token); } result.Data = ToView(dm); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}"); return result.Invalidate(_localizer["Error: {0}", ex.Message], 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.DmPosts .Match(p => p.GroupId == dmGroupId) .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["Error: {0}", ex.Message], 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, await _localActors.GetInstanceActor(token), refresh: false, token); return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; } async Task ReplyTarget(string answeringToPostId, CancellationToken token) { if (string.IsNullOrEmpty(answeringToPostId)) return default; if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return answeringToPostId; var parent = await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token); return parent?.ObjectURI; } 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, AnsweringToPostId = post.AnsweringToPostId, Title = post.Title, Text = post.Text, HasContentWarning = post.HasContentWarning, IsFederatedCopy = post.IsFederatedCopy, CreationDate = post.CreationDate }; static ViewPost ToView(DmPostEntity post) => new() { Id = post.ID, ObjectURI = post.ObjectURI, AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId, AuthorActorURI = post.ActorURI, DmGroupId = post.GroupId, AnsweringToPostId = post.AnsweringToPostId, Title = post.Title, Text = post.Text, HasContentWarning = post.HasContentWarning, IsFederatedCopy = post.IsFederatedCopy, CreationDate = post.CreationDate }; } }