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:
thepraandClaude Opus 5.5 committed 2026-10-01 11:21:31 +02:00
1 parent e9529310fe
commit e54e17708f
9 files changed
+589 -73

No files matched your search

@@ -0,0 +1,91 @@
using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Domain
{
public class ContentRendererTests
{
const string Base = "https://privapub.test";
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
[Fact]
public async Task Decorates_local_and_remote_mentions_and_hashtags()
{
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
Assert.Contains("<strong>now</strong>", rendered.Html);
Assert.DoesNotContain("<script>", rendered.Html);
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
Assert.Equal("fedi", Assert.Single(rendered.Tags));
}
[Theory]
[InlineData("`@alice` in code")]
[InlineData("mail me@alice.example")]
[InlineData("@nobody is unknown")]
[InlineData("see https://x.example/page#alice")]
public async Task Leaves_what_is_not_a_mention_alone(string text)
{
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
Assert.Empty(rendered.Mentions);
Assert.DoesNotContain("h-card", rendered.Html);
}
[Theory]
[InlineData("issue #123")]
[InlineData("it&#39;s fine")]
[InlineData("[link](https://x.example/#tag)")]
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
[Fact]
public async Task Renders_plain_text_like_mastodon()
{
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
Assert.Equal("<p>line one<br>line &lt;two&gt;</p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
}
sealed class StubLocalActors : ILocalActorService
{
public string BaseAddress => Base;
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
: default);
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
}
sealed class StubRemoteActors : IRemoteActorService
{
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
Task.FromResult(actorUri == "https://m.example/users/bob"
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
: default);
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
}
}
}
+103
View File
@@ -0,0 +1,103 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class RendererTests
{
static readonly LocalActor Alice = new()
{
Id = "a1",
Kind = LocalActorKind.Person,
UserName = "alice",
Name = "Alice",
Summary = "I **like** <b>cats</b>",
PublicKeyPem = Keys.NewKeyPair().PublicKeyPem,
Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc),
Fields = new Dictionary<string, string> { ["site"] = "https://alice.example", ["mood"] = "<i>ok</i>" },
BaseAddress = "https://privapub.test"
};
[Fact]
public void An_actor_looks_like_a_mastodon_actor_with_our_route_names()
{
var actor = ActivityPubRenderer.Actor(Alice);
Assert.Equal("https://privapub.test/peasants/alice", actor["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue<string>());
Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue<string>());
Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue<string>());
Assert.False(actor["indexable"]!.GetValue<bool>());
Assert.Equal("<p>I <strong>like</strong> &lt;b&gt;cats&lt;/b&gt;</p>", actor["summary"]!.GetValue<string>());
var fields = actor["attachment"]!.AsArray();
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
Assert.Equal("&lt;i&gt;ok&lt;/i&gt;", fields[1]!["value"]!.GetValue<string>());
Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
Assert.Equal(actor["id"]!.GetValue<string>(), actor["publicKey"]!["owner"]!.GetValue<string>());
}
[Fact]
public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon()
{
var post = new Post
{
ID = "p1",
Title = "On <cats>",
ContentHtml = "<p>body</p>",
HasContentWarning = true,
Language = "en",
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
Tags = new() { "cats" },
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc)
};
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue<string>());
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
Assert.Equal("<p><strong>On &lt;cats&gt;</strong></p><p>body</p>", note["content"]!.GetValue<string>());
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
Assert.True(note["sensitive"]!.GetValue<bool>());
Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue<string>());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
var tags = note["tag"]!.AsArray();
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
Assert.NotNull(note["contentMap"]!["en"]);
}
[Fact]
public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary()
{
var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
}
[Fact]
public void A_direct_note_is_addressed_to_its_recipients_only()
{
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", Visibility = PostVisibility.Direct };
var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") },
Alice.ConversationUri("c1"));
Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
Assert.Empty(note["cc"]!.AsArray());
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
}
}
}
+150
View File
@@ -0,0 +1,150 @@
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using Markdig;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using System.Net;
using System.Text.RegularExpressions;
namespace PrivaPub.Domain.Content
{
public sealed record ResolvedMention(string ActorUri, string UserName, string Domain, string ProfileUrl, bool IsLocal, string AccountId, string Inbox)
{
public string Handle => $"{UserName}@{Domain}";
}
public sealed record RenderedContent(string Html, IReadOnlyList<ResolvedMention> Mentions, IReadOnlyList<string> Tags);
public interface IContentRenderer
{
Task<RenderedContent> Markdown(string text, CancellationToken token);
Task<RenderedContent> PlainText(string text, CancellationToken token);
string TagUrl(string tag);
}
public partial class ContentRenderer : IContentRenderer
{
const int MaxMentions = 20;
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().UseAutoLinks().UseEmphasisExtras().Build();
static readonly HtmlParser Parser = new();
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
public ContentRenderer(ILocalActorService localActors, IRemoteActorService remoteActors)
{
_localActors = localActors;
_remoteActors = remoteActors;
}
public string TagUrl(string tag) => $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}";
public Task<RenderedContent> Markdown(string text, CancellationToken token) =>
Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token);
public Task<RenderedContent> PlainText(string text, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(text))
return Decorate(string.Empty, token);
var paragraphs = Regex.Split(text.Replace("\r\n", "\n").Trim(), "\n{2,}")
.Select(p => "<p>" + Linkify(WebUtility.HtmlEncode(p)).Replace("\n", "<br>") + "</p>");
return Decorate(string.Concat(paragraphs), token);
}
async Task<RenderedContent> Decorate(string html, CancellationToken token)
{
var document = Parser.ParseDocument($"<body>{html}</body>");
var textNodes = document.Body.Descendants<IText>()
.Where(node => !node.Ancestors<IElement>().Any(e => e.LocalName is "a" or "code" or "pre"))
.ToList();
var handles = textNodes.SelectMany(node => MentionPattern().Matches(node.Data).Select(m => m.Value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxMentions)
.ToList();
var mentions = new Dictionary<string, ResolvedMention>(StringComparer.OrdinalIgnoreCase);
foreach (var handle in handles)
{
var resolved = await Resolve(handle, token);
if (resolved != default)
mentions[handle] = resolved;
}
var tags = new List<string>();
foreach (var node in textNodes)
{
var replaced = Replace(node.Data, mentions, tags);
if (replaced == default)
continue;
var fragment = Parser.ParseFragment(replaced, node.ParentElement);
foreach (var child in fragment.ToList())
node.Parent.InsertBefore(child, node);
node.Parent.RemoveChild(node);
}
return new RenderedContent(
ContentSanitizer.Html(document.Body.InnerHtml),
mentions.Values.DistinctBy(m => m.ActorUri).ToList(),
tags.Distinct().ToList());
}
string Replace(string text, IReadOnlyDictionary<string, ResolvedMention> mentions, List<string> tags)
{
var changed = false;
var encoded = WebUtility.HtmlEncode(text);
encoded = MentionPattern().Replace(encoded, match =>
{
if (!mentions.TryGetValue(WebUtility.HtmlDecode(match.Value), out var mention))
return match.Value;
changed = true;
return $"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(mention.ProfileUrl)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(mention.UserName)}</span></a></span>";
});
encoded = HashtagPattern().Replace(encoded, match =>
{
var tag = match.Groups["tag"].Value;
if (tag.All(char.IsDigit))
return match.Value;
changed = true;
tags.Add(tag.ToLowerInvariant());
return $"{match.Groups["lead"].Value}<a href=\"{WebUtility.HtmlEncode(TagUrl(tag.ToLowerInvariant()))}\" class=\"mention hashtag\" rel=\"tag\">#<span>{tag}</span></a>";
});
return changed ? encoded : default;
}
async Task<ResolvedMention> Resolve(string handle, CancellationToken token)
{
var parts = handle.TrimStart('@').Split('@');
var localDomain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{
var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, Kind: not LocalActorKind.Application }
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
: default;
}
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
var actor = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
return actor == default
? default
: new ResolvedMention(actor.ActorURI, actor.UserName ?? parts[0], parts[1].ToLowerInvariant(), actor.Url ?? actor.ActorURI, false, actor.ID,
actor.InboxURL);
}
static string Linkify(string encoded) =>
UrlPattern().Replace(encoded, match => $"<a href=\"{match.Value}\">{match.Value}</a>");
[GeneratedRegex(@"(?<![\w@/.])@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+(?::\d+)?)?(?![\w@])")]
private static partial Regex MentionPattern();
[GeneratedRegex(@"(?<lead>^|[^\w&#/])#(?<tag>[\p{L}\p{N}_]{1,100})(?![\p{L}\p{N}_])")]
private static partial Regex HashtagPattern();
[GeneratedRegex(@"https?://[^\s<]+[^\s<.,;:!?)\]'""]")]
private static partial Regex UrlPattern();
}
}
@@ -31,18 +31,22 @@ namespace PrivaPub.Federation.Actors
public bool IsFederated { get; init; } = true;
public DateTime Published { get; init; }
public string BaseAddress { get; init; }
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
public string Uri => $"{BaseAddress}/peasants/{UserName}";
public string KeyId => $"{Uri}#main-key";
public string Inbox => $"{Uri}/mouth";
public string Outbox => $"{Uri}/anus";
public string Followers => $"{Uri}/followers";
public string Following => $"{Uri}/following";
public string Followers => $"{Uri}/groupies";
public string Following => $"{Uri}/stalking";
public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}";
public string PostUri(string postId) => $"{Uri}/posts/{postId}";
public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}";
public string HtmlUrl => $"{BaseAddress}/@{UserName}";
public string PostUri(string postId) => $"{Uri}/scribbles/{postId}";
public string PostHtmlUrl(string postId) => $"{HtmlUrl}/{postId}";
public string ActivityUri(string activityId) => $"{Uri}/grunts/{activityId}";
public string ConversationUri(string conversationId) => $"{Uri}/whispers/{conversationId}";
}
public interface ILocalActorService
@@ -190,6 +194,7 @@ namespace PrivaPub.Federation.Actors
PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey,
Published = avatar.CreatedAt,
Fields = avatar.Fields ?? new Dictionary<string, string>(),
BaseAddress = BaseAddress
};
@@ -41,26 +41,39 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
if (local is not { IsFederated: true })
return NotFound();
if (WantsHtml() && local.Kind != LocalActorKind.Application)
return Redirect(local.HtmlUrl);
return Activity(ActivityPubRenderer.Actor(local));
}
[HttpGet, Route("{actor}/anus")]
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
public async Task<IActionResult> Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId,
CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
if (!page)
{
var total = local.Kind == LocalActorKind.Group
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token)
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token)
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, default, $"{local.Outbox}?page=true"));
}
var latest = await (local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)))
.Sort(p => p.CreationDate, Order.Descending)
.Limit(OutboxSize)
.ExecuteAsync(token);
var query = local.Kind == LocalActorKind.Group
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted));
if (!string.IsNullOrEmpty(maxId))
query.Match(f => f.Lt(p => p.ID, maxId));
var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token);
var items = new List<JsonNode>();
foreach (var post in latest)
@@ -70,15 +83,15 @@ namespace PrivaPub.Federation.Controllers
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue;
}
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
items.Add(await CreateFor(post, local, token));
}
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items));
var pageId = string.IsNullOrEmpty(maxId) ? $"{local.Outbox}?page=true" : $"{local.Outbox}?page=true&max_id={maxId}";
var next = latest.Count == OutboxSize ? $"{local.Outbox}?page=true&max_id={latest[^1].ID}" : default;
return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, local.Outbox, items, next, default));
}
[HttpGet, Route("{actor}/followers")]
[HttpGet, Route("{actor}/groupies")]
public async Task<IActionResult> Followers(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
@@ -89,32 +102,37 @@ namespace PrivaPub.Federation.Controllers
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
}
[HttpGet, Route("{actor}/following")]
[HttpGet, Route("{actor}/stalking")]
public async Task<IActionResult> Following(string actor, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, Enumerable.Empty<JsonNode>()));
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
}
[HttpGet, Route("{actor}/posts/{postId}")]
[HttpGet, Route("{actor}/scribbles/{postId}")]
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local == default || local.Kind != LocalActorKind.Person)
return NotFound();
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
.ExecuteFirstAsync(token);
var (local, post) = await PublicPost(actor, postId, token);
if (post == default)
return NotFound();
if (WantsHtml())
return Redirect(local.PostHtmlUrl(post.ID));
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
note["@context"] = ActivityPubRenderer.ActivityStreams;
note["@context"] = ActivityPubRenderer.Context();
return Activity(note);
}
[HttpGet, Route("{actor}/grunts/{activityId}")]
public async Task<IActionResult> Grunt(string actor, string activityId, CancellationToken token)
{
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
return NotFound();
var (local, post) = await PublicPost(actor, activityId["create-".Length..], token);
return post == default ? NotFound() : Activity(await CreateFor(post, local, token));
}
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{
@@ -132,6 +150,33 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token));
async Task<(LocalActor Actor, PostEntity Post)> PublicPost(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return default;
var post = await _dbEntities.Posts
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
.ExecuteFirstAsync(token);
return (local, post);
}
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
return ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
}
bool WantsHtml()
{
var accept = Request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
&& !accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase)
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
}
IActionResult Answer(InboxResult result)
{
if (result.Error != default)
@@ -49,10 +49,12 @@ namespace PrivaPub.Federation.Controllers
var document = new JsonObject
{
["subject"] = $"acct:{actor.Handle}",
["aliases"] = new JsonArray(actor.Uri),
["links"] = new JsonArray(
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri },
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.Uri })
["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = actor.Kind == LocalActorKind.Application
? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
: new JsonArray(
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl },
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
};
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
}
@@ -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;
}
}
}
@@ -15,6 +15,7 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Domain.Content;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using Microsoft.Extensions.Options;
@@ -49,6 +50,7 @@ namespace PrivaPub.Middleware
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
return service
.AddSingleton<IFederationHttp, FederationHttp>()
.AddSingleton<IContentRenderer, ContentRenderer>()
.AddSingleton<ILocalActorService, LocalActorService>()
.AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>()
+24 -6
View File
@@ -13,6 +13,7 @@ using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
@@ -36,6 +37,7 @@ namespace PrivaPub.Services
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger;
@@ -43,6 +45,7 @@ namespace PrivaPub.Services
ILocalActorService localActors,
IRemoteActorService remoteActors,
IDeliveryService delivery,
IContentRenderer content,
IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger)
{
@@ -50,6 +53,7 @@ namespace PrivaPub.Services
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_content = content;
_localizer = localizer;
_logger = logger;
}
@@ -76,6 +80,7 @@ namespace PrivaPub.Services
}
var parent = await Parent(form.AnsweringToPostId, token);
var rendered = await _content.Markdown(form.Text, token);
var post = new PostEntity
{
GroupUserId = author.Id,
@@ -84,8 +89,10 @@ namespace PrivaPub.Services
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
Title = form.Title,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentHtml = rendered.Html,
ContentFormat = ContentFormat.Markdown,
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
HasContentWarning = form.HasContentWarning,
AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
@@ -95,7 +102,7 @@ namespace PrivaPub.Services
};
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
post.Url = post.ObjectURI;
post.Url = author.PostHtmlUrl(post.ID);
if (isLocalOnly)
{
@@ -113,7 +120,10 @@ namespace PrivaPub.Services
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _delivery.EnqueueToFollowers(author, create, token);
var addressedInboxes = rendered.Mentions.Where(m => !m.IsLocal).Select(m => m.Inbox).ToList();
if (parent is { IsFederatedCopy: true } && !string.IsNullOrEmpty(parent.AuthorAccountId))
addressedInboxes.Add((await _dbEntities.ForeignAvatars.MatchID(parent.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL);
await _delivery.EnqueueToFollowers(author, create, token, addressedInboxes);
if (group != default)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
@@ -248,7 +258,7 @@ namespace PrivaPub.Services
{
dmGroup = new DmGroup { Members = members, ParticipantsKey = key };
dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}";
dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID);
await DB.Default.SaveAsync(dmGroup, token);
}
}
@@ -261,14 +271,14 @@ namespace PrivaPub.Services
Visibility = PostVisibility.Direct,
ContextURI = dmGroup.ConversationURI,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentHtml = (await _content.Markdown(form.Text, token)).Html,
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri
};
dm.ID = (string)dm.GenerateNewID();
dm.ObjectURI = author.PostUri(dm.ID);
dm.Url = dm.ObjectURI;
dm.Url = author.PostHtmlUrl(dm.ID);
var remote = new List<(string Uri, string Handle)>();
var inboxes = new List<string>();
@@ -377,6 +387,14 @@ namespace PrivaPub.Services
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
}
static PostMention ToMention(ResolvedMention mention) => new()
{
ActorURI = mention.ActorUri,
Handle = "@" + mention.Handle,
IsLocal = mention.IsLocal,
AccountId = mention.AccountId
};
static string RemoteUri(string answeringTo) =>
answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default;