using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using System.Linq.Expressions; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Domain.Privacy { public static class VisibilityPolicy { //neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03) public static readonly Expression> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone; public static readonly Expression> IsPublic = p => !p.DeletedAt.HasValue && !p.AuthorGone && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted); static readonly Func IsShownCompiled = IsShown.Compile(); static readonly Func IsPublicCompiled = IsPublic.Compile(); public static bool Shown(Post post) => post != default && IsShownCompiled(post); public static async Task CanSee(Post post, string viewerAvatarId, CancellationToken token) { if (!Shown(post)) return false; if (IsPublicCompiled(post)) return true; if (string.IsNullOrEmpty(viewerAvatarId)) return false; if (post.GroupUserId == viewerAvatarId || post.Mentions.Any(m => m.IsLocal && m.AccountId == viewerAvatarId)) return true; return post.Visibility switch { PostVisibility.Direct => !string.IsNullOrEmpty(post.ConversationId) && await DB.Default.Find() .Match(g => g.ID == post.ConversationId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId)) .ExecuteAnyAsync(token), PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find() .Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId)) .ExecuteAnyAsync(token), PostVisibility.FollowersOnly => await FollowsAuthor(viewerAvatarId, post, token), _ => false }; } // A remote account may see a post, and so like, react to, vote on or downvote it, when the post was for it: anyone for // public and unlisted, an addressed account for a DM, a follower or an addressed account for followers-only (our own // followers-only posts are addressed to the followers collection, never to each follower), a member for a circle. // Accounts only: the instance-actor alias of SignedFetchAuthorizer.MayRead is for refetches, never for interactions. public static async Task RemoteCanSee(Post post, ForeignAvatar actor, CancellationToken token) { if (!Shown(post) || actor == default || string.IsNullOrEmpty(actor.ActorURI)) return false; if (IsPublicCompiled(post)) return true; var addressed = post.To.Contains(actor.ActorURI) || post.Cc.Contains(actor.ActorURI) || post.Mentions.Any(m => m.ActorURI == actor.ActorURI); return post.Visibility switch { PostVisibility.Direct => addressed, //a remote author's followers live on other servers too; we know only who it addressed PostVisibility.FollowersOnly => addressed || !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find() .Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == actor.ActorURI) .ExecuteAnyAsync(token), PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find() .Match(g => g.ID == post.GroupId && g.Members.Any(m => m.IsForeign && m.AvatarId == actor.ActorURI)) .ExecuteAnyAsync(token), _ => false }; } static async Task FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token) { if (!string.IsNullOrEmpty(post.ActorURI)) return await DB.Default.Find() .Match(f => f.AvatarId == viewerAvatarId && f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted) .ExecuteAnyAsync(token); return !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find() .Match(f => f.AvatarId == viewerAvatarId && f.TargetIsLocal && f.TargetAccountId == post.GroupUserId && f.State == FollowState.Accepted) .ExecuteAnyAsync(token); } } }