diff --git a/PrivaPub.ClientModels/Social/FollowForm.cs b/PrivaPub.ClientModels/Social/FollowForm.cs new file mode 100644 index 0000000..a244a35 --- /dev/null +++ b/PrivaPub.ClientModels/Social/FollowForm.cs @@ -0,0 +1,37 @@ +using PrivaPub.ClientModels.Resources; + +using System.ComponentModel.DataAnnotations; + +namespace PrivaPub.ClientModels.Social +{ + public class FollowForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))] + public string AvatarId { get; set; } + + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(2048, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Target { get; set; }//user@host, @user, or an actor URI + } + + public class ViewFollowing + { + public string Id { get; set; } + public string TargetActorURI { get; set; } + public string TargetAccountId { get; set; } + public bool TargetIsLocal { get; set; } + public string State { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class ViewNotification + { + public string Id { get; set; } + public string Type { get; set; } + public string FromAccountId { get; set; } + public string FromActorURI { get; set; } + public string PostId { get; set; } + public bool IsRead { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/PrivaPub.Tests/Domain/FollowTests.cs b/PrivaPub.Tests/Domain/FollowTests.cs new file mode 100644 index 0000000..8328c2c --- /dev/null +++ b/PrivaPub.Tests/Domain/FollowTests.cs @@ -0,0 +1,124 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Social; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Domain +{ + [Trait("Category", "Integration")] + public sealed class FollowTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + [Fact] + public async Task A_local_follow_is_accepted_at_once_and_notified() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var (_, bob) = await _harness.Persona("bob"); + + var result = await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = "@" + bob.UserName }, token); + + Assert.True(result.IsValid); + var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token); + Assert.Equal(FollowState.Accepted, following.State); + Assert.True(following.TargetIsLocal); + Assert.True((await DB.Default.Find().Match(f => f.LocalActorId == bob.Id && f.ActorURI == alice.Uri).ExecuteSingleAsync(token)).IsAccepted); + Assert.Equal(NotificationType.Follow, (await DB.Default.Find().Match(n => n.AvatarId == bob.Id).ExecuteSingleAsync(token)).Type); + Assert.Empty(await _harness.Outgoing(bob.Inbox)); + } + + [Fact] + public async Task Someone_elses_persona_cannot_be_made_to_follow() + { + var (_, alice) = await _harness.Persona("alice"); + var (_, bob) = await _harness.Persona("bob"); + + var result = await _harness.Follows.Follow("someone-else", new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, TestContext.Current.CancellationToken); + + Assert.False(result.IsValid); + Assert.Equal(404, result.StatusCode); + } + + [Fact] + public async Task A_remote_follow_is_requested_then_accepted() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var carol = new RemoteActor(_harness.Peer, "carol"); + + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token); + + var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token); + Assert.Equal(FollowState.Requested, following.State); + var follow = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox")); + Assert.Equal("Follow", follow["type"]!.GetValue()); + Assert.Equal(alice.Uri, follow["actor"]!.GetValue()); + Assert.StartsWith(alice.Uri + "/grunts/follow-", follow["id"]!.GetValue()); + + var accept = new JsonObject + { + ["id"] = $"{carol.Id}#accepts/{Guid.NewGuid():N}", + ["type"] = "Accept", + ["actor"] = carol.Id, + ["object"] = follow.DeepClone() + }; + Assert.Equal(202, (await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", accept)).StatusCode); + Assert.Equal(FollowState.Accepted, (await DB.Default.Find().OneAsync(following.ID, token)).State); + } + + [Fact] + public async Task An_accept_from_someone_else_changes_nothing_and_a_reject_ends_the_request() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var carol = new RemoteActor(_harness.Peer, "carol"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token); + var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token); + + await _harness.Deliver(mallory, "/human-centipede", new JsonObject + { + ["id"] = $"{mallory.Id}#accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = mallory.Id, ["object"] = following.FollowActivityURI + }); + Assert.Equal(FollowState.Requested, (await DB.Default.Find().OneAsync(following.ID, token)).State); + + await _harness.Deliver(carol, "/human-centipede", new JsonObject + { + ["id"] = $"{carol.Id}#rejects/{Guid.NewGuid():N}", ["type"] = "Reject", ["actor"] = carol.Id, ["object"] = following.FollowActivityURI + }); + Assert.Null(await DB.Default.Find().OneAsync(following.ID, token)); + } + + [Fact] + public async Task Unfollowing_a_remote_account_sends_an_undo() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var carol = new RemoteActor(_harness.Peer, "carol"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token); + + await _harness.Follows.Unfollow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token); + + Assert.False(await DB.Default.Find().Match(f => f.AvatarId == alice.Id).ExecuteAnyAsync(token)); + var undo = (await _harness.Outgoing(carol.Id + "/inbox")).Single(a => a["type"]!.GetValue() == "Undo"); + Assert.Equal("Follow", undo["object"]!["type"]!.GetValue()); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs new file mode 100644 index 0000000..6b51e14 --- /dev/null +++ b/PrivaPub.Tests/Support/Harness.cs @@ -0,0 +1,98 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Inbox.Handlers; +using PrivaPub.Federation.Outbox; +using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Models; +using PrivaPub.Models.Jobs; +using PrivaPub.Models.User; +using PrivaPub.Resources; +using PrivaPub.StaticServices; + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Support +{ + public sealed class Harness : IAsyncDisposable + { + public const string Host = "privapub.test"; + public const string Base = "https://" + Host; + + Harness(Peer peer) + { + Peer = peer; + var cache = new MemoryCache(new MemoryCacheOptions()); + Local = new LocalActorService(Db, new StaticOptions(new AppConfiguration { BackendBaseAddress = Base })); + Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db); + Delivery = new DeliveryService(Db, Queue); + Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance); + Processor = new InboxProcessor(Remote, new IActivityHandler[] + { + new FollowHandler(Db, Local, Remote, Delivery), + new AcceptHandler(Db, Local), + new RejectHandler(Db, Local), + new UndoHandler(Db, Local, Remote, Delivery), + new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks()), + new DeleteHandler(Db, Local, Remote, Delivery), + new UpdateHandler(Db, Local, Remote) + }, NullLogger.Instance); + Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); + } + + public Peer Peer { get; } + public DbEntities Db { get; } = new(); + public JobQueue Queue { get; } = new(); + public LocalActorService Local { get; } + public RemoteActorService Remote { get; } + public DeliveryService Delivery { get; } + public InboxReceiver Receiver { get; } + public InboxProcessor Processor { get; } + public FollowService Follows { get; } + + public static async Task Start() => new(await Peer.Start()); + + public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default) + { + rootId ??= Guid.NewGuid().ToString("N")[..24]; + var (privateKey, publicKey) = Keys.NewKeyPair(); + var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey }; + await DB.Default.SaveAsync(avatar); + await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID }); + return (rootId, Local.FromAvatar(avatar)); + } + + public async Task Deliver(RemoteActor sender, string path, JsonNode activity) + { + var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None); + var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue() : default); + var job = await DB.Default.Find().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(); + if (job != default) + Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result); + return result; + } + + public async Task> Outgoing(string inbox) => + (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync()) + .Select(j => JsonSerializer.Deserialize(j.Payload)) + .Where(p => p.Inbox == inbox) + .Select(p => JsonNode.Parse(p.Body)!.AsObject()) + .ToList(); + + public async ValueTask DisposeAsync() => await Peer.DisposeAsync(); + } + + public sealed class KeyLocalizer : IStringLocalizer + { + public LocalizedString this[string name] => new(name, name); + public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments)); + public IEnumerable GetAllStrings(bool includeParentCultures) => Enumerable.Empty(); + } +} diff --git a/PrivaPub/Controllers/ClientToServer/SocialController.cs b/PrivaPub/Controllers/ClientToServer/SocialController.cs new file mode 100644 index 0000000..5c144f0 --- /dev/null +++ b/PrivaPub/Controllers/ClientToServer/SocialController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; + +using PrivaPub.ClientModels; +using PrivaPub.ClientModels.Social; +using PrivaPub.Domain.Social; +using PrivaPub.Extensions; +using PrivaPub.Resources; + +namespace PrivaPub.Controllers.ClientToServer +{ + [ApiController, + Route("clientapi/social"), + Authorize(Policy = Policies.IsUser)] + public class SocialController : ControllerBase + { + readonly IFollowService _follows; + readonly IStringLocalizer _localizer; + + public SocialController(IFollowService follows, IStringLocalizer localizer) + { + _follows = follows; + _localizer = localizer; + } + + [HttpPost, Route("/clientapi/follow")] + public async Task Follow(FollowForm form, CancellationToken token) => + !ModelState.IsValid ? Invalid() : Answer(await _follows.Follow(User.GetUserId(), form, token)); + + [HttpPost, Route("/clientapi/unfollow")] + public async Task Unfollow(FollowForm form, CancellationToken token) => + !ModelState.IsValid ? Invalid() : Answer(await _follows.Unfollow(User.GetUserId(), form, token)); + + [HttpGet, Route("/clientapi/following")] + public async Task Following([FromQuery] string avatarId, CancellationToken token) => + Answer(await _follows.Followings(User.GetUserId(), avatarId, token)); + + IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."])); + + IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); + } +} diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs new file mode 100644 index 0000000..9906660 --- /dev/null +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -0,0 +1,235 @@ +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 + }; + } +} diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs new file mode 100644 index 0000000..cd862b4 --- /dev/null +++ b/PrivaPub/Domain/Social/Notifications.cs @@ -0,0 +1,32 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Models.Social; + +namespace PrivaPub.Domain.Social +{ + public static class Notifications + { + public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId, + CancellationToken token) + { + if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId) + return; + try + { + await DB.Default.SaveAsync(new Notification + { + AvatarId = avatarId, + Type = type, + FromAccountId = fromAccountId, + FromActorURI = fromActorUri, + PostId = postId, + DedupeKey = $"{type}|{avatarId}|{fromActorUri}|{postId}" + }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + } + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs new file mode 100644 index 0000000..7a971b0 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs @@ -0,0 +1,67 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + public class AcceptHandler : IActivityHandler + { + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + + public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors) + { + _dbEntities = dbEntities; + _localActors = localActors; + } + + public virtual string Type => "Accept"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); + if (following == default) + return; + await Apply(following, token); + } + + protected virtual Task Apply(Following following, CancellationToken token) => + DB.Default.Update().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + + public static async Task FindFollowing(JsonNode follow, ForeignAvatar target, DbEntities dbEntities, ILocalActorService localActors, + CancellationToken token) + { + if (follow is JsonObject && Value(follow, "type") is { } type && type != "Follow") + return default; + var followId = Id(follow); + var byId = followId == default + ? default + : await dbEntities.Followings.Match(f => f.FollowActivityURI == followId && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token); + if (byId != default || follow is not JsonObject) + return byId; + + var follower = await localActors.FindByUri(Id(follow["actor"]), token); + return follower == default + ? default + : await dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == target.ActorURI).ExecuteFirstAsync(token); + } + } + + public class RejectHandler : AcceptHandler + { + public RejectHandler(DbEntities dbEntities, ILocalActorService localActors) : base(dbEntities, localActors) + { + } + + public override string Type => "Reject"; + + protected override Task Apply(Following following, CancellationToken token) => DB.Default.DeleteAsync(following.ID); + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs index cde66d1..150a67e 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs @@ -1,6 +1,8 @@ using MongoDB.Entities; +using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; +using PrivaPub.Models.Social; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; @@ -60,6 +62,9 @@ namespace PrivaPub.Federation.Inbox.Handlers record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers; await DB.Default.SaveAsync(record, token); + if (target.Kind == LocalActorKind.Person) + await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest, + follower.ID, follower.ActorURI, default, token); if (!record.IsAccepted) return; diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index c79a0b5..141d25e 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -6,6 +6,7 @@ using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Jobs; using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.Models.User; namespace PrivaPub.Infrastructure.Data @@ -59,6 +60,28 @@ namespace PrivaPub.Infrastructure.Data .Key(j => j.FinishedAt, KeyType.Ascending) .Option(o => o.ExpireAfter = TimeSpan.FromDays(7)) .CreateAsync(token); + await DB.Default.Index() + .Key(f => f.AvatarId, KeyType.Ascending) + .Key(f => f.TargetActorURI, KeyType.Ascending) + .Option(o => o.Unique = true) + .CreateAsync(token); + await Plain(token, f => f.TargetActorURI, f => f.State); + await Plain(token, f => f.TargetAccountId, f => f.State); + await Plain(token, f => f.FollowActivityURI); + await DB.Default.Index() + .Key(e => e.AvatarId, KeyType.Ascending) + .Key(e => e.PostId, KeyType.Ascending) + .Option(o => o.Unique = true) + .CreateAsync(token); + await Plain(token, e => e.PostId); + await Unique(n => n.DedupeKey, Builders.Filter.Type(n => n.DedupeKey, BsonType.String), token); + await Plain(token, n => n.AvatarId, n => n.ID); + await DB.Default.Index() + .Key(f => f.AccountId, KeyType.Ascending) + .Key(f => f.PostId, KeyType.Ascending) + .Option(o => o.Unique = true) + .CreateAsync(token); + await Plain(token, f => f.ActivityURI); await Unique(b => b.Domain, Builders.Filter.Type(b => b.Domain, BsonType.String), token); await Unique(i => i.Host, Builders.Filter.Type(i => i.Host, BsonType.String), token); } diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 7a83904..f097727 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -17,6 +17,7 @@ using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Domain.Content; +using PrivaPub.Domain.Social; using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Jobs; using Microsoft.Extensions.Options; @@ -58,6 +59,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -118,6 +121,7 @@ namespace PrivaPub.Middleware .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddSingleton() .AddHttpContextAccessor() .AddMemoryCache() diff --git a/PrivaPub/Models/Social/Favourite.cs b/PrivaPub/Models/Social/Favourite.cs new file mode 100644 index 0000000..c5edcbd --- /dev/null +++ b/PrivaPub/Models/Social/Favourite.cs @@ -0,0 +1,13 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + public class Favourite : Entity + { + public string AccountId { get; set; }//Avatar.ID or ForeignAvatar.ID + public string ActorURI { get; set; } + public string PostId { get; set; } + public string ActivityURI { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/PrivaPub/Models/Social/Following.cs b/PrivaPub/Models/Social/Following.cs new file mode 100644 index 0000000..6f4ff89 --- /dev/null +++ b/PrivaPub/Models/Social/Following.cs @@ -0,0 +1,23 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + public class Following : Entity + { + public string AvatarId { get; set; }//the local follower + public string TargetActorURI { get; set; } + public string TargetAccountId { get; set; }//Avatar.ID, Group.ID or ForeignAvatar.ID + public bool TargetIsLocal { get; set; } + public string TargetInboxURL { get; set; } + public FollowState State { get; set; } + public string FollowActivityURI { get; set; } + public bool ShowReblogs { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public enum FollowState + { + Requested, + Accepted + } +} diff --git a/PrivaPub/Models/Social/Notification.cs b/PrivaPub/Models/Social/Notification.cs new file mode 100644 index 0000000..bfaace5 --- /dev/null +++ b/PrivaPub/Models/Social/Notification.cs @@ -0,0 +1,26 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + public class Notification : Entity + { + public string AvatarId { get; set; } + public NotificationType Type { get; set; } + public string FromAccountId { get; set; } + public string FromActorURI { get; set; } + public string PostId { get; set; } + public string DedupeKey { get; set; } + public bool IsRead { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public enum NotificationType + { + Mention, + Follow, + FollowRequest, + Favourite, + Reblog, + Update + } +} diff --git a/PrivaPub/Models/Social/TimelineEntry.cs b/PrivaPub/Models/Social/TimelineEntry.cs new file mode 100644 index 0000000..f57e073 --- /dev/null +++ b/PrivaPub/Models/Social/TimelineEntry.cs @@ -0,0 +1,12 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + public class TimelineEntry : Entity + { + public string AvatarId { get; set; } + public string PostId { get; set; }//the post shown: a reblog row's id for a reblog + public string AuthorAccountId { get; set; } + public string ReblogOfPostId { get; set; } + } +} diff --git a/PrivaPub/StaticServices/DbEntities.cs b/PrivaPub/StaticServices/DbEntities.cs index 6914884..9bc9fe0 100644 --- a/PrivaPub/StaticServices/DbEntities.cs +++ b/PrivaPub/StaticServices/DbEntities.cs @@ -5,6 +5,7 @@ using PrivaPub.Models.Data; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.Models.User; namespace PrivaPub.StaticServices @@ -32,5 +33,10 @@ namespace PrivaPub.StaticServices public Find Followers { get { return DB.Default.Find(); } } public Find Deliveries { get { return DB.Default.Find(); } } public Find InstanceActors { get { return DB.Default.Find(); } } + + public Find Followings { get { return DB.Default.Find(); } } + public Find TimelineEntries { get { return DB.Default.Find(); } } + public Find Notifications { get { return DB.Default.Find(); } } + public Find Favourites { get { return DB.Default.Find(); } } } }