Every remote object keeps its raw form and how it reached us, for the client's details view
Build / Build (push) Successful in 36s

- ObjectRecord, one per stored remote object: the raw JSON (up to 256 KB, always hashed), delivered or fetched,
  refetched from origin or not, the activity that brought it (or caused the fetch), shared or personal inbox, the
  signature's key, algorithm and signed headers, received time, published and updated, the delivering activity's
  @context, and up to ten later revisions from Update.
- Delivery details travel from InboxReceiver through the inbox job to the handlers as Arrival.Current.
- A host is described from its NodeInfo when we first hear from it, at most weekly (DescribeInstance job), never when
  someone opens the details view.
- GET /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host, with the extensions an object
  used detected from its raw form (044f quotes, interaction policies, contexts, proofs, Misskey fields, MFM, FEP-8967
  links, emoji, polls, language maps, url variants, Markdown content).

Checked live: a GoToSocial reply shows as delivered to the shared inbox, signed hs2019 with GoToSocial's fragment-less
key id, with its interaction policy detected, and gts.test is described as gotosocial 0.22.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 17:49:22 +02:00
1 parent bb9bd71391
commit dcd024100a
21 files changed
+662 -16

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
namespace PrivaPub.Federation.Inbox
{
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm,
IReadOnlyList<string> SignedHeaders, DateTime ReceivedAt, string Context = default)
{
static readonly AsyncLocal<Arrival> current = new();
public static Arrival Current
{
get => current.Value;
set => current.Value = value;
}
}
}
@@ -34,10 +34,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records)
{
_records = records;
_groups = groups;
_fanout = fanout;
_remotePosts = remotePosts;
@@ -53,7 +55,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
{
var node = activity["object"];
if (node is not JsonObject || !Origin.Same(Id(node), author.ActorURI))
var refetched = node is not JsonObject || !Origin.Same(Id(node), author.ActorURI);
if (refetched)
{
using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
@@ -131,6 +134,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
return;
}
await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
@@ -23,9 +23,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups)
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
IObjectRecords records)
{
_records = records;
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
@@ -83,6 +86,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
}
+10 -1
View File
@@ -46,7 +46,16 @@ namespace PrivaPub.Federation.Inbox
if (actor == default)
return JobOutcome.Retry("the actor could not be loaded");
await handler.Handle(activity, actor, token);
Arrival.Current = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm,
payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString());
try
{
await handler.Handle(activity, actor, token);
}
finally
{
Arrival.Current = default;
}
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
return JobOutcome.Done;
}
+5 -2
View File
@@ -15,7 +15,8 @@ namespace PrivaPub.Federation.Inbox
{
public sealed record InboxResult(int StatusCode, string Error = default);
public sealed record InboxPayload(string ActorURI, string Activity);
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default);
public interface IInboxReceiver
{
@@ -99,7 +100,9 @@ namespace PrivaPub.Federation.Inbox
return shapeProblem;
var activityId = Id(activity);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(new InboxPayload(actorUri, activity.ToJsonString())),
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
parameters.Algorithm, parameters.Headers, DateTime.UtcNow);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
return new(StatusCodes.Status202Accepted);
+5 -1
View File
@@ -6,6 +6,7 @@ using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
@@ -37,10 +38,12 @@ namespace PrivaPub.Federation.Inbox
readonly IRemoteActorService _remoteActors;
readonly IDomainBlocks _domainBlocks;
readonly IJobQueue _queue;
readonly IObjectRecords _records;
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
IJobQueue queue)
IJobQueue queue, IObjectRecords records)
{
_records = records;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -131,6 +134,7 @@ namespace PrivaPub.Federation.Inbox
{
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
}
await _records.Record(note, post, ObjectPath.Fetched, refetched: false, token);
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
@@ -0,0 +1,84 @@
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 links = await _http.GetJson($"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(href, "application/json", sign: default, token);
return nodeInfo == default ? await Described(host, default, "NodeInfo unreadable", token) : await Described(host, nodeInfo.Root, default, token);
}
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];
}
}
}
@@ -11,6 +11,7 @@ namespace PrivaPub.Federation.Objects
{
public string Id { get; init; }
public string Type { get; init; }
public JsonObject Raw { get; init; }
public string AttributedTo { get; init; }
public string ContentHtml { get; init; }
public string Title { get; init; }
@@ -55,6 +56,7 @@ namespace PrivaPub.Federation.Objects
{
Id = id,
Type = type,
Raw = note,
AttributedTo = Attribution(note["attributedTo"]),
ContentHtml = ContentSanitizer.Html(contentHtml),
Title = Plain(Value(note, "name"), 500),
@@ -0,0 +1,68 @@
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static class ObjectFeatures
{
public static List<string> Detect(JsonObject raw)
{
var found = new List<string>();
if (raw == default)
return found;
void When(bool condition, string feature)
{
if (condition)
found.Add(feature);
}
When(raw.ContainsKey("quote"), "fep-044f-quote");
When(raw.ContainsKey("quoteAuthorization"), "fep-044f-authorization");
When(raw.ContainsKey("_misskey_quote") || raw.ContainsKey("quoteUrl") || raw.ContainsKey("quoteUri"), "legacy-quote");
When(raw.ContainsKey("interactionPolicy"), "interaction-policy");
When(raw.ContainsKey("context"), "context");
When(raw.ContainsKey("proof"), "fep-8b32-proof");
When(raw.Any(p => p.Key.StartsWith("_misskey_", StringComparison.Ordinal)), "misskey");
When(raw["source"] is JsonObject, "source:" + (Value(raw["source"], "mediaType") ?? "unknown"));
When(raw["source"] is JsonValue, "source:url");
When(raw.ContainsKey("htmlMfm"), "fep-c16b-mfm");
When(raw.ContainsKey("formerRepresentations"), "former-representations");
When(raw.ContainsKey("searchableBy"), "fep-268d-searchable-by");
When(raw.ContainsKey("audience"), "fep-1b12-audience");
When(Items(raw["attachment"]).Any(a => Value(a, "type") == "Link"), "fep-8967-link");
When(Items(raw["tag"]).Any(t => Value(t, "type") == "Emoji"), "custom-emoji");
When(Value(raw, "type") == "Question", "poll");
When(raw["contentMap"] is JsonObject, "content-map");
When(raw["url"] is JsonArray, "url-variants");
var contentType = Value(raw, "mediaType");
When(contentType is not (null or "text/html"), "content:" + contentType);
return found;
}
public static List<string> Namespaces(JsonObject raw, string activityContext = default) =>
Namespaces(raw?["@context"] ?? (activityContext == default ? default : JsonNode.Parse(activityContext)));
static List<string> Namespaces(JsonNode context) => context switch
{
JsonValue single when single.TryGetValue<string>(out var url) => new List<string> { url },
JsonArray array => array.SelectMany(entry => entry switch
{
JsonValue value when value.TryGetValue<string>(out var url) => new[] { url },
JsonObject terms => terms.Where(t => t.Value is JsonValue v && v.TryGetValue<string>(out var target) && target.EndsWith('#'))
.Select(t => t.Key + "=" + t.Value!.GetValue<string>()),
_ => Enumerable.Empty<string>()
})
.Take(32)
.ToList(),
_ => new List<string>()
};
static IEnumerable<JsonObject> Items(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
}
}
@@ -0,0 +1,112 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Objects
{
public interface IObjectRecords
{
Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token);
Task Revise(NoteDocument note, string activityId, CancellationToken token);
}
public class ObjectRecords : IObjectRecords
{
public const int MaxRawBytes = 256 * 1024;
const int MaxRevisions = 10;
static readonly TimeSpan DescriptionAge = TimeSpan.FromDays(7);
readonly IJobQueue _queue;
public ObjectRecords(IJobQueue queue) => _queue = queue;
public async Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token)
{
var arrival = Arrival.Current;
var raw = Capture(note.Raw);
var host = new Uri(note.Id).Host.ToLowerInvariant();
var record = new ObjectRecord
{
ObjectURI = note.Id,
PostId = post.ID,
ObjectType = note.Type,
Host = host,
Raw = raw.Text,
RawHash = raw.Hash,
RawBytes = raw.Bytes,
RawTruncated = raw.Truncated,
ActivityContext = note.Raw?.ContainsKey("@context") == true || arrival?.Context?.Length > 16 * 1024 ? default : arrival?.Context,
Path = path,
Refetched = refetched,
ActivityId = arrival?.ActivityId,
ActivityType = arrival?.ActivityType,
ActivityActorURI = arrival?.ActorURI,
Inbox = path == ObjectPath.Delivered ? arrival?.Inbox : default,
SignatureScheme = arrival?.KeyId == default ? default : "draft-cavage",
KeyId = arrival?.KeyId,
Algorithm = arrival?.Algorithm,
SignedHeaders = arrival?.SignedHeaders?.ToList() ?? new(),
ReceivedAt = arrival?.ReceivedAt ?? DateTime.UtcNow,
Published = note.Published,
Updated = note.Updated
};
try
{
await DB.Default.SaveAsync(record, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await Describe(host, token);
}
public async Task Revise(NoteDocument note, string activityId, CancellationToken token)
{
var raw = Capture(note.Raw);
var revision = new ObjectRevision
{
Raw = raw.Text,
RawHash = raw.Hash,
RawTruncated = raw.Truncated,
ActivityId = activityId,
Updated = note.Updated
};
await DB.Default.Update<ObjectRecord>()
.Match(r => r.ObjectURI == note.Id)
.Modify(b => b.PushEach(r => r.Revisions, new[] { revision }, -MaxRevisions))
.Modify(r => r.Updated, note.Updated)
.ExecuteAsync(token);
}
async Task Describe(string host, CancellationToken token)
{
var known = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
if (known?.DescribedAt > DateTime.UtcNow - DescriptionAge)
return;
var week = ISOWeek.GetYear(DateTime.UtcNow) + "-" + ISOWeek.GetWeekOfYear(DateTime.UtcNow);
await _queue.Enqueue(JobKind.DescribeInstance, host, host, $"describe|{host}|{week}", token);
}
public static (string Text, string Hash, int Bytes, bool Truncated) Capture(JsonNode raw)
{
if (raw == default)
return default;
var text = raw.ToJsonString();
var bytes = Encoding.UTF8.GetBytes(text);
var hash = Convert.ToHexStringLower(SHA256.HashData(bytes));
return bytes.Length > MaxRawBytes ? (default, hash, bytes.Length, true) : (text, hash, bytes.Length, false);
}
}
}