Once a follow holds, OutboxBackfill reads the account's latest public posts from its outbox's first page (twenty at most, once a day) and keeps them as any fetched post: its profile shows them at once instead of only what it posts from then on. Homes still get only what arrives afterwards, as on Mastodon. Announces and other servers' objects in the outbox are left out. Checked live against Mastodon (scenarios/pins.sh, now 9 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
137 lines
5.6 KiB
C#
137 lines
5.6 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Http.Features;
|
|
|
|
using PrivaPub.Federation.Signing;
|
|
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
{
|
|
public sealed class RemoteActor
|
|
{
|
|
readonly RSA _key = RSA.Create(2048);
|
|
readonly bool _namesSharedInbox;
|
|
|
|
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do
|
|
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false)
|
|
{
|
|
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
|
Id = $"{origin ?? peer.A}/users/{Name}";
|
|
Type = type;
|
|
_namesSharedInbox = sharedInbox;
|
|
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
|
}
|
|
|
|
public string Type { get; }
|
|
|
|
public string Name { get; }
|
|
public string Id { get; }
|
|
public string KeyId => Id + "#main-key";
|
|
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
|
|
|
|
public JsonObject Document()
|
|
{
|
|
var document = new JsonObject
|
|
{
|
|
["id"] = Id,
|
|
["type"] = Type,
|
|
["preferredUsername"] = Name,
|
|
["inbox"] = Id + "/inbox",
|
|
["followers"] = Id + "/followers",
|
|
["outbox"] = Id + "/outbox",
|
|
["featured"] = Id + "/featured",
|
|
["publicKey"] = new JsonObject
|
|
{
|
|
["id"] = KeyId,
|
|
["owner"] = Id,
|
|
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
|
|
}
|
|
};
|
|
if (_namesSharedInbox)
|
|
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
|
|
return document;
|
|
}
|
|
|
|
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
|
|
{
|
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
|
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
|
request.Headers.TryAddWithoutValidation("Date", date);
|
|
request.Headers.TryAddWithoutValidation("Accept", "application/activity+json");
|
|
request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"));
|
|
return request;
|
|
}
|
|
|
|
public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default)
|
|
{
|
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
|
date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
|
var digest = HttpSignatures.Digest(body);
|
|
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) };
|
|
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
|
|
request.Headers.TryAddWithoutValidation("Date", date);
|
|
request.Headers.TryAddWithoutValidation("Digest", digest);
|
|
request.Headers.TryAddWithoutValidation("Signature",
|
|
Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"));
|
|
return request;
|
|
}
|
|
|
|
// the same delivery signed as RFC 9421 does it (WordPress, Ghost, Fedify), for `signedFor` when it is not where the
|
|
// request goes
|
|
public HttpRequestMessage MessageSignedPost(string path, JsonNode activity, string signedFor = default, string origin = "https://privapub.test")
|
|
{
|
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
|
var request = new HttpRequestMessage(HttpMethod.Post, new Uri(origin + (signedFor ?? path))) { Content = new ByteArrayContent(body) };
|
|
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json");
|
|
MessageSignatures.Sign(request, KeyId, _key.ExportPkcs8PrivateKeyPem(), body);
|
|
request.RequestUri = new Uri(origin + path);
|
|
return request;
|
|
}
|
|
|
|
public string PrivateKeyPem => _key.ExportPkcs8PrivateKeyPem();
|
|
|
|
public string PublicKeyPem => _key.ExportSubjectPublicKeyInfoPem();
|
|
|
|
string Signature(string signingString, string headers)
|
|
{
|
|
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
|
return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\"";
|
|
}
|
|
|
|
public HttpRequest Get(string host, string path)
|
|
{
|
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
|
var context = new DefaultHttpContext();
|
|
context.Request.Method = "GET";
|
|
context.Request.Host = new HostString(host);
|
|
context.Request.Path = path;
|
|
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
|
context.Request.Headers["Date"] = date;
|
|
context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date");
|
|
return context.Request;
|
|
}
|
|
|
|
public HttpRequest Post(string host, string path, JsonNode activity)
|
|
{
|
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
|
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
|
var digest = HttpSignatures.Digest(body);
|
|
var context = new DefaultHttpContext();
|
|
context.Request.Method = "POST";
|
|
context.Request.Host = new HostString(host);
|
|
context.Request.Path = path;
|
|
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
|
context.Request.Headers["Date"] = date;
|
|
context.Request.Headers["Digest"] = digest;
|
|
context.Request.Headers["Content-Type"] = "application/activity+json";
|
|
context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest");
|
|
context.Request.Body = new MemoryStream(body);
|
|
context.Request.ContentLength = body.Length;
|
|
return context.Request;
|
|
}
|
|
}
|
|
}
|