Files
SocialPub/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
T
thepraandClaude Opus 5.5 4d9be37c1c Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
  scopes including the granular ones, non-expiring reference tokens,
  `created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
  page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
  only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
  then asks which persona the application acts as. The token's subject
  is that persona's id and nothing else; no root id reaches a token, an
  authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
  and every API request checks the same.

/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.

/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.

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

279 lines
10 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",
_ => "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,
["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 };
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.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(post, author, 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(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(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 };
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;
}
}
}