Infrastructure/Http adds the client the roadmap's S3 and S4 ask for: - the connect callback resolves the name itself and refuses loopback, private, link-local, CGNAT, documentation, multicast, ULA, NAT64, 6to4, Teredo and IPv4-mapped/compatible forms, then connects to the vetted address, so DNS rebinding cannot swap it afterwards; - redirects are followed by hand, at most three, each one re-checked; - bodies are capped at 1 MB after decompression, only JSON media types are read, every request has a 15 s budget, and a refused URL is not asked again for five minutes. Actor and WebFinger fetches and inbox deliveries all use it. Test networks can switch on Federation:AllowPrivateNetworks/AllowPlainHttp; startup refuses both in Production. PrivaPub.Tests (xUnit v3) starts with the address table and the fetcher's limits against an in-process peer; build.yml and deploy.yml run it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
163 lines
6.1 KiB
C#
163 lines
6.1 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
|
|
using PrivaPub.Federation.Signing;
|
|
using PrivaPub.Infrastructure.Http;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public interface IRemoteActorService
|
|
{
|
|
Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token);
|
|
Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token);
|
|
Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token);
|
|
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);
|
|
|
|
readonly IFederationHttp _http;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public RemoteActorService(IFederationHttp http, DbEntities dbEntities)
|
|
{
|
|
_http = http;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
|
|
{
|
|
var fetched = await _http.GetJson(uri, Accept,
|
|
signAs == default ? default : request => HttpSignatures.Sign(request, signAs, body: null), token);
|
|
return fetched?.Document;
|
|
}
|
|
|
|
public async Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, 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;
|
|
|
|
using var document = await Fetch(actorUri, signAs, token);
|
|
if (document == default)
|
|
return cached;
|
|
|
|
return await Upsert(document.RootElement, cached, token);
|
|
}
|
|
|
|
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, LocalActor signAs, bool refresh, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(keyId))
|
|
return default;
|
|
|
|
if (!refresh)
|
|
{
|
|
var cached = await _dbEntities.ForeignAvatars.Match(a => a.PublicKeyId == keyId).ExecuteFirstAsync(token);
|
|
if (cached != default && !string.IsNullOrEmpty(cached.PublicKey))
|
|
return cached;
|
|
}
|
|
|
|
using var document = await Fetch(StripFragment(keyId), signAs, token);
|
|
if (document == default)
|
|
return default;
|
|
|
|
var root = document.RootElement;
|
|
if (root.TryGetProperty("publicKey", out _))
|
|
{
|
|
var actorUri = Text(root, "id");
|
|
var existing = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token);
|
|
return await Upsert(root, existing, token);
|
|
}
|
|
|
|
var owner = Text(root, "owner");
|
|
return owner == default ? default : await GetActor(owner, signAs, refresh: true, token);
|
|
}
|
|
|
|
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;
|
|
|
|
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
|
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
|
|
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<ForeignAvatar> Upsert(JsonElement actor, ForeignAvatar existing, CancellationToken token)
|
|
{
|
|
var actorUri = Text(actor, "id");
|
|
if (string.IsNullOrEmpty(actorUri))
|
|
return existing;
|
|
|
|
var avatar = existing ?? new ForeignAvatar { ActorURI = actorUri, CreatedAt = DateTime.UtcNow };
|
|
avatar.ActorURI = actorUri;
|
|
avatar.UserName = Text(actor, "preferredUsername");
|
|
avatar.Name = Text(actor, "name");
|
|
avatar.Biography = Text(actor, "summary");
|
|
avatar.Url = Text(actor, "url") ?? actorUri;
|
|
avatar.Domain = new Uri(actorUri).Authority;
|
|
avatar.InboxURL = Text(actor, "inbox");
|
|
avatar.OutboxURL = Text(actor, "outbox");
|
|
avatar.IsDiscoverable = !actor.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False;
|
|
avatar.AvatarType = Enum.TryParse<AvatarType>(Text(actor, "type"), out var type) ? type : AvatarType.Person;
|
|
if (actor.TryGetProperty("endpoints", out var endpoints) && endpoints.ValueKind == JsonValueKind.Object)
|
|
avatar.SharedInboxURL = Text(endpoints, "sharedInbox");
|
|
if (actor.TryGetProperty("publicKey", out var publicKey) && publicKey.ValueKind == JsonValueKind.Object)
|
|
{
|
|
avatar.PublicKeyId = Text(publicKey, "id");
|
|
avatar.PublicKey = Text(publicKey, "publicKeyPem");
|
|
}
|
|
if (actor.TryGetProperty("icon", out var icon) && icon.ValueKind == JsonValueKind.Object)
|
|
avatar.PictureURL = Text(icon, "url");
|
|
avatar.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await DB.Default.SaveAsync(avatar, token);
|
|
return avatar;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|