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,178 @@
|
||||
using Markdig;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public static class ActivityPubRenderer
|
||||
{
|
||||
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams";
|
||||
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
|
||||
|
||||
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
|
||||
|
||||
public static string Html(string markdown) =>
|
||||
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
|
||||
|
||||
public static string Timestamp(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
|
||||
|
||||
public static JsonObject Actor(LocalActor actor)
|
||||
{
|
||||
var document = new JsonObject
|
||||
{
|
||||
["@context"] = new JsonArray(ActivityStreams, "https://w3id.org/security/v1"),
|
||||
["id"] = actor.Uri,
|
||||
["type"] = actor.Kind switch
|
||||
{
|
||||
LocalActorKind.Group => "Group",
|
||||
LocalActorKind.Application => "Application",
|
||||
_ => "Person"
|
||||
},
|
||||
["preferredUsername"] = actor.UserName,
|
||||
["name"] = actor.Name,
|
||||
["summary"] = Html(actor.Summary),
|
||||
["url"] = actor.Uri,
|
||||
["inbox"] = actor.Inbox,
|
||||
["outbox"] = actor.Outbox,
|
||||
["followers"] = actor.Followers,
|
||||
["following"] = actor.Following,
|
||||
["published"] = Timestamp(actor.Published),
|
||||
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
|
||||
["discoverable"] = actor.Discoverable,
|
||||
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = actor.KeyId,
|
||||
["owner"] = actor.Uri,
|
||||
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem)
|
||||
}
|
||||
};
|
||||
if (!string.IsNullOrEmpty(actor.PictureURL))
|
||||
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
|
||||
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
|
||||
document["image"] = new JsonObject { ["type"] = "Image", ["url"] = actor.ThumbnailURL };
|
||||
return document;
|
||||
}
|
||||
|
||||
public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
|
||||
{
|
||||
var to = new JsonArray(Public);
|
||||
var cc = new JsonArray(author.Followers);
|
||||
if (group != default)
|
||||
cc.Add(group.Uri);
|
||||
|
||||
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning,
|
||||
post.CreationDate, to, cc, inReplyTo);
|
||||
if (group != default)
|
||||
note["audience"] = group.Uri;
|
||||
return note;
|
||||
}
|
||||
|
||||
public static JsonObject DirectNote(DmPostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
|
||||
string context)
|
||||
{
|
||||
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning,
|
||||
post.CreationDate, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), default);
|
||||
note["tag"] = new JsonArray(recipients.Select(r => (JsonNode)new JsonObject
|
||||
{
|
||||
["type"] = "Mention",
|
||||
["href"] = r.Uri,
|
||||
["name"] = "@" + r.Handle
|
||||
}).ToArray());
|
||||
note["content"] = string.Join(" ", recipients.Select(r => $"<span class=\"h-card\"><a href=\"{r.Uri}\" class=\"u-url mention\">@{r.Handle}</a></span>"))
|
||||
+ " " + note["content"]!.GetValue<string>();
|
||||
if (!string.IsNullOrEmpty(context))
|
||||
note["context"] = context;
|
||||
return note;
|
||||
}
|
||||
|
||||
static JsonObject NoteBody(string id, LocalActor author, string title, string text, bool sensitive,
|
||||
DateTime published, JsonArray to, JsonArray cc, string inReplyTo)
|
||||
{
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = author.Uri,
|
||||
["content"] = Html(text),
|
||||
["published"] = Timestamp(published),
|
||||
["url"] = id,
|
||||
["to"] = to,
|
||||
["cc"] = cc,
|
||||
["sensitive"] = sensitive
|
||||
};
|
||||
if (sensitive && !string.IsNullOrEmpty(title))
|
||||
note["summary"] = title;
|
||||
else if (!string.IsNullOrEmpty(title))
|
||||
note["name"] = title;
|
||||
if (!string.IsNullOrEmpty(inReplyTo))
|
||||
note["inReplyTo"] = inReplyTo;
|
||||
return note;
|
||||
}
|
||||
|
||||
public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = actor.ActivityUri(activityId),
|
||||
["type"] = "Create",
|
||||
["actor"] = actor.Uri,
|
||||
["published"] = note["published"]?.DeepClone(),
|
||||
["to"] = note["to"]?.DeepClone(),
|
||||
["cc"] = note["cc"]?.DeepClone(),
|
||||
["object"] = note
|
||||
};
|
||||
|
||||
public static JsonObject Announce(LocalActor group, string objectUri, string activityId) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = group.ActivityUri(activityId),
|
||||
["type"] = "Announce",
|
||||
["actor"] = group.Uri,
|
||||
["published"] = Timestamp(DateTime.UtcNow),
|
||||
["to"] = new JsonArray(Public),
|
||||
["cc"] = new JsonArray(group.Followers),
|
||||
["object"] = objectUri
|
||||
};
|
||||
|
||||
public static JsonObject Delete(LocalActor actor, string objectUri, string activityId, JsonArray to, JsonArray cc) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = actor.ActivityUri(activityId),
|
||||
["type"] = "Delete",
|
||||
["actor"] = actor.Uri,
|
||||
["to"] = to,
|
||||
["cc"] = cc,
|
||||
["object"] = new JsonObject { ["id"] = objectUri, ["type"] = "Tombstone" }
|
||||
};
|
||||
|
||||
public static JsonObject Accept(LocalActor actor, JsonNode follow, string activityId) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = actor.ActivityUri(activityId),
|
||||
["type"] = "Accept",
|
||||
["actor"] = actor.Uri,
|
||||
["object"] = follow.DeepClone()
|
||||
};
|
||||
|
||||
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items)
|
||||
{
|
||||
var collection = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = id,
|
||||
["type"] = "OrderedCollection",
|
||||
["totalItems"] = totalItems
|
||||
};
|
||||
if (items != default)
|
||||
collection["orderedItems"] = new JsonArray(items.ToArray());
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public interface IDeliveryService
|
||||
{
|
||||
Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token);
|
||||
Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default);
|
||||
Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token);
|
||||
}
|
||||
|
||||
public class DeliveryService : IDeliveryService
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public DeliveryService(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token)
|
||||
{
|
||||
var body = activity.ToJsonString();
|
||||
var deliveries = inboxes
|
||||
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(inbox => new Delivery
|
||||
{
|
||||
SignerId = signer.Id,
|
||||
SignerKind = signer.Kind,
|
||||
InboxURL = inbox,
|
||||
Body = body
|
||||
})
|
||||
.ToList();
|
||||
if (deliveries.Count > 0)
|
||||
await DB.Default.SaveAsync(deliveries, token);
|
||||
}
|
||||
|
||||
public async Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default)
|
||||
{
|
||||
var inboxes = (await FollowerInboxes(signer, token)).Concat(extraInboxes ?? Enumerable.Empty<string>());
|
||||
await Enqueue(signer, inboxes, activity, token);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token)
|
||||
{
|
||||
var followers = await _dbEntities.Followers
|
||||
.Match(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted)
|
||||
.ExecuteAsync(token);
|
||||
return followers.Select(f => string.IsNullOrEmpty(f.SharedInboxURL) ? f.InboxURL : f.SharedInboxURL)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class DeliveryWorker : BackgroundService
|
||||
{
|
||||
const int MaxAttempts = 8;
|
||||
static readonly TimeSpan Poll = TimeSpan.FromSeconds(3);
|
||||
|
||||
readonly IServiceProvider _services;
|
||||
readonly IHttpClientFactory _httpClientFactory;
|
||||
readonly ILogger<DeliveryWorker> _logger;
|
||||
|
||||
public DeliveryWorker(IServiceProvider services, IHttpClientFactory httpClientFactory, ILogger<DeliveryWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeliverDue(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{Worker} pass failed", nameof(DeliveryWorker));
|
||||
}
|
||||
await Task.Delay(Poll, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
async Task DeliverDue(CancellationToken token)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var dbEntities = scope.ServiceProvider.GetRequiredService<DbEntities>();
|
||||
var actors = scope.ServiceProvider.GetRequiredService<ILocalActorService>();
|
||||
var now = DateTime.UtcNow;
|
||||
var due = await dbEntities.Deliveries
|
||||
.Match(d => !d.DeliveredAt.HasValue && !d.AbandonedAt.HasValue && d.NextAttemptAt <= now)
|
||||
.Sort(d => d.NextAttemptAt, Order.Ascending)
|
||||
.Limit(20)
|
||||
.ExecuteAsync(token);
|
||||
|
||||
foreach (var delivery in due)
|
||||
{
|
||||
var signer = await actors.FindById(delivery.SignerKind, delivery.SignerId, token);
|
||||
if (signer == default)
|
||||
{
|
||||
delivery.AbandonedAt = DateTime.UtcNow;
|
||||
delivery.LastError = "the signing actor no longer exists";
|
||||
await DB.Default.SaveAsync(delivery, token);
|
||||
continue;
|
||||
}
|
||||
await Deliver(delivery, signer, token);
|
||||
await DB.Default.SaveAsync(delivery, token);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Deliver(Delivery delivery, LocalActor signer, CancellationToken token)
|
||||
{
|
||||
delivery.Attempts++;
|
||||
try
|
||||
{
|
||||
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !RemoteActorService.IsFetchable(inbox))
|
||||
{
|
||||
delivery.AbandonedAt = DateTime.UtcNow;
|
||||
delivery.LastError = "not a deliverable inbox";
|
||||
return;
|
||||
}
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(delivery.Body);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, inbox)
|
||||
{
|
||||
Content = new ByteArrayContent(body)
|
||||
};
|
||||
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
|
||||
HttpSignatures.Sign(request, signer, body);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(RemoteActorService.HttpClientName).SendAsync(request, token);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
delivery.DeliveredAt = DateTime.UtcNow;
|
||||
delivery.LastError = default;
|
||||
return;
|
||||
}
|
||||
|
||||
delivery.LastError = $"{(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
if (response.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NotFound or HttpStatusCode.BadRequest or HttpStatusCode.Forbidden)
|
||||
{
|
||||
delivery.AbandonedAt = DateTime.UtcNow;
|
||||
_logger.LogWarning("Delivery {Id} to {Inbox} abandoned: {Status}", delivery.ID, delivery.InboxURL, delivery.LastError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
|
||||
{
|
||||
delivery.LastError = ex.Message;
|
||||
}
|
||||
|
||||
if (delivery.Attempts >= MaxAttempts)
|
||||
{
|
||||
delivery.AbandonedAt = DateTime.UtcNow;
|
||||
_logger.LogWarning("Delivery {Id} to {Inbox} abandoned after {Attempts} attempts: {Error}",
|
||||
delivery.ID, delivery.InboxURL, delivery.Attempts, delivery.LastError);
|
||||
return;
|
||||
}
|
||||
delivery.NextAttemptAt = DateTime.UtcNow.AddMinutes(Math.Pow(2, delivery.Attempts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public sealed record SignatureParameters(string KeyId, string Algorithm, string[] Headers, byte[] Signature,
|
||||
string Created, string Expires);
|
||||
|
||||
public static class HttpSignatures
|
||||
{
|
||||
static readonly TimeSpan AllowedClockSkew = TimeSpan.FromHours(12);
|
||||
|
||||
public static string Digest(byte[] body) => "SHA-256=" + Convert.ToBase64String(SHA256.HashData(body));
|
||||
|
||||
public static void Sign(HttpRequestMessage request, LocalActor signer, byte[] body)
|
||||
{
|
||||
var date = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||
var signed = new List<(string Name, string Value)>
|
||||
{
|
||||
("(request-target)", $"{request.Method.Method.ToLowerInvariant()} {request.RequestUri.PathAndQuery}"),
|
||||
("host", request.RequestUri.IsDefaultPort ? request.RequestUri.Host : request.RequestUri.Authority),
|
||||
("date", date)
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Date", date);
|
||||
|
||||
if (body != null)
|
||||
{
|
||||
var digest = Digest(body);
|
||||
request.Headers.TryAddWithoutValidation("Digest", digest);
|
||||
signed.Add(("digest", digest));
|
||||
}
|
||||
|
||||
var signingString = string.Join("\n", signed.Select(h => $"{h.Name}: {h.Value}"));
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(signer.PrivateKeyPem);
|
||||
var signature = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(signingString),
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
|
||||
request.Headers.TryAddWithoutValidation("Signature",
|
||||
$"keyId=\"{signer.KeyId}\",algorithm=\"rsa-sha256\",headers=\"{string.Join(' ', signed.Select(h => h.Name))}\",signature=\"{signature}\"");
|
||||
}
|
||||
|
||||
public static SignatureParameters Parse(string header)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header))
|
||||
return default;
|
||||
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var i = 0;
|
||||
while (i < header.Length)
|
||||
{
|
||||
while (i < header.Length && (header[i] == ',' || header[i] == ' '))
|
||||
i++;
|
||||
var equals = header.IndexOf('=', i);
|
||||
if (equals < 0)
|
||||
break;
|
||||
var key = header[i..equals].Trim();
|
||||
i = equals + 1;
|
||||
string value;
|
||||
if (i < header.Length && header[i] == '"')
|
||||
{
|
||||
var close = header.IndexOf('"', i + 1);
|
||||
if (close < 0)
|
||||
return default;
|
||||
value = header[(i + 1)..close];
|
||||
i = close + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var comma = header.IndexOf(',', i);
|
||||
value = comma < 0 ? header[i..] : header[i..comma];
|
||||
i = comma < 0 ? header.Length : comma;
|
||||
}
|
||||
values[key] = value.Trim();
|
||||
}
|
||||
|
||||
if (!values.TryGetValue("keyId", out var keyId) || !values.TryGetValue("signature", out var signature))
|
||||
return default;
|
||||
|
||||
byte[] signatureBytes;
|
||||
try
|
||||
{
|
||||
signatureBytes = Convert.FromBase64String(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var headers = values.TryGetValue("headers", out var headerList)
|
||||
? headerList.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(h => h.ToLowerInvariant()).ToArray()
|
||||
: new[] { "date" };
|
||||
|
||||
return new(keyId, values.GetValueOrDefault("algorithm") ?? "hs2019", headers, signatureBytes,
|
||||
values.GetValueOrDefault("created"), values.GetValueOrDefault("expires"));
|
||||
}
|
||||
|
||||
public static string SigningString(HttpRequest request, SignatureParameters parameters)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var header in parameters.Headers)
|
||||
{
|
||||
switch (header)
|
||||
{
|
||||
case "(request-target)":
|
||||
lines.Add($"(request-target): {request.Method.ToLowerInvariant()} {request.PathBase}{request.Path}{request.QueryString}");
|
||||
break;
|
||||
case "(created)":
|
||||
lines.Add($"(created): {parameters.Created}");
|
||||
break;
|
||||
case "(expires)":
|
||||
lines.Add($"(expires): {parameters.Expires}");
|
||||
break;
|
||||
case "host":
|
||||
lines.Add($"host: {request.Host.Value}");
|
||||
break;
|
||||
default:
|
||||
if (!request.Headers.TryGetValue(header, out var value))
|
||||
return default;
|
||||
lines.Add($"{header}: {string.Join(", ", value.Select(v => v.Trim()))}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
public static string CheckRequest(HttpRequest request, SignatureParameters parameters, byte[] body)
|
||||
{
|
||||
if (parameters.Algorithm is not ("rsa-sha256" or "hs2019"))
|
||||
return $"unsupported signature algorithm '{parameters.Algorithm}'";
|
||||
|
||||
if (body is { Length: > 0 })
|
||||
{
|
||||
if (!parameters.Headers.Contains("digest"))
|
||||
return "the digest is not signed";
|
||||
var expectedHash = Convert.ToBase64String(SHA256.HashData(body));
|
||||
var matches = request.Headers["Digest"].ToString().Split(',').Select(d => d.Trim())
|
||||
.Any(d => d.StartsWith("SHA-256=", StringComparison.OrdinalIgnoreCase) && d[8..] == expectedHash);
|
||||
if (!matches)
|
||||
return "the digest does not match the body";
|
||||
}
|
||||
|
||||
if (parameters.Headers.Contains("date"))
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(request.Headers["Date"].ToString(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal, out var date))
|
||||
return "unreadable Date header";
|
||||
if ((DateTimeOffset.UtcNow - date).Duration() > AllowedClockSkew)
|
||||
return "the Date header is outside the allowed window";
|
||||
}
|
||||
else if (!parameters.Headers.Contains("(created)"))
|
||||
return "neither date nor (created) is signed";
|
||||
|
||||
if (!string.IsNullOrEmpty(parameters.Expires) && long.TryParse(parameters.Expires, out var expires)
|
||||
&& DateTimeOffset.FromUnixTimeSeconds(expires) < DateTimeOffset.UtcNow - AllowedClockSkew)
|
||||
return "the signature has expired";
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public static bool Verify(string publicKeyPem, string signingString, byte[] signature)
|
||||
{
|
||||
if (string.IsNullOrEmpty(publicKeyPem) || signingString == null)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.VerifyData(Encoding.UTF8.GetBytes(signingString), signature,
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public sealed record InboxResult(int StatusCode, string Error = default);
|
||||
|
||||
public interface IInboxService
|
||||
{
|
||||
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
|
||||
}
|
||||
|
||||
public class InboxService : IInboxService
|
||||
{
|
||||
const int MaxBodyBytes = 1024 * 1024;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly ILogger<InboxService> _logger;
|
||||
|
||||
public InboxService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
||||
IDeliveryService delivery, ILogger<InboxService> logger)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_delivery = delivery;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token)
|
||||
{
|
||||
if (request.ContentLength > MaxBodyBytes)
|
||||
return new(StatusCodes.Status413PayloadTooLarge);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await request.Body.CopyToAsync(buffer, token);
|
||||
if (buffer.Length > MaxBodyBytes)
|
||||
return new(StatusCodes.Status413PayloadTooLarge);
|
||||
var body = buffer.ToArray();
|
||||
|
||||
JsonNode activity;
|
||||
try
|
||||
{
|
||||
activity = JsonNode.Parse(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new(StatusCodes.Status400BadRequest, "the body is not JSON");
|
||||
}
|
||||
if (activity is not JsonObject)
|
||||
return new(StatusCodes.Status400BadRequest, "the body is not an activity");
|
||||
|
||||
var type = Value(activity, "type");
|
||||
var actorUri = Id(activity["actor"]);
|
||||
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri))
|
||||
return new(StatusCodes.Status400BadRequest, "type and actor are required");
|
||||
|
||||
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
|
||||
if (parameters == default)
|
||||
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header");
|
||||
|
||||
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
|
||||
if (requestProblem != default)
|
||||
return new(StatusCodes.Status401Unauthorized, requestProblem);
|
||||
|
||||
var signAs = recipient ?? await _localActors.GetInstanceActor(token);
|
||||
var signingString = HttpSignatures.SigningString(request, parameters);
|
||||
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: false, token);
|
||||
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri)
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||
{
|
||||
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, signAs, refresh: true, token);
|
||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
|
||||
}
|
||||
|
||||
if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal))
|
||||
return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner");
|
||||
|
||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor}", recipient?.Handle ?? "shared", type, actorUri);
|
||||
|
||||
return type switch
|
||||
{
|
||||
"Follow" => await Follow(activity, keyOwner, token),
|
||||
"Undo" => await Undo(activity, keyOwner, token),
|
||||
"Create" => await Create(activity, keyOwner, signAs, token),
|
||||
"Delete" => await Delete(activity, keyOwner, token),
|
||||
"Update" => await Update(activity, keyOwner, signAs, token),
|
||||
_ => new(StatusCodes.Status202Accepted)
|
||||
};
|
||||
}
|
||||
|
||||
async Task<InboxResult> Follow(JsonNode follow, ForeignAvatar follower, CancellationToken token)
|
||||
{
|
||||
var target = await _localActors.FindByUri(Id(follow["object"]), token);
|
||||
if (target == default || target.Kind == LocalActorKind.Application)
|
||||
return new(StatusCodes.Status404NotFound, "no such local actor");
|
||||
|
||||
var existing = await _dbEntities.Followers
|
||||
.Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI)
|
||||
.ExecuteFirstAsync(token);
|
||||
var record = existing ?? new Follower
|
||||
{
|
||||
LocalActorId = target.Id,
|
||||
LocalActorKind = target.Kind,
|
||||
ActorURI = follower.ActorURI
|
||||
};
|
||||
record.InboxURL = follower.InboxURL;
|
||||
record.SharedInboxURL = follower.SharedInboxURL;
|
||||
record.FollowActivityURI = Id(follow);
|
||||
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
|
||||
await DB.Default.SaveAsync(record, token);
|
||||
|
||||
if (!record.IsAccepted)
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
if (target.Kind == LocalActorKind.Group)
|
||||
await AddForeignMember(target.Id, follower.ActorURI, token);
|
||||
|
||||
var accept = ActivityPubRenderer.Accept(target, follow, $"accept-{record.ID}-{DateTime.UtcNow.Ticks}");
|
||||
await _delivery.Enqueue(target, new[] { follower.InboxURL }, accept, token);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Undo(JsonNode undo, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var inner = undo["object"];
|
||||
var innerType = inner is JsonObject ? Value(inner, "type") : default;
|
||||
var innerId = Id(inner);
|
||||
|
||||
if (innerType is not (null or "Follow"))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
|
||||
var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
|
||||
foreach (var follower in followers)
|
||||
{
|
||||
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
|
||||
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
|
||||
var matchesTarget = target != default && target.Id == follower.LocalActorId && target.Kind == follower.LocalActorKind;
|
||||
if (!matchesActivity && !matchesTarget)
|
||||
continue;
|
||||
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
if (follower.LocalActorKind == LocalActorKind.Group)
|
||||
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
|
||||
}
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Create(JsonNode create, ForeignAvatar author, LocalActor signAs, CancellationToken token)
|
||||
{
|
||||
var note = create["object"];
|
||||
if (note is JsonValue)
|
||||
{
|
||||
using var fetched = await _remoteActors.Fetch(Id(note), signAs, token);
|
||||
note = fetched == default ? default : JsonNode.Parse(fetched.RootElement.GetRawText());
|
||||
}
|
||||
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var objectUri = Id(note);
|
||||
if (string.IsNullOrEmpty(objectUri) || Id(note["attributedTo"]) != author.ActorURI)
|
||||
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor");
|
||||
|
||||
var addressed = Addresses(note).Concat(Addresses(create)).Distinct(StringComparer.Ordinal).ToList();
|
||||
var isPublic = addressed.Contains(ActivityPubRenderer.Public) || addressed.Contains("as:Public") || addressed.Contains("Public");
|
||||
var inReplyTo = Id(note["inReplyTo"]);
|
||||
|
||||
var localTargets = new List<LocalActor>();
|
||||
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
if (local != default && localTargets.All(l => l.Id != local.Id))
|
||||
localTargets.Add(local);
|
||||
}
|
||||
|
||||
if (isPublic || addressed.Any(a => a == author.ActorURI + "/followers" || a.EndsWith("/followers")))
|
||||
{
|
||||
var group = localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group);
|
||||
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
|
||||
group = default;
|
||||
if (group == default && localTargets.All(t => t.Kind != LocalActorKind.Person))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var post = new PostEntity
|
||||
{
|
||||
ObjectURI = objectUri,
|
||||
ActorURI = author.ActorURI,
|
||||
GroupId = group?.Id,
|
||||
Title = Value(note, "summary") ?? Value(note, "name"),
|
||||
Text = Value(note, "content"),
|
||||
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
|
||||
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
|
||||
IsFederatedCopy = true,
|
||||
CreationDate = DateTime.TryParse(Value(note, "published"), out var published) ? published.ToUniversalTime() : DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
|
||||
if (group != default)
|
||||
{
|
||||
var announce = ActivityPubRenderer.Announce(group, objectUri, $"announce-{post.ID}");
|
||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
||||
}
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
var recipients = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
|
||||
if (recipients.Count == 0)
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var participants = addressed.Where(a => a != ActivityPubRenderer.Public).Append(author.ActorURI).ToList();
|
||||
var dmGroup = await FindOrCreateDmGroup(participants, Value(note, "context") ?? Value(note, "conversation"), token);
|
||||
var dm = new DmPostEntity
|
||||
{
|
||||
ObjectURI = objectUri,
|
||||
ActorURI = author.ActorURI,
|
||||
GroupId = dmGroup.ID,
|
||||
Title = Value(note, "summary"),
|
||||
Text = Value(note, "content"),
|
||||
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
|
||||
AnsweringToPostId = inReplyTo,
|
||||
IsFederatedCopy = true,
|
||||
CreationDate = DateTime.TryParse(Value(note, "published"), out var dmPublished) ? dmPublished.ToUniversalTime() : DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(dm, token);
|
||||
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Delete(JsonNode delete, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(delete["object"]);
|
||||
if (string.IsNullOrEmpty(objectUri))
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
if (objectUri == actor.ActorURI)
|
||||
{
|
||||
actor.DeletionAt = DateTime.UtcNow;
|
||||
actor.AccountState = AvatarAccountState.Deleted;
|
||||
await DB.Default.SaveAsync(actor, token);
|
||||
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
|
||||
foreach (var follower in followers)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||
if (follower.LocalActorKind == LocalActorKind.Group)
|
||||
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
|
||||
}
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
||||
await DB.Default.DeleteAsync<DmPostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<InboxResult> Update(JsonNode update, ForeignAvatar actor, LocalActor signAs, CancellationToken token)
|
||||
{
|
||||
var inner = update["object"];
|
||||
if (Id(inner) == actor.ActorURI)
|
||||
{
|
||||
await _remoteActors.GetActor(actor.ActorURI, signAs, refresh: true, token);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI)
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
|
||||
var objectUri = Id(inner);
|
||||
var text = Value(inner, "content");
|
||||
await DB.Default.Update<PostEntity>()
|
||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
||||
.Modify(p => p.Text, text)
|
||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.Update<DmPostEntity>()
|
||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
||||
.Modify(p => p.Text, text)
|
||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
return new(StatusCodes.Status202Accepted);
|
||||
}
|
||||
|
||||
async Task<bool> IsAcceptedFollower(LocalActor group, string actorUri, CancellationToken token) =>
|
||||
await _dbEntities.Followers
|
||||
.Match(f => f.LocalActorId == group.Id && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == actorUri && f.IsAccepted)
|
||||
.ExecuteAnyAsync(token);
|
||||
|
||||
async Task AddForeignMember(string groupId, string actorUri, CancellationToken token)
|
||||
{
|
||||
var group = await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
||||
if (group == default || group.Members.Any(m => m.IsForeign && m.AvatarId == actorUri))
|
||||
return;
|
||||
group.Members.Add(new GroupMember { AvatarId = actorUri, IsForeign = true });
|
||||
group.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(group, token);
|
||||
}
|
||||
|
||||
async Task RemoveForeignMember(string groupId, string actorUri, CancellationToken token)
|
||||
{
|
||||
var group = await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
||||
if (group == default || group.Members.RemoveAll(m => m.IsForeign && m.AvatarId == actorUri) == 0)
|
||||
return;
|
||||
group.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync<GroupEntity>(group, token);
|
||||
}
|
||||
|
||||
async Task<DmGroup> FindOrCreateDmGroup(List<string> participantUris, string context, CancellationToken token)
|
||||
{
|
||||
var members = new List<GroupMember>();
|
||||
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
members.Add(local != default && local.Kind == LocalActorKind.Person
|
||||
? new GroupMember { AvatarId = local.Id }
|
||||
: new GroupMember { AvatarId = uri, IsForeign = true });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context))
|
||||
{
|
||||
var byContext = await _dbEntities.DmGroups.Match(g => g.ConversationURI == context).ExecuteFirstAsync(token);
|
||||
if (byContext != default)
|
||||
return byContext;
|
||||
}
|
||||
|
||||
var keys = members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
var candidates = await _dbEntities.DmGroups
|
||||
.Match(g => !g.DeletionAt.HasValue && g.Members.Count == keys.Count)
|
||||
.ExecuteAsync(token);
|
||||
var match = candidates.FirstOrDefault(g => g.Members.Select(m => m.AvatarId).OrderBy(k => k, StringComparer.Ordinal).SequenceEqual(keys));
|
||||
if (match != default)
|
||||
return match;
|
||||
|
||||
var dmGroup = new DmGroup { Members = members, ConversationURI = context };
|
||||
await DB.Default.SaveAsync(dmGroup, token);
|
||||
return dmGroup;
|
||||
}
|
||||
|
||||
async Task<string> LocalPostId(string uri, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uri) || !uri.StartsWith(_localActors.BaseAddress + "/peasants/", StringComparison.OrdinalIgnoreCase))
|
||||
return default;
|
||||
var id = uri[(uri.LastIndexOf('/') + 1)..];
|
||||
return await _dbEntities.Posts.MatchID(id).ExecuteAnyAsync(token) ? id : default;
|
||||
}
|
||||
|
||||
static IEnumerable<string> Addresses(JsonNode node)
|
||||
{
|
||||
foreach (var field in new[] { "to", "cc", "bto", "bcc", "audience" })
|
||||
{
|
||||
switch (node[field])
|
||||
{
|
||||
case JsonArray array:
|
||||
foreach (var item in array)
|
||||
{
|
||||
var id = Id(item);
|
||||
if (id != default)
|
||||
yield return id;
|
||||
}
|
||||
break;
|
||||
case JsonNode single when Id(single) is { } id:
|
||||
yield return id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string Id(JsonNode node) => node switch
|
||||
{
|
||||
JsonValue value when value.TryGetValue<string>(out var text) => text,
|
||||
JsonObject obj => Value(obj, "id") ?? Value(obj, "href"),
|
||||
JsonArray array => array.Select(Id).FirstOrDefault(i => i != default),
|
||||
_ => default
|
||||
};
|
||||
|
||||
public static string Value(JsonNode node, string property) =>
|
||||
node is JsonObject obj && obj[property] is JsonValue value && value.TryGetValue<string>(out var text) ? text : default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
public class LocalActor
|
||||
{
|
||||
public string Id { get; init; }
|
||||
public LocalActorKind Kind { get; init; }
|
||||
public string UserName { get; init; }
|
||||
public string Name { get; init; }
|
||||
public string Summary { get; init; }
|
||||
public string PictureURL { get; init; }
|
||||
public string ThumbnailURL { get; init; }
|
||||
public string PrivateKeyPem { get; init; }
|
||||
public string PublicKeyPem { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
public bool ManuallyApprovesFollowers { get; init; }
|
||||
public DateTime Published { get; init; }
|
||||
public string BaseAddress { get; init; }
|
||||
|
||||
public string Uri => $"{BaseAddress}/peasants/{UserName}";
|
||||
public string KeyId => $"{Uri}#main-key";
|
||||
public string Inbox => $"{Uri}/mouth";
|
||||
public string Outbox => $"{Uri}/anus";
|
||||
public string Followers => $"{Uri}/followers";
|
||||
public string Following => $"{Uri}/following";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
public string Domain => new Uri(BaseAddress).Authority;
|
||||
public string Handle => $"{UserName}@{Domain}";
|
||||
public string PostUri(string postId) => $"{Uri}/posts/{postId}";
|
||||
public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}";
|
||||
}
|
||||
|
||||
public interface ILocalActorService
|
||||
{
|
||||
string BaseAddress { get; }
|
||||
Task<LocalActor> FindByUserName(string userName, CancellationToken token);
|
||||
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
|
||||
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
||||
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
||||
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
||||
LocalActor FromAvatar(Avatar avatar);
|
||||
LocalActor FromGroup(GroupEntity group);
|
||||
}
|
||||
|
||||
public class LocalActorService : ILocalActorService
|
||||
{
|
||||
public const string InstanceUserName = "privapub";
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
|
||||
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_appConfiguration = appConfiguration;
|
||||
}
|
||||
|
||||
public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/');
|
||||
|
||||
public async Task<LocalActor> FindByUserName(string userName, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userName))
|
||||
return default;
|
||||
userName = userName.ToLowerInvariant();
|
||||
if (userName == InstanceUserName)
|
||||
return await GetInstanceActor(token);
|
||||
|
||||
var avatar = await _dbEntities.Avatars
|
||||
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
return FromAvatar(avatar);
|
||||
|
||||
var group = await _dbEntities.Groups
|
||||
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
return group == default ? default : FromGroup(group);
|
||||
}
|
||||
|
||||
public async Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case LocalActorKind.Person:
|
||||
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
|
||||
return avatar == default ? default : FromAvatar(avatar);
|
||||
case LocalActorKind.Group:
|
||||
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
||||
return group == default ? default : FromGroup(group);
|
||||
default:
|
||||
return await GetInstanceActor(token);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token)
|
||||
{
|
||||
var prefix = $"{BaseAddress}/peasants/";
|
||||
if (string.IsNullOrEmpty(actorUri) || !actorUri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return Task.FromResult<LocalActor>(default);
|
||||
var userName = actorUri[prefix.Length..].Split('/', '#', '?')[0];
|
||||
return FindByUserName(userName, token);
|
||||
}
|
||||
|
||||
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
|
||||
{
|
||||
var instance = await _dbEntities.InstanceActors.ExecuteFirstAsync(token);
|
||||
if (instance == default)
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(instance, token);
|
||||
}
|
||||
|
||||
return new LocalActor
|
||||
{
|
||||
Id = instance.ID,
|
||||
Kind = LocalActorKind.Application,
|
||||
UserName = InstanceUserName,
|
||||
Name = "PrivaPub",
|
||||
Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.",
|
||||
PrivateKeyPem = instance.PrivateKey,
|
||||
PublicKeyPem = instance.PublicKey,
|
||||
Discoverable = false,
|
||||
Published = instance.CreationDate,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
|
||||
{
|
||||
userName = userName?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(userName) || userName == InstanceUserName)
|
||||
return true;
|
||||
if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token))
|
||||
return true;
|
||||
return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
public LocalActor FromAvatar(Avatar avatar) => new()
|
||||
{
|
||||
Id = avatar.ID,
|
||||
Kind = LocalActorKind.Person,
|
||||
UserName = avatar.UserName,
|
||||
Name = string.IsNullOrEmpty(avatar.Name) ? avatar.UserName : avatar.Name,
|
||||
Summary = avatar.Biography,
|
||||
PictureURL = avatar.PictureURL,
|
||||
ThumbnailURL = avatar.ThumbnailURL,
|
||||
PrivateKeyPem = avatar.PrivateKey,
|
||||
PublicKeyPem = avatar.PublicKey,
|
||||
Published = avatar.CreatedAt,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
|
||||
public LocalActor FromGroup(GroupEntity group) => new()
|
||||
{
|
||||
Id = group.ID,
|
||||
Kind = LocalActorKind.Group,
|
||||
UserName = group.UserName,
|
||||
Name = string.IsNullOrEmpty(group.Name) ? group.UserName : group.Name,
|
||||
Summary = group.Description,
|
||||
PictureURL = group.PictureURL,
|
||||
ThumbnailURL = group.ThumbnailURL,
|
||||
PrivateKeyPem = group.PrivateKey,
|
||||
PublicKeyPem = group.PublicKey,
|
||||
Discoverable = group.IsDiscoverable,
|
||||
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
|
||||
Published = group.CreationDate,
|
||||
BaseAddress = BaseAddress
|
||||
};
|
||||
}
|
||||
|
||||
public static class Keys
|
||||
{
|
||||
public static (string PrivateKeyPem, string PublicKeyPem) NewKeyPair()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
return (rsa.ExportRSAPrivateKeyPem(), rsa.ExportSubjectPublicKeyInfoPem());
|
||||
}
|
||||
|
||||
public static string ToSubjectPublicKeyInfoPem(string publicKeyPem)
|
||||
{
|
||||
if (string.IsNullOrEmpty(publicKeyPem) || publicKeyPem.Contains("BEGIN PUBLIC KEY"))
|
||||
return publicKeyPem;
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.ExportSubjectPublicKeyInfoPem();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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