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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:32:33 +02:00
1 parent 8af50dad9c
commit 61f6ca0676
15 files changed
+748

No files matched your search

@@ -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<GenericRes> localizer)
{
_follows = follows;
_localizer = localizer;
}
[HttpPost, Route("/clientapi/follow")]
public async Task<IActionResult> Follow(FollowForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _follows.Follow(User.GetUserId(), form, token));
[HttpPost, Route("/clientapi/unfollow")]
public async Task<IActionResult> Unfollow(FollowForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _follows.Unfollow(User.GetUserId(), form, token));
[HttpGet, Route("/clientapi/following")]
public async Task<IActionResult> 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);
}
}
+235
View File
@@ -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<WebResult> Follow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> Unfollow(string rootUserId, FollowForm form, CancellationToken token);
Task<WebResult> 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<GenericRes> _localizer;
readonly ILogger<FollowService> _logger;
public FollowService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer, ILogger<FollowService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> 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<WebResult> 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>(following.ID);
if (following.TargetIsLocal)
{
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
await DB.Default.Update<GroupEntity>().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<WebResult> 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<Follower>()
.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<GroupEntity>()
.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<LocalActor> 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
};
}
}
+32
View File
@@ -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)
{
}
}
}
}
@@ -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<Following>().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
public static async Task<Following> 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>(following.ID);
}
}
@@ -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;
+23
View File
@@ -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<Following>()
.Key(f => f.AvatarId, KeyType.Ascending)
.Key(f => f.TargetActorURI, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<Following>(token, f => f.TargetActorURI, f => f.State);
await Plain<Following>(token, f => f.TargetAccountId, f => f.State);
await Plain<Following>(token, f => f.FollowActivityURI);
await DB.Default.Index<TimelineEntry>()
.Key(e => e.AvatarId, KeyType.Ascending)
.Key(e => e.PostId, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<TimelineEntry>(token, e => e.PostId);
await Unique<Notification>(n => n.DedupeKey, Builders<Notification>.Filter.Type(n => n.DedupeKey, BsonType.String), token);
await Plain<Notification>(token, n => n.AvatarId, n => n.ID);
await DB.Default.Index<Favourite>()
.Key(f => f.AccountId, KeyType.Ascending)
.Key(f => f.PostId, KeyType.Ascending)
.Option(o => o.Unique = true)
.CreateAsync(token);
await Plain<Favourite>(token, f => f.ActivityURI);
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
}
@@ -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<IDeliveryService, DeliveryService>()
.AddSingleton<IInboxReceiver, InboxReceiver>()
.AddSingleton<IActivityHandler, FollowHandler>()
.AddSingleton<IActivityHandler, AcceptHandler>()
.AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
@@ -118,6 +121,7 @@ namespace PrivaPub.Middleware
.AddTransient<IPrivateAvatarUsersService, PrivateAvatarUsersService>()
.AddTransient<IGroupUsersService, GroupUsersService>()
.AddTransient<IPostsService, PostsService>()
.AddTransient<IFollowService, FollowService>()
.AddSingleton<AppConfigurationService>()
.AddHttpContextAccessor()
.AddMemoryCache()
+13
View File
@@ -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;
}
}
+23
View File
@@ -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
}
}
+26
View File
@@ -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
}
}
+12
View File
@@ -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; }
}
}
+6
View File
@@ -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<Follower> Followers { get { return DB.Default.Find<Follower>(); } }
public Find<Delivery> Deliveries { get { return DB.Default.Find<Delivery>(); } }
public Find<InstanceActor> InstanceActors { get { return DB.Default.Find<InstanceActor>(); } }
public Find<Following> Followings { get { return DB.Default.Find<Following>(); } }
public Find<TimelineEntry> TimelineEntries { get { return DB.Default.Find<TimelineEntry>(); } }
public Find<Notification> Notifications { get { return DB.Default.Find<Notification>(); } }
public Find<Favourite> Favourites { get { return DB.Default.Find<Favourite>(); } }
}
}