Everything on, phase 4b: ids that resolve, a hashtag page, grouped notifications, remote accounts' real counts
- A community's announces resolve, as FEDERATION.md and ROADMAP always said Announce ids do. GroupDistributor keeps
each one it sends (GroupAnnouncement, unique by id). /grunts serves it while the post it carries is shown and 410
after, and also serves the announce-{postId} ids the group outbox lists, which now keep a stable `published`
instead of the time of the fetch.
- A persona's boost points at the boosted post: its `url` in the Mastodon API is the boosted post's page, and a browser
following the boost's id is redirected there instead of getting JSON.
- /tags/{tag}, where every Hashtag link we send points, is now a public page of this server's public posts with that
tag, with the same strict CSP and noindex as the profile pages.
- Grouped notifications (/api/v2/notifications, its unread count, a group, its accounts and dismiss). We advertise
api_versions.mastodon = 7 so clients show quotes, and clients that trust it call these; they answered 404. Likes and
boosts of one post group together, as do follows within an hour. The version string stays 4.2.0 until streaming
and Web Push exist.
- A remote account's follower, following and post counts are what its server publishes. AccountCountsJob reads its
collections' totalItems from its own origin, signed by the instance actor, at most daily and only after the account
was fetched, never when someone looks. They used to be 0.
- The other ids that do not resolve are documented as such: Update, Delete, EmojiReact, QuoteRequest and its answers,
Flag, Ignore and poll votes.
676 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
8f25bf056d
commit
e2f61ede56
18 files changed
+452
-17
No files matched your search
@@ -0,0 +1,60 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
|
||||
// followers, following and outbox collections). Read when the account is fetched, at most once a day, never when
|
||||
// someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and GoToSocial do the same.
|
||||
public class AccountCountsJob : IJobHandler
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
public int Concurrency => 2;
|
||||
public int MaxAttempts => 2;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static string DedupeKey(string actorUri, DateTime day) => $"count|{actorUri}|{day:yyyyMMdd}";
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token))
|
||||
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token))
|
||||
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
// only from the actor's own server, as everything we believe about it
|
||||
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
||||
return default;
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0
|
||||
? count
|
||||
: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,12 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOptionsMonitor<FederationOptions> _options;
|
||||
|
||||
readonly Infrastructure.Jobs.IJobQueue _jobs;
|
||||
|
||||
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities,
|
||||
IOptionsMonitor<FederationOptions> options = default)
|
||||
IOptionsMonitor<FederationOptions> options = default, Infrastructure.Jobs.IJobQueue jobs = default)
|
||||
{
|
||||
_jobs = jobs;
|
||||
_http = http;
|
||||
_localActors = localActors;
|
||||
_cache = cache;
|
||||
@@ -89,7 +92,16 @@ namespace PrivaPub.Federation.Actors
|
||||
return cached;
|
||||
|
||||
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
|
||||
return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token);
|
||||
return await Counted(await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token), token);
|
||||
}
|
||||
|
||||
// its counts are read once a day, after it was fetched (AccountCountsJob)
|
||||
async Task<ForeignAvatar> Counted(ForeignAvatar stored, CancellationToken token)
|
||||
{
|
||||
if (_jobs != default && stored is { DeletionAt: null } && Uri.TryCreate(stored.ActorURI, UriKind.Absolute, out var uri))
|
||||
await _jobs.Enqueue(Models.Jobs.JobKind.CountAccount, stored.ActorURI, uri.Host.ToLowerInvariant(),
|
||||
AccountCountsJob.DedupeKey(stored.ActorURI, DateTime.UtcNow), token);
|
||||
return stored;
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
|
||||
@@ -121,7 +133,7 @@ namespace PrivaPub.Federation.Actors
|
||||
var key = actor?.Key(keyId);
|
||||
if (key == default)
|
||||
return default;
|
||||
return await Upsert(actor, key, token);
|
||||
return await Counted(await Upsert(actor, key, token), token);
|
||||
}
|
||||
|
||||
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
|
||||
|
||||
@@ -101,7 +101,7 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
if (local.Kind == LocalActorKind.Group)
|
||||
{
|
||||
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
|
||||
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}", post.CreationDate));
|
||||
continue;
|
||||
}
|
||||
if (post.ReblogOfPostId != default)
|
||||
@@ -258,8 +258,12 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
var booster = await _localActors.FindByUserName(actor, token);
|
||||
var uri = booster?.ActivityUri(activityId);
|
||||
if (booster is { Kind: LocalActorKind.Group, IsCircle: false })
|
||||
return await GroupAnnounce(booster, activityId, uri, token);
|
||||
var reblog = uri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == uri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var rendered = reblog == default ? default : await AnnounceFor(reblog, token);
|
||||
if (rendered != default && WantsHtml() && await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token) is { } boosted)
|
||||
return Redirect(boosted.Url ?? boosted.ObjectURI);//a boost has no page of its own: the boosted post's
|
||||
return rendered == default ? NotFound() : Activity(rendered);
|
||||
}
|
||||
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
|
||||
@@ -386,6 +390,24 @@ namespace PrivaPub.Federation.Controllers
|
||||
return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone };
|
||||
}
|
||||
|
||||
// A community's announce: one GroupDistributor sent (kept as sent), or one its outbox lists (announce-{postId}),
|
||||
// while the post it is about is still shown; 410 once that post is gone.
|
||||
async Task<IActionResult> GroupAnnounce(LocalActor group, string activityId, string uri, CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.Find<GroupAnnouncement>().Match(a => a.ActivityURI == uri && a.GroupId == group.Id).ExecuteFirstAsync(token) is { } kept)
|
||||
{
|
||||
var about = await _dbEntities.Posts.Match(p => p.ObjectURI == kept.ObjectURI).ExecuteFirstAsync(token);
|
||||
if (!VisibilityPolicy.Shown(about) || about.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return StatusCode(StatusCodes.Status410Gone);
|
||||
var body = JsonNode.Parse(kept.Body)!.AsObject();
|
||||
body["@context"] = ActivityPubRenderer.Context();
|
||||
return Activity(body);
|
||||
}
|
||||
var postId = activityId["announce-".Length..];
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupId == group.Id).Match(VisibilityPolicy.IsPublic).ExecuteFirstAsync(token);
|
||||
return post == default ? NotFound() : Activity(ActivityPubRenderer.Announce(group, post.ObjectURI, activityId, post.CreationDate));
|
||||
}
|
||||
|
||||
async Task<JsonObject> AnnounceFor(PostEntity reblog, CancellationToken token)
|
||||
{
|
||||
var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -14,6 +17,26 @@ namespace PrivaPub.Federation.Outbox
|
||||
|
||||
public class GroupDistributor : IGroupDistributor
|
||||
{
|
||||
// so the announce's id resolves (PeasantsController.Grunt); a repeat of the same announce keeps the first
|
||||
static async Task Keep(LocalActor group, JsonObject announce, string objectUri, CancellationToken token)
|
||||
{
|
||||
var body = (JsonObject)announce.DeepClone();
|
||||
body.Remove("@context");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new GroupAnnouncement
|
||||
{
|
||||
GroupId = group.Id,
|
||||
ActivityURI = body["id"]!.GetValue<string>(),
|
||||
ObjectURI = objectUri,
|
||||
Body = body.ToJsonString()
|
||||
}, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public GroupDistributor(IDeliveryService delivery)
|
||||
@@ -43,8 +66,13 @@ namespace PrivaPub.Federation.Outbox
|
||||
// Every follower, the author's own server included: Lemmy 1.0 keeps its user's post pending until the community
|
||||
// announces it back, and clears that before it answers the echo 400 ("Object is not remote").
|
||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
||||
await Keep(group, announce, objectUri, token);
|
||||
if (isNewPost && !string.IsNullOrEmpty(objectUri))
|
||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
|
||||
{
|
||||
var boost = ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}");
|
||||
await _delivery.EnqueueToFollowers(group, boost, token);
|
||||
await Keep(group, boost, objectUri, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,13 +357,13 @@ namespace PrivaPub.Federation.Rendering
|
||||
["object"] = note
|
||||
};
|
||||
|
||||
public static JsonObject Announce(LocalActor group, string objectUri, string activityId) => new()
|
||||
public static JsonObject Announce(LocalActor group, string objectUri, string activityId, DateTime? published = default) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = group.ActivityUri(activityId),
|
||||
["type"] = "Announce",
|
||||
["actor"] = group.Uri,
|
||||
["published"] = Timestamp(DateTime.UtcNow),
|
||||
["published"] = Timestamp(published ?? DateTime.UtcNow),
|
||||
["to"] = new JsonArray(Public),
|
||||
["cc"] = new JsonArray(group.Followers),
|
||||
["object"] = objectUri
|
||||
|
||||
Reference in new issue
Block a user