net10, the refactor finished, and federation that works
The tree had not compiled since its first commit: Group, DmGroup and
IGroupUsersService were referenced and never written, an IDE rename had
turned the user-settings DTO into the ViewAvatarServer enum, and the settings
were saved as an entity they no longer were.
Built now:
- Group (an ActivityPub Group actor with its own keys, members, invitation
code and optional password) and DmGroup (a conversation), with
/clientapi/group/{list,insert,update,join,leave,approve}.
- Posts and DMs: /clientapi/post/{list,insert,delete}, /clientapi/dm/{list,insert};
DM recipients are local usernames or user@host handles resolved by WebFinger.
- Invitation sign-up and login against the group's invitation code, checking
the password before any account is created.
- Federation: WebFinger, NodeInfo 2.0, actors at /peasants/{name} (Person,
Group, and an Application instance actor) with SPKI keys, draft-cavage
RSA-SHA256 HTTP signatures both ways, an inbox handling Follow (+Accept),
Undo, Create, Delete and Update, an outbox, notes at /posts/{id}, and a
persisted, retried, signed delivery queue. A post to a group is announced
by the group to its followers (FEP-1b12).
- The unused, broken typed ActivityPub models are replaced by a renderer;
NSign's HMAC setup, which could not federate, is gone.
Upgrade: net10.0, MongoDB.Entities 25.1 (instance DB API, Standard GUIDs),
Swashbuckle 10 / OpenApi 2, Serilog.AspNetCore 10, MailKit 4.18,
PasswordGenerator 3. The JWT keys are 64 bytes (IdentityModel 8 refuses
shorter for HS512). Production runs its own mongod on 127.0.0.1:27022, as
Sintopia's apps do, and deploys to privapub.thepra.dev from the build runner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
56c5396106
commit
075c22228a
78 files changed
+3419
-1091
No files matched your search
@@ -0,0 +1,191 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
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 HttpClientName = "ActivityPub";
|
||||
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 IHttpClientFactory _httpClientFactory;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILogger<RemoteActorService> _logger;
|
||||
|
||||
public RemoteActorService(IHttpClientFactory httpClientFactory, DbEntities dbEntities, ILogger<RemoteActorService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_dbEntities = dbEntities;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out var target) || !IsFetchable(target))
|
||||
return default;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.ParseAdd(Accept);
|
||||
if (signAs != default)
|
||||
HttpSignatures.Sign(request, signAs, body: null);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("GET {Uri} answered {Status}", uri, (int)response.StatusCode);
|
||||
return default;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: token);
|
||||
}
|
||||
|
||||
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]}")}";
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsFetchable(target))
|
||||
return default;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/jrd+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return default;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token);
|
||||
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 bool IsFetchable(Uri target) =>
|
||||
target.Scheme == Uri.UriSchemeHttps
|
||||
&& target.HostNameType == UriHostNameType.Dns
|
||||
&& !target.IsLoopback
|
||||
&& !target.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& target.Host.Contains('.');
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user