Owner decision (2026-10-03, "A remote account deletes itself"): its posts are kept but
hidden everywhere.
- Post.AuthorGone (additive bool). DeleteHandler's actor-delete branch sets it on every
post whose ActorURI is the actor (one update-many), besides dropping its follows and
timeline rows as before. RemotePosts.Build sets it on a post stored later for an
account already marked Deleted.
- One rule in VisibilityPolicy: IsShown (not deleted, author not gone), IsPublic and
CanSee exclude AuthorGone, plus Shown(post) for loaded posts.
- Lookups by id answer 404 through CanSee (statuses/:id and every sub-route, context,
bookmarks, favourites, polls, reactions, search); provenance, account statuses,
home/public/tag timelines, notifications, conversations, reblogged_by, the clientapi
home and post/DM lists, a community's outbox and our Announces filter on IsShown or
IsPublic; the Mastodon mapper never renders a hidden post or a boost of one.
Tests (30 new):
- AuthorGoneTests: the rule, the handler (posts kept, boosts included, follows and rows
gone), a post fetched after the delete, and 20 Mastodon/ActivityPub lookups over HTTP
seen before and hidden after.
- InboxGapTests: actor Update refresh (name, sanitised summary, key rotation in place
and to a new key id) even with an older `updated`; Undo{Follow} by activity id and by
object; Reject of our QuoteRequest (and a stranger's ignored); group-wrapped
Announce{Like} and Announce{Undo{Like}}; a locked persona's pending follow,
FollowRequest notification, and Decide accepting and rejecting with the original Follow.
- JobHandlerTests: AncestorsJobHandler up to its depth limit; PollRefreshJob and
PollCloseJob (local and remote polls); InstanceDescriber from a peer's NodeInfo and
the weekly dedupe through ObjectRecords; LinkPreviews for public posts only;
DeliveryJobHandler outcomes (2xx, 404/410, 429/503 with Retry-After in seconds and as
a date, 5xx) and a signature and Digest the peer can verify; MediaJanitor.Sweep;
OAuthPruner.Prune.
- MigrationTests: _003, _004, _006 and _007 on seeded rows.
- PublicPagesTests: /@user and /@user/{id} (visibility, junk ids, exact CSP,
Referrer-Policy and nosniff), circle 404, community page, the instance actor,
ActivityPub redirects, and markup escaped in posts, titles and bios.
Production changes besides the rule:
- LinkPreviews.Handle re-checks that a post is still shown and public (the rule
Wanted applies) before fetching anything; before, only enqueueing checked it.
- The legacy /clientapi post and DM lists no longer return soft-deleted posts.
- MediaJanitor.Sweep and OAuthPruner.Prune are the loop bodies, now public and tested.
- InstanceDescriber.Address: a protected virtual identity seam so a test can point
the https NodeInfo addresses at a plain-http peer; production behaviour unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
89 lines
3.8 KiB
C#
89 lines
3.8 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models.Jobs;
|
|
|
|
using System.Text.Json;
|
|
|
|
namespace PrivaPub.Federation.Objects
|
|
{
|
|
public class InstanceDescriber : IJobHandler
|
|
{
|
|
const int MaxNodeInfoChars = 64 * 1024;
|
|
static readonly string[] Schemas =
|
|
{
|
|
"http://nodeinfo.diaspora.software/ns/schema/2.1",
|
|
"http://nodeinfo.diaspora.software/ns/schema/2.0"
|
|
};
|
|
|
|
readonly IFederationHttp _http;
|
|
|
|
public InstanceDescriber(IFederationHttp http) => _http = http;
|
|
|
|
public JobKind Kind => JobKind.DescribeInstance;
|
|
public int Concurrency => 1;
|
|
public int MaxAttempts => 2;
|
|
public int PerHostLimit => 1;
|
|
|
|
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
|
{
|
|
var host = job.Payload;
|
|
using var scope = HttpScope.For("nodeinfo");
|
|
using var links = await _http.GetJson(Address($"https://{host}/.well-known/nodeinfo"), "application/json", sign: default, token);
|
|
var href = links == default ? default : Schemas.Select(schema => LinkFor(links.Root, schema)).FirstOrDefault(link => link != default);
|
|
if (href == default)
|
|
return await Described(host, default, "no NodeInfo", token);
|
|
|
|
using var nodeInfo = await _http.GetJson(Address(href), "application/json", sign: default, token);
|
|
return nodeInfo == default ? await Described(host, default, "NodeInfo unreadable", token) : await Described(host, nodeInfo.Root, default, token);
|
|
}
|
|
|
|
//where an https address is fetched from: itself, except in tests, which point it at a plain-http peer
|
|
protected virtual string Address(string url) => url;
|
|
|
|
static async Task<JobOutcome> Described(string host, JsonElement? nodeInfo, string error, CancellationToken token)
|
|
{
|
|
var update = DB.Default.Update<RemoteInstance>()
|
|
.Match(i => i.Host == host)
|
|
.Modify(i => i.DescribedAt, DateTime.UtcNow)
|
|
.Modify(i => i.DescriptionError, error)
|
|
.Option(o => o.IsUpsert = true);
|
|
if (nodeInfo is { } root)
|
|
{
|
|
var raw = root.GetRawText();
|
|
update = update
|
|
.Modify(i => i.Software, Text(root, "software", "name"))
|
|
.Modify(i => i.SoftwareVersion, Text(root, "software", "version"))
|
|
.Modify(i => i.NodeName, Text(root, "metadata", "nodeName"))
|
|
.Modify(i => i.Protocols, root.TryGetProperty("protocols", out var protocols) && protocols.ValueKind == JsonValueKind.Array
|
|
? protocols.EnumerateArray().Where(p => p.ValueKind == JsonValueKind.String).Select(p => p.GetString()).Take(16).ToList()
|
|
: new List<string>())
|
|
.Modify(i => i.OpenRegistrations, root.TryGetProperty("openRegistrations", out var open) && open.ValueKind is JsonValueKind.True or JsonValueKind.False
|
|
? open.GetBoolean()
|
|
: default(bool?))
|
|
.Modify(i => i.NodeInfo, raw.Length <= MaxNodeInfoChars ? raw : default);
|
|
}
|
|
await update.ExecuteAsync(token);
|
|
return JobOutcome.Done;
|
|
}
|
|
|
|
static string LinkFor(JsonElement root, string schema) =>
|
|
root.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array
|
|
? links.EnumerateArray()
|
|
.Where(l => l.ValueKind == JsonValueKind.Object && l.TryGetProperty("rel", out var rel) && rel.ValueKind == JsonValueKind.String && rel.GetString() == schema)
|
|
.Select(l => l.TryGetProperty("href", out var href) && href.ValueKind == JsonValueKind.String ? href.GetString() : default)
|
|
.FirstOrDefault(h => h != default && h.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
: default;
|
|
|
|
static string Text(JsonElement root, string parent, string name)
|
|
{
|
|
if (!root.TryGetProperty(parent, out var section) || section.ValueKind != JsonValueKind.Object
|
|
|| !section.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String)
|
|
return default;
|
|
var text = value.GetString();
|
|
return text.Length <= 200 ? text : text[..200];
|
|
}
|
|
}
|
|
}
|