Communities: - a post addressed to a community (to, cc or audience) is accepted according to its posting policy - followers, anyone, or moderators - and GroupDistributor announces the whole activity with `audience` to the community's followers, plus the object for new posts so Mastodon shows them; updates and deletes of community content are announced too; - top-level posts are Pages with a name (the title, or a headline from the text); /flock counts members, /wardens lists moderators; - a Mastodon client posts into a community by mentioning it, or into a remote group, which sets `audience`; - an Announce of an activity from a remote group a persona follows (Lemmy) is followed through: the object is fetched from its own origin, kept with its AudienceURI, and fanned out to the group's local followers; updates are applied in place and deletes checked against the origin. Circles stop being local-only: an undiscoverable Group actor whose follows are all requests the owner approves; posts addressed to the circle and its /flock and delivered to members' own inboxes, never announced, never public; a remote member's post into the circle is accepted from members only. SignedFetchAuthorizer serves circle posts and collections only to a signed request from a member or a member server's instance actor - 404 for anyone else. Circles never surface in search, lookups, mentions, account ids or profile pages. Federation:SecureMode requires a valid signature on every GET under /peasants except the instance actor. Group forms take a posting policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
300 lines
12 KiB
C#
300 lines
12 KiB
C#
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);
|
|
Task<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token);
|
|
Task UnfollowAs(LocalActor follower, string target, CancellationToken token);
|
|
Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, 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 following = await FollowAs(follower, form.Target, true, token);
|
|
if (following == default)
|
|
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
|
|
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);
|
|
await UnfollowAs(follower, form.Target, 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<Following> FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token)
|
|
{
|
|
var (local, remote) = await ResolveTarget(target, token);
|
|
if (local == default && remote == default)
|
|
return default;
|
|
if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application))
|
|
return default;
|
|
|
|
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)
|
|
{
|
|
if (existing.ShowReblogs != showReblogs)
|
|
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
|
|
existing.ShowReblogs = showReblogs;
|
|
return existing;
|
|
}
|
|
|
|
var following = new Following
|
|
{
|
|
AvatarId = follower.Id,
|
|
TargetActorURI = targetUri,
|
|
TargetAccountId = local?.Id ?? remote.ID,
|
|
TargetIsLocal = local != default,
|
|
TargetInboxURL = local?.Inbox ?? remote.InboxURL,
|
|
ShowReblogs = showReblogs,
|
|
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)
|
|
{
|
|
return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
|
}
|
|
|
|
if (local != default)
|
|
await FollowLocally(follower, local, following, token);
|
|
else
|
|
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token);
|
|
return following;
|
|
}
|
|
|
|
public async Task UnfollowAs(LocalActor follower, string target, CancellationToken token)
|
|
{
|
|
var (local, remote) = await ResolveTarget(target, token);
|
|
var targetUri = local?.Uri ?? remote?.ActorURI ?? target;
|
|
var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
|
if (following == default)
|
|
return;
|
|
|
|
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);
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
|
|
public async Task<bool> Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token)
|
|
{
|
|
var localFollower = await _localActors.FindById(LocalActorKind.Person, followerAccountId, token);
|
|
var remoteFollower = localFollower == default ? await _dbEntities.ForeignAvatars.MatchID(followerAccountId).ExecuteFirstAsync(token) : default;
|
|
var followerUri = localFollower?.Uri ?? remoteFollower?.ActorURI;
|
|
if (followerUri == default)
|
|
return false;
|
|
var request = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.LocalActorKind == me.Kind && f.ActorURI == followerUri && !f.IsAccepted)
|
|
.ExecuteFirstAsync(token);
|
|
if (request == default)
|
|
return false;
|
|
|
|
if (accept)
|
|
await DB.Default.Update<Follower>().MatchID(request.ID).Modify(f => f.IsAccepted, true).ExecuteAsync(token);
|
|
else
|
|
await DB.Default.DeleteAsync<Follower>(request.ID);
|
|
|
|
if (localFollower != default)
|
|
{
|
|
if (accept)
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri)
|
|
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
|
else
|
|
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri);
|
|
}
|
|
else
|
|
{
|
|
var follow = new JsonObject
|
|
{
|
|
["id"] = request.FollowActivityURI,
|
|
["type"] = "Follow",
|
|
["actor"] = followerUri,
|
|
["object"] = me.Uri
|
|
};
|
|
var answer = accept
|
|
? ActivityPubRenderer.Accept(me, follow, $"accept-{request.ID}-{DateTime.UtcNow.Ticks}")
|
|
: new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
|
["id"] = me.ActivityUri($"reject-{request.ID}-{DateTime.UtcNow.Ticks}"),
|
|
["type"] = "Reject",
|
|
["actor"] = me.Uri,
|
|
["object"] = follow
|
|
};
|
|
await _delivery.Enqueue(me, new[] { request.InboxURL }, answer, token);
|
|
}
|
|
if (accept)
|
|
await Notifications.Add(me.Id, NotificationType.Follow, followerAccountId, followerUri, default, token);
|
|
return true;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|