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
179 lines
5.9 KiB
C#
179 lines
5.9 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|