- A fetched ObjectRecord no longer wears the signature, key, signed headers and @context of the activity that caused the fetch, and its ReceivedAt is the fetch's own time. Its activity fields now name the trigger. Provenance answers signature null and fetchedBy "instance-actor". Migration _008 clears the old fetched records. - ObjectRecords store their ObjectFeatures extensions and context namespaces (migration _009 fills older records in batches), so statistics can count them. Provenance reads the stored lists. - ForeignAvatar.Features records what an actor document uses (ActorFeatures): featured, shared inbox, FEP-521a, FEP-8b32, identity proofs, Misskey fields, indexable, undiscoverable, locked, key size, and more. - RemoteEdits.Apply and RemoteDeletes.Remove are shared by the Update, Delete and Announce handlers. A community-wrapped Update is now an edit only when newer, refreshes polls and media otherwise, revises the ObjectRecord and re-resolves quotes. A community-wrapped Delete now tombstones the object, removes its ObjectRecord, reblogs and timeline rows, and lowers the reply count. Any deleted quoting post lowers the quoted post's quote count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
225 lines
8.3 KiB
C#
225 lines
8.3 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Federation.Signing;
|
|
using PrivaPub.Infrastructure.Http;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public interface IRemoteActorService
|
|
{
|
|
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
|
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
|
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
|
bool KeyTemporarilyUnavailable(string keyId) => false;
|
|
Task<string> ResolveHandle(string handle, CancellationToken token);
|
|
}
|
|
|
|
public class RemoteActorService : IRemoteActorService
|
|
{
|
|
public const string ActivityJson = "application/activity+json";
|
|
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
|
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
|
|
static readonly TimeSpan RefetchInterval = TimeSpan.FromMinutes(5);
|
|
|
|
readonly IFederationHttp _http;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IMemoryCache _cache;
|
|
readonly DbEntities _dbEntities;
|
|
readonly IOptionsMonitor<FederationOptions> _options;
|
|
|
|
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities,
|
|
IOptionsMonitor<FederationOptions> options = default)
|
|
{
|
|
_http = http;
|
|
_localActors = localActors;
|
|
_cache = cache;
|
|
_dbEntities = dbEntities;
|
|
_options = options;
|
|
}
|
|
|
|
public async Task<FetchedJson> FetchObject(string uri, CancellationToken token)
|
|
{
|
|
using var scope = HttpScope.Default("object");
|
|
var signer = await _localActors.GetInstanceActor(token);
|
|
var fetched = await Get(uri, signer, token);
|
|
if (fetched == default)
|
|
return default;
|
|
|
|
var id = Text(fetched.Root, "id");
|
|
if (Origin.IsDocumentAt(id, fetched.FinalUri))
|
|
return fetched;
|
|
|
|
var finalUri = fetched.FinalUri;
|
|
fetched.Dispose();
|
|
if (!Origin.Same(id, finalUri.AbsoluteUri))
|
|
return default;
|
|
|
|
var named = await Get(id, signer, token);
|
|
if (named != default && Origin.IsDocumentAt(Text(named.Root, "id"), named.FinalUri))
|
|
return named;
|
|
named?.Dispose();
|
|
return default;
|
|
}
|
|
|
|
public async Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(actorUri))
|
|
return default;
|
|
actorUri = StripFragment(actorUri);
|
|
|
|
var cached = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
|
|
if (cached != default && !refresh && DateTime.UtcNow - cached.UpdatedAt < CacheLifetime)
|
|
return cached;
|
|
if (!MayFetch(actorUri))
|
|
return cached;
|
|
|
|
using var scope = HttpScope.For("actor");
|
|
using var fetched = await FetchObject(actorUri, token);
|
|
var actor = fetched == default ? default : ActorDocument.Parse(fetched.Root);
|
|
if (actor == default)
|
|
return cached;
|
|
|
|
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
|
|
return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token);
|
|
}
|
|
|
|
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
|
|
{
|
|
if (Origin.Of(keyId) == default)
|
|
return default;
|
|
|
|
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
|
|
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey) && !refresh)
|
|
return cached;
|
|
if (!MayFetch(keyId))
|
|
return cached;
|
|
|
|
using var scope = HttpScope.For("key");
|
|
using var fetched = await FetchObject(StripFragment(keyId), token);
|
|
if (fetched == default)
|
|
return cached;
|
|
|
|
var actor = ActorDocument.Parse(fetched.Root);
|
|
if (actor == default)
|
|
{
|
|
var owner = Text(fetched.Root, "owner");
|
|
if (Text(fetched.Root, "id") != keyId || !Origin.Same(owner, keyId))
|
|
return default;
|
|
using var ownerDocument = await FetchObject(owner, token);
|
|
actor = ownerDocument == default ? default : ActorDocument.Parse(ownerDocument.Root);
|
|
}
|
|
|
|
var key = actor?.Key(keyId);
|
|
if (key == default)
|
|
return default;
|
|
return await Upsert(actor, key, token);
|
|
}
|
|
|
|
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
|
|
|
|
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
|
{
|
|
var parts = handle?.TrimStart('@').Split('@');
|
|
if (parts is not { Length: 2 } || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
|
|
return default;
|
|
|
|
using var scope = HttpScope.For("webfinger");
|
|
var query = $"/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
|
using var fetched = await _http.GetJson($"https://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
|
|
?? (_options?.CurrentValue.AllowPlainHttp == true
|
|
? await _http.GetJson($"http://{parts[1]}{query}", "application/jrd+json, application/json", sign: default, token)
|
|
: default);
|
|
if (fetched == default)
|
|
return default;
|
|
var document = fetched.Document;
|
|
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
|
|
return default;
|
|
|
|
foreach (var link in links.EnumerateArray())
|
|
{
|
|
var type = Text(link, "type") ?? string.Empty;
|
|
if (Text(link, "rel") == "self" && (type.Contains("activity+json") || type.Contains("ld+json")))
|
|
return Text(link, "href");
|
|
}
|
|
return default;
|
|
}
|
|
|
|
async Task<FetchedJson> Get(string uri, LocalActor signer, CancellationToken token) =>
|
|
await _http.GetJson(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
|
|
|
|
bool MayFetch(string uri)
|
|
{
|
|
var key = "remote-actor:fetched:" + uri;
|
|
if (_cache.TryGetValue(key, out _))
|
|
return false;
|
|
_cache.Set(key, true, RefetchInterval);
|
|
return true;
|
|
}
|
|
|
|
static async Task<ForeignAvatar> Upsert(ActorDocument actor, ActorKey key, CancellationToken token)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
return await DB.Default.UpdateAndGet<ForeignAvatar>()
|
|
.Match(a => a.ActorURI == actor.Id)
|
|
.Modify(a => a.UserName, actor.PreferredUsername)
|
|
.Modify(a => a.Name, actor.Name)
|
|
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
|
|
.Modify(a => a.Url, actor.Url)
|
|
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
|
|
.Modify(a => a.InboxURL, actor.Inbox)
|
|
.Modify(a => a.OutboxURL, actor.Outbox)
|
|
.Modify(a => a.FollowersURL, actor.Followers)
|
|
.Modify(a => a.FollowingURL, actor.Following)
|
|
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
|
.Modify(a => a.PictureURL, actor.Icon)
|
|
.Modify(a => a.ThumbnailURL, actor.Header)
|
|
.Modify(a => a.PictureDescription, actor.IconDescription)
|
|
.Modify(a => a.HeaderDescription, actor.HeaderDescription)
|
|
.Modify(a => a.IsDiscoverable, actor.Discoverable)
|
|
.Modify(a => a.IsIndexable, actor.Indexable)
|
|
.Modify(a => a.IsLocked, actor.Locked)
|
|
.Modify(a => a.IsMemorial, actor.Memorial)
|
|
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
|
|
.Modify(a => a.Published, actor.Published)
|
|
.Modify(a => a.Emojis, actor.Emojis)
|
|
.Modify(a => a.Fields, actor.Fields)
|
|
.Modify(a => a.Features, actor.Features)
|
|
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
|
|
.Modify(a => a.PublicKeyId, key?.Id)
|
|
.Modify(a => a.PublicKey, key?.Pem)
|
|
.Modify(a => a.UpdatedAt, now)
|
|
.Modify(b => b.SetOnInsert(a => a.CreatedAt, now))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
}
|
|
|
|
public static string StripFragment(string uri)
|
|
{
|
|
var hash = uri.IndexOf('#');
|
|
return hash < 0 ? uri : uri[..hash];
|
|
}
|
|
|
|
public static string Text(JsonElement element, string property)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(property, out var value))
|
|
return default;
|
|
return value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.GetString(),
|
|
JsonValueKind.Object => Text(value, "id") ?? Text(value, "href"),
|
|
JsonValueKind.Array => value.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() : Text(v, "id")).FirstOrDefault(v => v != null),
|
|
_ => default
|
|
};
|
|
}
|
|
}
|
|
}
|