Files
SocialPub/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
T
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00

319 lines
12 KiB
C#

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;
namespace PrivaPub.Federation.Rendering
{
public static class ActivityPubRenderer
{
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams";
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"] = Context(),
["id"] = actor.Uri,
["type"] = actor.Kind switch
{
LocalActorKind.Group => "Group",
LocalActorKind.Application => "Application",
_ when actor.IsBot => "Service",
_ => "Person"
},
["preferredUsername"] = actor.UserName,
["name"] = actor.Name,
["summary"] = Html(actor.Summary),
["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
["inbox"] = actor.Inbox,
["outbox"] = actor.Outbox,
["followers"] = actor.Followers,
["following"] = actor.Following,
["featured"] = actor.Featured,
["featuredTags"] = actor.FeaturedTags,
["published"] = Day(actor.Published),
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
["discoverable"] = actor.Discoverable,
["indexable"] = actor.Indexable,
["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 };
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 mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var (to, cc) = post.Visibility switch
{
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
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 is { IsCircle: false })
cc.Add(group.Uri);
var note = NoteBody(post, author, to, cc, inReplyTo);
if (group != default)
note["audience"] = group.Uri;
else if (!string.IsNullOrEmpty(post.AudienceURI))
note["audience"] = post.AudienceURI;
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
{
note["type"] = "Page";
note["name"] = post.Title ?? Headline(post);
}
return note;
}
public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
string context)
{
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 string Headline(PostEntity post)
{
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
return text.Length <= 100 ? text : text[..97] + "...";
}
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"] = author.PostUri(post.ID),
["type"] = "Note",
["attributedTo"] = author.Uri,
["content"] = content,
["published"] = Timestamp(post.CreationDate),
["url"] = author.PostHtmlUrl(post.ID),
["to"] = to,
["cc"] = cc,
["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 (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content };
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
{
var attachment = new JsonObject
{
["type"] = "Document",
["mediaType"] = m.ContentType,
["url"] = m.URL,
["name"] = m.Description
};
if (!string.IsNullOrEmpty(m.Blurhash))
attachment["blurhash"] = m.Blurhash;
if (m.Focus is { Length: 2 })
attachment["focalPoint"] = new JsonArray(m.Focus[0], m.Focus[1]);
if (m.Width.HasValue && m.Height.HasValue)
{
attachment["width"] = m.Width;
attachment["height"] = m.Height;
}
return (JsonNode)attachment;
}).ToArray();
if (attachments.Length > 0)
note["attachment"] = new JsonArray(attachments);
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}");
}
public 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"] = Context(),
["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, string first = default)
{
var collection = new JsonObject
{
["@context"] = ActivityStreams,
["id"] = id,
["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;
}
}
}