diff --git a/FEDERATION.md b/FEDERATION.md index 4c7c597..56d987f 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -224,6 +224,9 @@ Posts with a location (shown to nearby users of this server) never leave the ser - **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin. +- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days, the + same activity each time: 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 holds the follow already answers the copy. - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three redirects and read at most 1 MB. - **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index 20c11f6..84348b6 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -42,6 +42,41 @@ namespace PrivaPub.Tests.Federation PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); + // a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a + // follow it took before it started sending to us, and the community's posts were refused as not followed) + [Fact] + public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var quiet = new RemoteActor(_harness.Peer, "quiet"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token); + var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token); + async Task Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue() == "Follow"); + var made = following.CreatedAt; + Assert.Equal(1, await Follows()); + + // (the store is shared with other tests' requests: what this one got is counted on its own inbox) + async Task After(TimeSpan wait) + { + var before = await Follows(); + await _harness.Follows.ResendPending(made + wait, token); + return await Follows() - before; + } + Assert.Equal(0, await After(TimeSpan.FromMinutes(10))); + Assert.Equal(1, await After(TimeSpan.FromMinutes(16))); + Assert.Equal(0, await After(TimeSpan.FromMinutes(30))); + Assert.Equal(1, await After(TimeSpan.FromMinutes(77))); + Assert.Equal(3, await Follows()); + Assert.All(await _harness.Outgoing(quiet.Id + "/inbox"), a => Assert.Equal(following.FollowActivityURI, a["id"]!.GetValue())); + + // a request older than the count is sent again too; an answered one never + await DB.Default.Update().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token); + Assert.Equal(1, await After(TimeSpan.FromDays(1))); + await DB.Default.Update().MatchID(following.ID).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + Assert.Equal(0, await After(TimeSpan.FromDays(30))); + } + // Pixelfed names our post by its page (/@name/, the post's url) in its Like, Announce and their Undo: the post is // found the same as by its id [Fact] diff --git a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs index 0045830..19d7bc4 100644 --- a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs +++ b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs @@ -23,7 +23,8 @@ namespace PrivaPub.Tests.Support.Host public const string ClientHeader = "X-Test-Client"; static readonly SemaphoreSlim Boot = new(1, 1); - static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule) }; + static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner), typeof(StatisticsSchedule), + typeof(PrivaPub.Domain.Social.FollowResender) }; static PrivaPubHost _shared; readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}"); diff --git a/PrivaPub/Domain/Social/FollowResender.cs b/PrivaPub/Domain/Social/FollowResender.cs new file mode 100644 index 0000000..7429ddf --- /dev/null +++ b/PrivaPub/Domain/Social/FollowResender.cs @@ -0,0 +1,27 @@ +namespace PrivaPub.Domain.Social +{ + // sends again, now and then, the follow requests still unanswered (FollowService.ResendPending) + public sealed class FollowResender(IServiceScopeFactory scopes, ILogger logger) : BackgroundService + { + static readonly TimeSpan Every = TimeSpan.FromMinutes(15); + + protected override async Task ExecuteAsync(CancellationToken token) + { + using var timer = new PeriodicTimer(Every); + while (await timer.WaitForNextTickAsync(token)) + { + try + { + using var scope = scopes.CreateScope(); + var sent = await scope.ServiceProvider.GetRequiredService().ResendPending(DateTime.UtcNow, token); + if (sent > 0) + logger.LogInformation("Sent {Count} unanswered follow requests again", sent); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Unanswered follow requests could not be sent again"); + } + } + } + } +} diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index 1b587ee..a107b92 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -29,6 +29,7 @@ namespace PrivaPub.Domain.Social Task FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token); Task UnfollowAs(LocalActor follower, string target, CancellationToken token); Task Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token); + Task ResendPending(DateTime now, CancellationToken token); } public class FollowService : IFollowService @@ -276,6 +277,37 @@ namespace PrivaPub.Domain.Social 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 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)) + { + await _delivery.Enqueue(follower, new[] { inbox }, FollowActivity(follower, following), token, + again: "resend-" + now.ToString("yyyyMMddHHmm", System.Globalization.CultureInfo.InvariantCulture)); + sent++; + } + await DB.Default.Update().MatchID(following.ID).Modify(b => b.Inc(f => f.Resent, 1)).Modify(f => f.ResentAt, now) + .ExecuteAsync(token); + } + return sent; + } + static JsonObject FollowActivity(LocalActor follower, Following following) => new() { ["@context"] = ActivityPubRenderer.ActivityStreams, diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index f6a518a..ff3bdfa 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -113,7 +113,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() - .AddHostedService(); + .AddHostedService() + .AddHostedService(); } public static IServiceCollection PrivaPubStatisticsConfiguration(this IServiceCollection service, IConfiguration configuration) { diff --git a/PrivaPub/Models/Social/Following.cs b/PrivaPub/Models/Social/Following.cs index 6f4ff89..80274b7 100644 --- a/PrivaPub/Models/Social/Following.cs +++ b/PrivaPub/Models/Social/Following.cs @@ -13,6 +13,8 @@ namespace PrivaPub.Models.Social public string FollowActivityURI { get; set; } public bool ShowReblogs { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public int Resent { get; set; }//how many times a request still unanswered was sent again (FollowService.ResendPending) + public DateTime? ResentAt { get; set; } } public enum FollowState