Found by the first live run against GoToSocial: - A remote post's id came from its published second plus random bytes, so a reply arriving in the same second as the post it answers could sort under it, and a late arrival landed behind a client's since_id. A post published within the last hour now gets an id for its arrival; backfill keeps its published time. - NoteParser.Time returned default(DateTime) for a missing "updated", so every remote post was stored as edited in year one. Migration _006 clears the stored ones. - published now carries milliseconds, so peers that derive ids from it (GoToSocial, Mastodon) keep our posts in order within a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
324 lines
12 KiB
C#
324 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: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"] = "<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;
|
|
}
|
|
}
|
|
}
|