From 61f6ca067645eb223178a6d63df2d7d6fe24a97a Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 11:32:33 +0200 Subject: [PATCH] Personas can follow: locally at once, remotely by Follow and Accept Following records what a local persona follows, local or remote, with its state and the Follow activity's id. FollowService (behind /clientapi/follow, /clientapi/unfollow and /clientapi/following): - resolves @user, user@host or an actor URI; - a local account is followed in-process: accepted unless it approves followers by hand, a Follower row on the other side, a community gains the persona as a member, and a Follow or FollowRequest notification; - a remote account gets a Follow signed by the persona, at /grunts/follow-{id}, and stays Requested until an Accept; - unfollowing deletes the rows, or sends Undo{Follow} to a remote account. Inbound Accept and Reject are matched to the Follow by its id (or, for an embedded Follow without one, by its actor), and only from the account that was followed. A remote Follow now notifies the persona too. Notification is the per-persona record P1.2 builds on, deduplicated by type, persona, sender and post. TimelineEntry and Favourite are created with their indexes for the next commits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- PrivaPub.ClientModels/Social/FollowForm.cs | 37 +++ PrivaPub.Tests/Domain/FollowTests.cs | 124 +++++++++ PrivaPub.Tests/Support/Harness.cs | 98 ++++++++ .../ClientToServer/SocialController.cs | 43 ++++ PrivaPub/Domain/Social/FollowService.cs | 235 ++++++++++++++++++ PrivaPub/Domain/Social/Notifications.cs | 32 +++ .../Inbox/Handlers/AcceptHandler.cs | 67 +++++ .../Inbox/Handlers/FollowHandler.cs | 5 + PrivaPub/Infrastructure/Data/Indexes.cs | 23 ++ .../Middleware/SocialPubConfigurations.cs | 4 + PrivaPub/Models/Social/Favourite.cs | 13 + PrivaPub/Models/Social/Following.cs | 23 ++ PrivaPub/Models/Social/Notification.cs | 26 ++ PrivaPub/Models/Social/TimelineEntry.cs | 12 + PrivaPub/StaticServices/DbEntities.cs | 6 + 15 files changed, 748 insertions(+) create mode 100644 PrivaPub.ClientModels/Social/FollowForm.cs create mode 100644 PrivaPub.Tests/Domain/FollowTests.cs create mode 100644 PrivaPub.Tests/Support/Harness.cs create mode 100644 PrivaPub/Controllers/ClientToServer/SocialController.cs create mode 100644 PrivaPub/Domain/Social/FollowService.cs create mode 100644 PrivaPub/Domain/Social/Notifications.cs create mode 100644 PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs create mode 100644 PrivaPub/Models/Social/Favourite.cs create mode 100644 PrivaPub/Models/Social/Following.cs create mode 100644 PrivaPub/Models/Social/Notification.cs create mode 100644 PrivaPub/Models/Social/TimelineEntry.cs 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(); } } } }