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.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 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); var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token); if (follower != default) { await DB.Default.DeleteAsync(follower.ID); if (!targetActorUri.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"] = targetActorUri, ["object"] = me.Uri } }; await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token); } else await DB.Default.DeleteAsync(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId); } await Forget(me, targetAccountId, token); if (block != default) await Tell(me, targetActorUri, BlockActivity(me, block), token); } 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) { } foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token)) if (Hidden.HostMatches(following.TargetActorURI, domain)) await _follows.UnfollowAs(me, following.TargetActorURI, token); foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token)) if (Hidden.HostMatches(follower.ActorURI, domain)) 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) && (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase)); 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); 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); foreach (var uri in uris) if (domains.Any(d => HostMatches(uri, d.Domain))) 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 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); foreach (var block in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token)) if (HostMatches(actorUri, block.Domain)) hiding.Add(block.AvatarId); return hiding; } } }