Files
SocialPub/PrivaPub/Federation/Objects/ObjectRecords.cs
T
thepraandClaude Opus 5.5 c8a305ae92 M8: every server we touch is described, located and snapshotted weekly
- Touches: the ledger marks a server as touched when it sends us a verified activity, when
  we exchange activities with it, or when we read its actors, keys, objects or WebFinger.
  It upserts RemoteInstance.Seen, FirstSeenAt and LastSeenAt at most hourly per server, and
  queues one DescribeInstance a week with the same dedupe key ObjectRecords uses. Suspended
  servers and pages behind link previews are never described. Migration _010 marks the
  servers already known as touched, with their dates.
- InstanceDescriber.Describe(host, crawled, allowed) reads:
  - NodeInfo 2.2/2.1/2.0, now with its published user counts, posts, comments,
    description, languages and schema version;
  - for software with a Mastodon API, /api/v2/instance falling back to v1: title,
    languages, registration mode, character limit, API version, source URL.

  It never keeps a contact as a field; the raw document is kept for the admin only. It
  locates the server from the address our connection reached (DB-IP Lite city and ASN, the
  CDN named when fronted) and writes a RemoteInstanceSnapshot per ISO week, unreachable
  weeks included. A crawled server is upserted as crawled only on insert, so it never
  downgrades a touched one, and robots.txt can deny any path.
- PublicGeo.Project is the only public form of a location: a CDN-fronted server shows its
  CDN only, a server reporting at least ten users shows its city, coordinates and network,
  any other only its country.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:07:57 +02:00

116 lines
3.8 KiB
C#

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 delivered = path == ObjectPath.Delivered;
var raw = Capture(note.Raw);
var host = new Uri(note.Id).Host.ToLowerInvariant();
var activityContext = !delivered || note.Raw?.ContainsKey("@context") == true || arrival?.Context?.Length > 16 * 1024 ? default : arrival?.Context;
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 = activityContext,
Path = path,
Refetched = refetched,
ActivityId = arrival?.ActivityId,
ActivityType = arrival?.ActivityType,
ActivityActorURI = arrival?.ActorURI,
Inbox = delivered ? arrival?.Inbox : default,
SignatureScheme = !delivered || arrival?.KeyId == default ? default : "draft-cavage",
KeyId = delivered ? arrival?.KeyId : default,
Algorithm = delivered ? arrival?.Algorithm : default,
SignedHeaders = delivered ? arrival?.SignedHeaders?.ToList() ?? new() : new(),
ReceivedAt = delivered ? arrival?.ReceivedAt ?? DateTime.UtcNow : DateTime.UtcNow,
Published = note.Published,
Updated = note.Updated,
Extensions = ObjectFeatures.Detect(note.Raw),
ContextNamespaces = ObjectFeatures.Namespaces(note.Raw, activityContext)
};
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;
await _queue.Enqueue(JobKind.DescribeInstance, host, host, InstanceDescriber.DedupeKey(host, DateTime.UtcNow), 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);
}
}
}