- 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
116 lines
3.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|