Outbound documents speak Mastodon's dialect under PrivaPub's route names
Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.
Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
bold first line, a content warning without its own text is summarised
by the title or "Content warning"; Mention and Hashtag tags, contentMap,
updated, and to/cc by visibility.
Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.
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
e9529310fe
commit
e54e17708f
9 files changed
+590
-74
No files matched your search
@@ -1,33 +1,65 @@
|
||||
using Markdig;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
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";
|
||||
public const string Public = Addressing.Public;
|
||||
public const string ContentWarning = "Content warning";
|
||||
|
||||
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
|
||||
|
||||
public static JsonArray Context() => new(
|
||||
ActivityStreams,
|
||||
"https://w3id.org/security/v1",
|
||||
new JsonObject
|
||||
{
|
||||
["manuallyApprovesFollowers"] = "as:manuallyApprovesFollowers",
|
||||
["sensitive"] = "as:sensitive",
|
||||
["Hashtag"] = "as:Hashtag",
|
||||
["alsoKnownAs"] = new JsonObject { ["@id"] = "as:alsoKnownAs", ["@type"] = "@id" },
|
||||
["movedTo"] = new JsonObject { ["@id"] = "as:movedTo", ["@type"] = "@id" },
|
||||
["toot"] = "http://joinmastodon.org/ns#",
|
||||
["featured"] = new JsonObject { ["@id"] = "toot:featured", ["@type"] = "@id" },
|
||||
["featuredTags"] = new JsonObject { ["@id"] = "toot:featuredTags", ["@type"] = "@id" },
|
||||
["discoverable"] = "toot:discoverable",
|
||||
["indexable"] = "toot:indexable",
|
||||
["blurhash"] = "toot:blurhash",
|
||||
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
||||
["schema"] = "http://schema.org#",
|
||||
["PropertyValue"] = "schema:PropertyValue",
|
||||
["value"] = "schema:value",
|
||||
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger"
|
||||
});
|
||||
|
||||
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 string Day(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
public static string TagUrl(string baseAddress, string tag) => $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}";
|
||||
|
||||
public static JsonObject Actor(LocalActor actor)
|
||||
{
|
||||
var document = new JsonObject
|
||||
{
|
||||
["@context"] = new JsonArray(ActivityStreams, "https://w3id.org/security/v1"),
|
||||
["@context"] = Context(),
|
||||
["id"] = actor.Uri,
|
||||
["type"] = actor.Kind switch
|
||||
{
|
||||
@@ -38,21 +70,29 @@ namespace PrivaPub.Federation.Rendering
|
||||
["preferredUsername"] = actor.UserName,
|
||||
["name"] = actor.Name,
|
||||
["summary"] = Html(actor.Summary),
|
||||
["url"] = actor.Uri,
|
||||
["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
|
||||
["inbox"] = actor.Inbox,
|
||||
["outbox"] = actor.Outbox,
|
||||
["followers"] = actor.Followers,
|
||||
["following"] = actor.Following,
|
||||
["published"] = Timestamp(actor.Published),
|
||||
["published"] = Day(actor.Published),
|
||||
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
|
||||
["discoverable"] = actor.Discoverable,
|
||||
["indexable"] = false,
|
||||
["webfinger"] = actor.Handle,
|
||||
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = actor.KeyId,
|
||||
["owner"] = actor.Uri,
|
||||
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem)
|
||||
}
|
||||
},
|
||||
["attachment"] = new JsonArray(actor.Fields.Select(field => (JsonNode)new JsonObject
|
||||
{
|
||||
["type"] = "PropertyValue",
|
||||
["name"] = field.Key,
|
||||
["value"] = FieldValue(field.Value)
|
||||
}).ToArray())
|
||||
};
|
||||
if (!string.IsNullOrEmpty(actor.PictureURL))
|
||||
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
|
||||
@@ -63,13 +103,18 @@ namespace PrivaPub.Federation.Rendering
|
||||
|
||||
public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
|
||||
{
|
||||
var to = new JsonArray(Public);
|
||||
var cc = new JsonArray(author.Followers);
|
||||
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
|
||||
var (to, cc) = post.Visibility switch
|
||||
{
|
||||
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
|
||||
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
|
||||
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
||||
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
||||
};
|
||||
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);
|
||||
var note = NoteBody(post, author, to, cc, inReplyTo);
|
||||
if (group != default)
|
||||
note["audience"] = group.Uri;
|
||||
return note;
|
||||
@@ -78,48 +123,84 @@ namespace PrivaPub.Federation.Rendering
|
||||
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>();
|
||||
var note = NoteBody(post, author, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), post.InReplyToURI);
|
||||
var named = post.Mentions.Select(m => m.ActorURI).ToHashSet();
|
||||
var tags = note["tag"]!.AsArray();
|
||||
foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri)))
|
||||
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle });
|
||||
var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList();
|
||||
if (unmentioned.Count > 0)
|
||||
note["content"] = "<p>" + string.Join(" ", unmentioned.Select(r =>
|
||||
$"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(r.Uri)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}</span></a></span>"))
|
||||
+ "</p>" + 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)
|
||||
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
|
||||
{
|
||||
var content = post.ContentHtml ?? Html(post.Text);
|
||||
if (!string.IsNullOrEmpty(post.Title))
|
||||
content = $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
||||
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["id"] = author.PostUri(post.ID),
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = author.Uri,
|
||||
["content"] = Html(text),
|
||||
["published"] = Timestamp(published),
|
||||
["url"] = id,
|
||||
["content"] = content,
|
||||
["published"] = Timestamp(post.CreationDate),
|
||||
["url"] = author.PostHtmlUrl(post.ID),
|
||||
["to"] = to,
|
||||
["cc"] = cc,
|
||||
["sensitive"] = sensitive
|
||||
["sensitive"] = post.HasContentWarning,
|
||||
["tag"] = new JsonArray(post.Mentions
|
||||
.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.ActorURI, ["name"] = MentionName(m) })
|
||||
.Concat(post.Tags.Select(t => (JsonNode)new JsonObject
|
||||
{
|
||||
["type"] = "Hashtag",
|
||||
["href"] = TagUrl(author.BaseAddress, t),
|
||||
["name"] = "#" + t
|
||||
}))
|
||||
.ToArray())
|
||||
};
|
||||
if (sensitive && !string.IsNullOrEmpty(title))
|
||||
note["summary"] = title;
|
||||
else if (!string.IsNullOrEmpty(title))
|
||||
note["name"] = title;
|
||||
if (!string.IsNullOrEmpty(post.Language))
|
||||
note["contentMap"] = new JsonObject { [post.Language] = content };
|
||||
if (!string.IsNullOrEmpty(post.Title))
|
||||
note["name"] = post.Title;
|
||||
var summary = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ContentWarning : default);
|
||||
if (!string.IsNullOrEmpty(summary))
|
||||
{
|
||||
note["summary"] = summary;
|
||||
note["sensitive"] = true;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(inReplyTo))
|
||||
note["inReplyTo"] = inReplyTo;
|
||||
if (post.EditedAt.HasValue)
|
||||
note["updated"] = Timestamp(post.EditedAt.Value);
|
||||
return note;
|
||||
}
|
||||
|
||||
static string MentionName(PostMention mention)
|
||||
{
|
||||
var handle = mention.Handle?.TrimStart('@');
|
||||
if (string.IsNullOrEmpty(handle))
|
||||
return "@" + mention.ActorURI;
|
||||
return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}");
|
||||
}
|
||||
|
||||
static string FieldValue(string value)
|
||||
{
|
||||
var encoded = WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http"
|
||||
? $"<a href=\"{encoded}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{encoded}</a>"
|
||||
: encoded;
|
||||
}
|
||||
|
||||
public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["@context"] = Context(),
|
||||
["id"] = actor.ActivityUri(activityId),
|
||||
["type"] = "Create",
|
||||
["actor"] = actor.Uri,
|
||||
@@ -161,7 +242,7 @@ namespace PrivaPub.Federation.Rendering
|
||||
["object"] = follow.DeepClone()
|
||||
};
|
||||
|
||||
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items)
|
||||
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items, string first = default)
|
||||
{
|
||||
var collection = new JsonObject
|
||||
{
|
||||
@@ -170,9 +251,28 @@ namespace PrivaPub.Federation.Rendering
|
||||
["type"] = "OrderedCollection",
|
||||
["totalItems"] = totalItems
|
||||
};
|
||||
if (first != default)
|
||||
collection["first"] = first;
|
||||
if (items != default)
|
||||
collection["orderedItems"] = new JsonArray(items.ToArray());
|
||||
return collection;
|
||||
}
|
||||
|
||||
public static JsonObject OrderedCollectionPage(string id, string partOf, IEnumerable<JsonNode> items, string next, string prev)
|
||||
{
|
||||
var page = new JsonObject
|
||||
{
|
||||
["@context"] = Context(),
|
||||
["id"] = id,
|
||||
["type"] = "OrderedCollectionPage",
|
||||
["partOf"] = partOf,
|
||||
["orderedItems"] = new JsonArray(items.ToArray())
|
||||
};
|
||||
if (next != default)
|
||||
page["next"] = next;
|
||||
if (prev != default)
|
||||
page["prev"] = prev;
|
||||
return page;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user