using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Text.Json.Nodes; namespace PrivaPub.Domain.Relationships { public interface IRelationshipService { Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token); Task Unblock(LocalActor me, string targetActorUri, CancellationToken token); Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token); Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token); Task Unmute(LocalActor me, string targetActorUri, CancellationToken token); Task BlockDomain(LocalActor me, string domain, CancellationToken token); Task UnblockDomain(LocalActor me, string domain, CancellationToken token); } public class RelationshipService : IRelationshipService { readonly DbEntities _dbEntities; readonly IFollowService _follows; readonly IDeliveryService _delivery; public RelationshipService(DbEntities dbEntities, IFollowService follows, IDeliveryService delivery) { _dbEntities = dbEntities; _follows = follows; _delivery = delivery; } public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token) { var block = new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }; try { await DB.Default.SaveAsync(block, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { block = await DB.Default.Find().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token); } await _follows.UnfollowAs(me, targetActorUri, token); await RemoveFollower(me, targetActorUri, targetAccountId, token); await Forget(me, targetAccountId, token); if (block != default) await Tell(me, targetActorUri, BlockActivity(me, block), token); } public async Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token) { var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == followerActorUri).ExecuteFirstAsync(token); if (follower == default) return; await DB.Default.DeleteAsync(follower.ID); if (!followerActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI)) { var reject = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"), ["type"] = "Reject", ["actor"] = me.Uri, ["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = followerActorUri, ["object"] = me.Uri } }; await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token); } else await DB.Default.DeleteAsync(f => f.TargetActorURI == me.Uri && f.AvatarId == followerAccountId); } public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) { var block = await DB.Default.Find().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token); if (block == default) return; await DB.Default.DeleteAsync(block.ID); await Tell(me, targetActorUri, new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = me.ActivityUri($"undo-block-{block.ID}"), ["type"] = "Undo", ["actor"] = me.Uri, ["object"] = BlockActivity(me, block) }, token); } static JsonObject BlockActivity(LocalActor me, Block block) => new() { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = me.ActivityUri($"block-{block.ID}"), ["type"] = "Block", ["actor"] = me.Uri, ["object"] = block.TargetActorURI, ["to"] = new JsonArray(block.TargetActorURI) }; async Task Tell(LocalActor me, string targetActorUri, JsonObject activity, CancellationToken token) { if (targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) return; var target = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == targetActorUri).ExecuteFirstAsync(token); if (!string.IsNullOrEmpty(target?.InboxURL)) await _delivery.Enqueue(me, new[] { target.InboxURL }, activity, token); } public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token) { await DB.Default.Update() .Match(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri) .Modify(m => m.TargetAccountId, targetAccountId) .Modify(m => m.HideNotifications, hideNotifications) .Modify(m => m.ExpiresAt, duration.HasValue && duration > TimeSpan.Zero ? DateTime.UtcNow + duration : null) .Modify(b => b.SetOnInsert(m => m.CreatedAt, DateTime.UtcNow)) .Option(o => o.IsUpsert = true) .ExecuteAsync(token); await Forget(me, targetAccountId, token); } public async Task Unmute(LocalActor me, string targetActorUri, CancellationToken token) => await DB.Default.DeleteAsync(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri); public async Task BlockDomain(LocalActor me, string domain, CancellationToken token) { domain = domain?.Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(domain) || Uri.CheckHostName(domain) != UriHostNameType.Dns) return; try { await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = me.Id, Domain = domain }, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { } var followings = await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token); var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token); var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token); foreach (var following in followings) if (Hidden.Blocks(domain, following.TargetActorURI, handles.GetValueOrDefault(following.TargetActorURI))) await _follows.UnfollowAs(me, following.TargetActorURI, token); foreach (var follower in followers) if (Hidden.Blocks(domain, follower.ActorURI, handles.GetValueOrDefault(follower.ActorURI))) await DB.Default.DeleteAsync(follower.ID); } public async Task UnblockDomain(LocalActor me, string domain, CancellationToken token) { domain = domain?.Trim().ToLowerInvariant(); await DB.Default.DeleteAsync(b => b.AvatarId == me.Id && b.Domain == domain); } static async Task Forget(LocalActor me, string targetAccountId, CancellationToken token) { if (string.IsNullOrEmpty(targetAccountId)) return; await DB.Default.DeleteAsync(e => e.AvatarId == me.Id && e.AuthorAccountId == targetAccountId); await DB.Default.DeleteAsync(n => n.AvatarId == me.Id && n.FromAccountId == targetAccountId); } } public static class Hidden { public static bool HostMatches(string actorUri, string domain) => Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) && IsOn(uri.Host, domain); static bool IsOn(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); // a server blocked by a persona hides an account whose actor is on it, or whose handle is (its domain apart from its // actor's host, FEP-2c59: the servers Mastodon lists as blocked are its handles' domains) public static bool Blocks(string domain, string actorUri, string handleDomain) => HostMatches(actorUri, domain) || (!string.IsNullOrEmpty(handleDomain) && IsOn(handleDomain.Split(':')[0], domain)); // the domains of these accounts' handles, for those known public static async Task> HandleDomains(List actorUris, CancellationToken token) => actorUris.Count == 0 ? [] : (await DB.Default.Find().Match(a => actorUris.Contains(a.ActorURI)) .Project(a => new ForeignAvatar { ActorURI = a.ActorURI, Domain = a.Domain }).ExecuteAsync(token)) .GroupBy(a => a.ActorURI).ToDictionary(g => g.Key, g => g.First().Domain); public static async Task> AuthorsHiddenFrom(string avatarId, IEnumerable actorUris, bool forNotifications, CancellationToken token) { var uris = actorUris.Where(u => !string.IsNullOrEmpty(u)).Distinct().ToList(); var hidden = new HashSet(StringComparer.Ordinal); if (string.IsNullOrEmpty(avatarId) || uris.Count == 0) return hidden; var now = DateTime.UtcNow; foreach (var block in await DB.Default.Find().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token)) hidden.Add(block.TargetActorURI); //an account that blocked the persona is hidden from it too, as Mastodon hides it from the blocked account's home foreach (var blocker in await DB.Default.Find().Match(b => b.AvatarId == avatarId && uris.Contains(b.ActorURI)).ExecuteAsync(token)) hidden.Add(blocker.ActorURI); foreach (var mute in await DB.Default.Find().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token)) if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications)) hidden.Add(mute.TargetActorURI); var domains = await DB.Default.Find().Match(b => b.AvatarId == avatarId).ExecuteAsync(token); if (domains.Count == 0) return hidden; var handles = await HandleDomains(uris, token); foreach (var uri in uris) if (domains.Any(d => Blocks(d.Domain, uri, handles.GetValueOrDefault(uri)))) hidden.Add(uri); return hidden; } public static async Task> RecipientsHiding(IEnumerable avatarIds, string actorUri, CancellationToken token) { var ids = avatarIds.Distinct().ToList(); var hiding = new HashSet(StringComparer.Ordinal); if (ids.Count == 0 || string.IsNullOrEmpty(actorUri)) return hiding; var now = DateTime.UtcNow; foreach (var block in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token)) hiding.Add(block.AvatarId); foreach (var blocked in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId) && b.ActorURI == actorUri).ExecuteAsync(token)) hiding.Add(blocked.AvatarId); foreach (var mute in await DB.Default.Find().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token)) if (mute.ExpiresAt == null || mute.ExpiresAt > now) hiding.Add(mute.AvatarId); var domains = await DB.Default.Find().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token); if (domains.Count == 0) return hiding; var handle = (await HandleDomains([actorUri], token)).GetValueOrDefault(actorUri); foreach (var block in domains) if (Blocks(block.Domain, actorUri, handle)) hiding.Add(block.AvatarId); return hiding; } } }