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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user