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", ["lemmy"] = "https://join-lemmy.org/ns#", ["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods" }); 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:ss.fffZ", 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 (actor is { Kind: LocalActorKind.Group, IsCircle: false }) { document["attributedTo"] = actor.Wardens; document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators; } 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"] = "
" + string.Join(" ", unmentioned.Select(r => $"@{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}")) + "
" + note["content"]!.GetValue{WebUtility.HtmlEncode(post.Title)}
{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" ? $"{encoded}" : 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