P7: downvotes go out, and feeds are read through the server's reader
Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
3b3f1f6b93
commit
6fccb6fe35
32 files changed
+577
-45
No files matched your search
@@ -19,16 +19,18 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
public const int MaxFeedCommunities = 50;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default,
|
||||
ILocalActorService localActors = default)
|
||||
ILocalActorService localActors = default, IJobQueue jobs = default)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
_featured = featured;
|
||||
_localActors = localActors;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
@@ -44,19 +46,24 @@ namespace PrivaPub.Federation.Actors
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
var feed = actor.AvatarType == AvatarType.Feed;
|
||||
var (following, communities) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var (following, communities, listed) = await Read(actor, actor.FollowingURL, feed, token);
|
||||
var update = DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total)
|
||||
.Modify(a => a.FollowingCount, following)
|
||||
.Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total)
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow);
|
||||
if (feed)
|
||||
// (a feed whose list could not be read keeps the one it had: the reader of feeds would unfollow them all)
|
||||
if (feed && listed)
|
||||
update = update.Modify(a => a.FeedCommunities, communities);
|
||||
await update.ExecuteAsync(token);
|
||||
if (feed)
|
||||
if (feed && listed)
|
||||
{
|
||||
foreach (var community in communities)
|
||||
if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal))
|
||||
await _remoteActors.GetActor(community, refresh: false, token);
|
||||
if (_jobs != default && await DB.Default.Find<Models.Social.FeedSubscription>().Match(s => s.FeedActorURI == actor.ActorURI).ExecuteAnyAsync(token))
|
||||
await Domain.Social.FeedFollows.Sync(_jobs, token);
|
||||
}
|
||||
if (_featured != default)
|
||||
await _featured.Sync(actor, token);
|
||||
return JobOutcome.Done;
|
||||
@@ -64,31 +71,33 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
// only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked
|
||||
// (inline, or on its first page)
|
||||
async Task<(int? Total, List<string> Items)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
|
||||
async Task<(int? Total, List<string> Items, bool Listed)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token)
|
||||
{
|
||||
var listed = new List<string>();
|
||||
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
||||
return (default, listed);
|
||||
return (default, listed, false);
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return (default, listed);
|
||||
return (default, listed, false);
|
||||
int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default;
|
||||
if (!items)
|
||||
return (total, listed);
|
||||
return (total, listed, true);
|
||||
var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText());
|
||||
var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"];
|
||||
if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI))
|
||||
{
|
||||
using var next = await _remoteActors.FetchObject(first, token);
|
||||
var nextPage = next == default ? default : System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
|
||||
if (next == default)
|
||||
return (total, listed, false);
|
||||
var nextPage = System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText());
|
||||
page = nextPage?["orderedItems"] ?? nextPage?["items"];
|
||||
}
|
||||
if (page is System.Text.Json.Nodes.JsonArray array)
|
||||
listed = array.Select(ActivityJson.Id)
|
||||
.Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http")
|
||||
.Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList();
|
||||
return (total, listed);
|
||||
return (total, listed, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Wall => $"{Uri}/graffiti";
|
||||
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
|
||||
// the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts
|
||||
public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter;
|
||||
public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter or LocalActorKind.Reader;
|
||||
public string Flock => $"{Uri}/flock";
|
||||
public string Wardens => $"{Uri}/wardens";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
@@ -74,6 +74,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Task<LocalActor> FindByAddress(string address, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
Task<LocalActor> GetReporterActor(CancellationToken token);
|
||||
Task<LocalActor> GetReaderActor(CancellationToken token);
|
||||
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
||||
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
||||
LocalActor FromAvatar(Avatar avatar);
|
||||
@@ -89,13 +90,17 @@ namespace PrivaPub.Federation.Actors
|
||||
// the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application
|
||||
// (owner decision 2026-10-06): one actor for the server, never one per persona
|
||||
public const string ReporterUserName = "privapub_reports";
|
||||
// the anonymous Service that follows the communities of the feeds personas read, so no community learns which persona
|
||||
// reads it (owner decision 2026-10-07); a Service, since Lemmy takes a follow from no Application
|
||||
public const string ReaderUserName = "privapub_feeds";
|
||||
|
||||
public static bool IsServerActorName(string userName) =>
|
||||
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(userName, ReaderUserName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
|
||||
{
|
||||
InstanceUserName, ReporterUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
||||
InstanceUserName, ReporterUserName, ReaderUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
||||
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
|
||||
};
|
||||
|
||||
@@ -103,6 +108,7 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
InstanceActor _instanceActor;
|
||||
ReporterActor _reporterActor;
|
||||
ReaderActor _readerActor;
|
||||
|
||||
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
||||
{
|
||||
@@ -121,6 +127,8 @@ namespace PrivaPub.Federation.Actors
|
||||
return await GetInstanceActor(token);
|
||||
if (userName == ReporterUserName)
|
||||
return await GetReporterActor(token);
|
||||
if (userName == ReaderUserName)
|
||||
return await GetReaderActor(token);
|
||||
|
||||
var avatar = await _dbEntities.Avatars
|
||||
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
|
||||
@@ -158,6 +166,8 @@ namespace PrivaPub.Federation.Actors
|
||||
return group == default ? default : FromGroup(group);
|
||||
case LocalActorKind.Reporter:
|
||||
return await GetReporterActor(token);
|
||||
case LocalActorKind.Reader:
|
||||
return await GetReaderActor(token);
|
||||
default:
|
||||
return await GetInstanceActor(token);
|
||||
}
|
||||
@@ -221,6 +231,36 @@ namespace PrivaPub.Federation.Actors
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LocalActor> GetReaderActor(CancellationToken token)
|
||||
{
|
||||
var reader = _readerActor ??= await LoadReaderActor(token);
|
||||
|
||||
return new LocalActor
|
||||
{
|
||||
Id = reader.ID,
|
||||
Kind = LocalActorKind.Reader,
|
||||
UserName = ReaderUserName,
|
||||
Name = $"Feeds read on {new Uri(BaseAddress).Host}",
|
||||
Summary = "It follows the communities in the feeds this PrivaPub server's people read; it never names who reads them.",
|
||||
PrivateKeyPem = reader.PrivateKey,
|
||||
PublicKeyPem = reader.PublicKey,
|
||||
Discoverable = false,
|
||||
Published = reader.CreationDate,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
}
|
||||
|
||||
async Task<ReaderActor> LoadReaderActor(CancellationToken token)
|
||||
{
|
||||
var reader = await _dbEntities.ReaderActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
||||
if (reader != default)
|
||||
return reader;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
reader = new ReaderActor { PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(reader, token);
|
||||
return reader;
|
||||
}
|
||||
|
||||
async Task<ReporterActor> LoadReporterActor(CancellationToken token)
|
||||
{
|
||||
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
||||
|
||||
@@ -532,8 +532,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
if (HttpMethods.IsGet(Request.Method))
|
||||
Response.Headers.Vary = "Accept";
|
||||
// SecureMode asks every reader of ActivityPub documents for a signature, except for the server's own actors (the
|
||||
// instance actor, the reporter), whose keys peers need first, and except for browsers, which only get redirected to
|
||||
// the public pages
|
||||
// instance actor, the reporter, the reader of feeds), whose keys peers need first, and except for browsers, which
|
||||
// only get redirected to the public pages
|
||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
|
||||
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
|
||||
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
LocalActorKind.Group when !local.IsCircle => "group",
|
||||
LocalActorKind.Application => "application",
|
||||
LocalActorKind.Reporter => "reporter",
|
||||
LocalActorKind.Reader => "reader",
|
||||
_ => default
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ namespace PrivaPub.Federation.Outbox
|
||||
{ Kind: LocalActorKind.Person } => "person",
|
||||
{ Kind: LocalActorKind.Group } => "group",
|
||||
{ Kind: LocalActorKind.Reporter } => "reporter",
|
||||
{ Kind: LocalActorKind.Reader } => "reader",
|
||||
_ => "application"
|
||||
},
|
||||
Signature = "cavage:rsa-sha256"
|
||||
|
||||
@@ -126,7 +126,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
{
|
||||
LocalActorKind.Group => "Group",
|
||||
LocalActorKind.Application => "Application",
|
||||
LocalActorKind.Reporter => "Service",
|
||||
LocalActorKind.Reporter or LocalActorKind.Reader => "Service",
|
||||
_ when actor.IsBot => "Service",
|
||||
_ => "Person"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user