With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
bot, discoverable, indexable, hide collections, posting defaults; the
change federates as Update{Person}), get, lookup, statuses (paged, by
visibility to the viewer), relationships, search, follow and unfollow,
follow requests (authorize and reject answer remote followers with
Accept or Reject), remove from followers. Followers and following lists
are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
delete returning the source for redrafting, context, history, source,
favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.
Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.
Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.
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.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
|
|
};
|
|
}
|
|
}
|