using Microsoft.Extensions.Localization; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Social; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Resources; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Domain.Social { public interface IFollowService { Task Follow(string rootUserId, FollowForm form, CancellationToken token); Task Unfollow(string rootUserId, FollowForm form, CancellationToken token); Task Followings(string rootUserId, string avatarId, CancellationToken token); } public class FollowService : IFollowService { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; readonly IStringLocalizer _localizer; readonly ILogger _logger; public FollowService(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 Follow(string rootUserId, FollowForm form, CancellationToken token) { var result = new WebResult(); try { var follower = await OwnedAvatar(rootUserId, form.AvatarId, token); if (follower == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); var (local, remote) = await ResolveTarget(form.Target, token); if (local == default && remote == default) return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound); if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application)) return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound); var targetUri = local?.Uri ?? remote.ActorURI; var existing = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); if (existing != default) { result.Data = ToView(existing); return result; } var following = new Following { AvatarId = follower.Id, TargetActorURI = targetUri, TargetAccountId = local?.Id ?? remote.ID, TargetIsLocal = local != default, TargetInboxURL = local?.Inbox ?? remote.InboxURL, State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested }; following.ID = (string)following.GenerateNewID(); following.FollowActivityURI = follower.ActivityUri($"follow-{following.ID}"); try { await DB.Default.SaveAsync(following, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { result.Data = ToView(await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token)); return result; } if (local != default) await FollowLocally(follower, local, following, token); else await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token); result.Data = ToView(following); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Follow)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task Unfollow(string rootUserId, FollowForm form, CancellationToken token) { var result = new WebResult(); try { var follower = await OwnedAvatar(rootUserId, form.AvatarId, token); if (follower == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); var (local, remote) = await ResolveTarget(form.Target, token); var targetUri = local?.Uri ?? remote?.ActorURI ?? form.Target; var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); if (following == default) return result; await DB.Default.DeleteAsync(following.ID); if (following.TargetIsLocal) { await DB.Default.DeleteAsync(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri); await DB.Default.Update().MatchID(following.TargetAccountId) .Modify(b => b.PullFilter(g => g.Members, m => !m.IsForeign && m.AvatarId == follower.Id && m.Role == GroupRole.Member)) .ExecuteAsync(token); } else { var undo = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = follower.ActivityUri($"undo-follow-{following.ID}"), ["type"] = "Undo", ["actor"] = follower.Uri, ["object"] = FollowActivity(follower, following) }; await _delivery.Enqueue(follower, new[] { following.TargetInboxURL }, undo, token); } return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(FollowService)}.{nameof(Unfollow)}"); return result.Invalidate(_localizer["Something went wrong."], exception: ex); } } public async Task Followings(string rootUserId, string avatarId, CancellationToken token) { var result = new WebResult(); var follower = await OwnedAvatar(rootUserId, avatarId, token); if (follower == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); result.Data = (await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id).Sort(f => f.ID, Order.Descending).ExecuteAsync(token)) .Select(ToView).ToList(); return result; } async Task FollowLocally(LocalActor follower, LocalActor target, Following following, CancellationToken token) { await DB.Default.Update() .Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.Uri) .Modify(f => f.InboxURL, follower.Inbox) .Modify(f => f.SharedInboxURL, follower.SharedInbox) .Modify(f => f.FollowActivityURI, following.FollowActivityURI) .Modify(f => f.IsAccepted, following.State == FollowState.Accepted) .Modify(b => b.SetOnInsert(f => f.CreationDate, DateTime.UtcNow)) .Option(o => o.IsUpsert = true) .ExecuteAsync(token); if (target.Kind == LocalActorKind.Group && following.State == FollowState.Accepted) await DB.Default.Update() .Match(g => g.ID == target.Id && !g.Members.Any(m => !m.IsForeign && m.AvatarId == follower.Id)) .Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = follower.Id })) .ExecuteAsync(token); if (target.Kind == LocalActorKind.Person) await Notifications.Add(target.Id, following.State == FollowState.Accepted ? NotificationType.Follow : NotificationType.FollowRequest, follower.Id, follower.Uri, default, token); } async Task<(LocalActor Local, ForeignAvatar Remote)> ResolveTarget(string target, CancellationToken token) { target = target?.Trim(); if (string.IsNullOrEmpty(target)) return default; if (target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || target.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) { var byUri = await _localActors.FindByUri(target, token); return byUri != default ? (byUri, default) : (default, await _remoteActors.GetActor(target, refresh: false, token)); } var parts = target.TrimStart('@').Split('@'); var localDomain = new Uri(_localActors.BaseAddress).Authority; if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) return (await _localActors.FindByUserName(parts[0], token), default); var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token); return (default, actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token)); } static JsonObject FollowActivity(LocalActor follower, Following following) => new() { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = following.FollowActivityURI, ["type"] = "Follow", ["actor"] = follower.Uri, ["object"] = following.TargetActorURI }; 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 ViewFollowing ToView(Following following) => new() { Id = following.ID, TargetActorURI = following.TargetActorURI, TargetAccountId = following.TargetAccountId, TargetIsLocal = following.TargetIsLocal, State = following.State.ToString(), CreatedAt = following.CreatedAt }; } }