using Microsoft.Extensions.Localization; using MongoDB.Driver; 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.Privacy; using PrivaPub.Domain.Statuses; 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 Nearby(string rootUserId, string avatarId, double latitude, double longitude, 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 IStatusService _statuses; readonly IStringLocalizer _localizer; readonly ILogger _logger; public PostsService(DbEntities dbEntities, ILocalActorService localActors, IStatusService statuses, IStringLocalizer localizer, ILogger logger) { _dbEntities = dbEntities; _localActors = localActors; _statuses = statuses; _localizer = localizer; _logger = logger; } public async Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token) { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); return await Answer(() => _statuses.Publish(author, new StatusDraft { Text = form.Text, Title = form.Title, SpoilerText = form.SpoilerText, Sensitive = form.HasContentWarning, Visibility = LocalVisibility(form.Visibility), InReplyTo = form.AnsweringToPostId, GroupId = form.GroupId, Latitude = form.Latitude, Longitude = form.Longitude, RangeKm = form.RangeKm }, token), nameof(InsertPost)); } public async Task DeletePost(string rootUserId, DeletePostForm form, CancellationToken token) { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); var result = await Answer(() => _statuses.Remove(author, form.PostId, token), nameof(DeletePost)); result.Data = default; return result; } public async Task UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token) { var author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); return await Answer(() => _statuses.Edit(author, form.PostId, new StatusDraft { Text = form.Text, Title = form.Title, SpoilerText = form.SpoilerText, Sensitive = form.HasContentWarning }, token), nameof(UpdatePost)); } 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.Match(VisibilityPolicy.IsShown).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 author = await OwnedAvatar(rootUserId, form.AvatarId, token); if (author == default) return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); if (string.IsNullOrEmpty(form.DmGroupId) && form.Recipients.Count == 0) return new WebResult().Invalidate(_localizer["At least one recipient is required."]); return await Answer(() => _statuses.Publish(author, new StatusDraft { Text = form.Text, Sensitive = form.HasContentWarning, Visibility = PostVisibility.Direct, ConversationId = form.DmGroupId, Recipients = form.Recipients }, token), nameof(InsertDm)); } 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) .Match(VisibilityPolicy.IsShown) .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); } } public async Task Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token) { var result = new WebResult(); var viewer = await OwnedAvatar(rootUserId, avatarId, token); if (viewer == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); if (latitude is < -90 or > 90 || longitude is < -180 or > 180) return result.Invalidate(_localizer["Invalid model."]); var query = new MongoDB.Bson.BsonDocument { ["Visibility"] = (int)PostVisibility.LocalGeo, ["DeletedAt"] = MongoDB.Bson.BsonNull.Value }; var candidates = await DB.Default.GeoNear(new Coordinates2D(longitude, latitude), p => p.Distance, true, 50_000, default, default, query) .Limit(200) .ToListAsync(token); var near = candidates.Where(p => p.Distance <= p.RangeKm * 1000).ToList(); var hidden = await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewer.Id, near.Select(p => p.ActorURI), forNotifications: false, token); result.Data = near.Where(p => !hidden.Contains(p.ActorURI)).Select(p => { var view = ToView(p); view.RangeKm = p.RangeKm; view.DistanceKm = Math.Round(p.Distance.Value / 1000, 1); return view; }).ToList(); return result; } async Task Answer(Func> action, string operation) { var result = new WebResult(); try { var outcome = await action(); if (!outcome.Ok) return result.Invalidate(_localizer[outcome.Error], outcome.Status); result.Data = ToView(outcome.Post); return result; } catch (Exception ex) { _logger.LogError(ex, "{Service}.{Operation}", nameof(PostsService), operation); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch { "unlisted" => PostVisibility.Unlisted, "followersonly" or "private" => PostVisibility.FollowersOnly, _ => PostVisibility.Public }; 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 }; } }