Post gains what federation and the Mastodon API need: Visibility (Public, Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account id, to/cc, the Create's id, url, context, quote, InReplyToURI and the parent's author, a separate SpoilerText next to the title, language, mentions, hashtags, remote attachments (alt text, blurhash, focus, size), reply/favourite/reblog counters, revisions, EditedAt and DeletedAt. Direct messages are Posts with Visibility Direct and a ConversationId; migration _005 copies DmPost rows across with their ids and fills the new fields of existing posts. DmPost is left in place so a rollback still sees the old messages. Inbound: - NoteParser reads Note, Article, Page, Question and media types: content, then contentMap, then _misskey_content; summary as the spoiler and name as the title; Mention and Hashtag tags; attachments; a PeerTube-style list attribution prefers the person over the channel; quote URIs. - Addressing classifies like Mastodon, finding followers-only by the author's own followers URL (now stored on ForeignAvatar), not by a "/followers" suffix. - Create keeps a post when a local persona is addressed or mentioned, when it replies to a local post (the parent's reply count goes up) or when a community it follows is addressed; an unsolicited public post is not stored. Update keeps the previous version as a revision. The outbox and object endpoints serve only Public and Unlisted posts. 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 PrivaPub.Federation.Actors;
|
|
|
|
namespace PrivaPub.Federation.Rendering
|
|
{
|
|
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(PostEntity 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;
|
|
}
|
|
}
|
|
}
|