A root exports one of its personas (a job) as Mastodon's account archive, so other servers' importers read it: the actor with its public key only, its own posts and boosts with their media, likes, bookmarks and Mastodon's CSV files, plus PrivaPub's filters, followed hashtags, notification policy, pins, scheduled and located posts; nothing of its root, its siblings, its keys or anyone's token. A ticket link downloads it, for a week. An archive (PrivaPub's or Mastodon's) uploaded in pieces is imported into a persona in a job, the parts the root picks, with progress and a stop. SafeArchive refuses links, escaping paths, duplicates, bombs and oversized items, and reads the outbox one item at a time. Imported posts are delivered to no one, put in no home and notify nobody, yet show on the profile, outbox, hashtags and search; back home a post keeps its id, from another actor it gets one of its date and ImportedFromURI, so importing twice changes nothing. Relationships go through the existing services; located, scheduled and likes only when asked; followers never. tools/pasture/scenarios/persona-archive.sh imports mastouser's real Mastodon archive (156 posts, 24 pictures) into a persona Mastodon follows: Mastodon receives none of it, and a second import changes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
352 lines
15 KiB
C#
352 lines
15 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);
|
|
Task<int> ResendPending(DateTime now, CancellationToken token);
|
|
/// <summary>An account named by its address or its handle (user@host): a persona here, or one elsewhere.</summary>
|
|
Task<(LocalActor Local, ForeignAvatar Remote)> Resolve(string target, 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.IsServerActor))
|
|
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;
|
|
// a request still unanswered is sent again, at most once an hour: a server can take a Follow (202) and
|
|
// drop it later, as one that cannot yet read our actor does; one that holds it already ignores the copy
|
|
if (existing.State == FollowState.Requested && remote != default)
|
|
await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, existing), token,
|
|
again: "again-" + DateTime.UtcNow.ToString("yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture));
|
|
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);
|
|
}
|
|
//a new follow starts in no list, whatever lists the account was in before a follow that ended without passing here
|
|
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
|
|
|
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);
|
|
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
|
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);
|
|
}
|
|
|
|
public Task<(LocalActor Local, ForeignAvatar Remote)> Resolve(string target, CancellationToken token) => ResolveTarget(target, 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));
|
|
}
|
|
|
|
// how long after it was made, or last sent again, a request still unanswered is sent again: a server can take a Follow and
|
|
// lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started sending there), and one that
|
|
// already holds the follow answers the copy again
|
|
public static readonly TimeSpan[] ResendAfter =
|
|
{
|
|
TimeSpan.FromMinutes(15), TimeSpan.FromHours(1), TimeSpan.FromHours(6), TimeSpan.FromDays(1), TimeSpan.FromDays(2), TimeSpan.FromDays(4)
|
|
};
|
|
|
|
// the requests to remote accounts due to be sent again; how many were
|
|
public async Task<int> ResendPending(DateTime now, CancellationToken token)
|
|
{
|
|
var sent = 0;
|
|
// (a request older than the count has no Resent yet: "not at least" matches it, "less than" would not)
|
|
var pending = await _dbEntities.Followings.Match(f => f.State == FollowState.Requested && !f.TargetIsLocal && !(f.Resent >= ResendAfter.Length))
|
|
.ExecuteAsync(token);
|
|
foreach (var following in pending.Where(f => (f.ResentAt ?? f.CreatedAt) + ResendAfter[f.Resent] <= now))
|
|
{
|
|
var follower = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token);
|
|
var inbox = following.TargetInboxURL ?? (await _remoteActors.GetActor(following.TargetActorURI, refresh: false, token))?.InboxURL;
|
|
if (follower != default && !string.IsNullOrEmpty(inbox))
|
|
{
|
|
// under a new id each time: Lemmy keeps the ids it received and never answers one again
|
|
var again = FollowActivity(follower, following);
|
|
again["id"] = AgainId(following.FollowActivityURI, following.Resent + 1);
|
|
await _delivery.Enqueue(follower, new[] { inbox }, again, token,
|
|
again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture));
|
|
sent++;
|
|
}
|
|
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Inc(f => f.Resent, 1)).Modify(f => f.ResentAt, now)
|
|
.ExecuteAsync(token);
|
|
}
|
|
return sent;
|
|
}
|
|
|
|
// a follow sent again is the same follow under a new id, which an answer may name (AcceptHandler.FindFollowing)
|
|
public const string Again = "-again-";
|
|
|
|
public static string AgainId(string followActivityUri, int resend) => $"{followActivityUri}{Again}{resend}";
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|