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
+590 -74

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 bool IsFederated { get; init; } = true;
public DateTime Published { get; init; } public DateTime Published { get; init; }
public string BaseAddress { 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 Uri => $"{BaseAddress}/peasants/{UserName}";
public string KeyId => $"{Uri}#main-key"; public string KeyId => $"{Uri}#main-key";
public string Inbox => $"{Uri}/mouth"; public string Inbox => $"{Uri}/mouth";
public string Outbox => $"{Uri}/anus"; public string Outbox => $"{Uri}/anus";
public string Followers => $"{Uri}/followers"; public string Followers => $"{Uri}/groupies";
public string Following => $"{Uri}/following"; public string Following => $"{Uri}/stalking";
public string SharedInbox => $"{BaseAddress}/human-centipede"; public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority; public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}"; public string Handle => $"{UserName}@{Domain}";
public string PostUri(string postId) => $"{Uri}/posts/{postId}"; public string HtmlUrl => $"{BaseAddress}/@{UserName}";
public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}"; 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 public interface ILocalActorService
@@ -190,6 +194,7 @@ namespace PrivaPub.Federation.Actors
PrivateKeyPem = avatar.PrivateKey, PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey, PublicKeyPem = avatar.PublicKey,
Published = avatar.CreatedAt, Published = avatar.CreatedAt,
Fields = avatar.Fields ?? new Dictionary<string, string>(),
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -41,26 +41,39 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> GetActor(string actor, CancellationToken token) public async Task<IActionResult> GetActor(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, 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")] [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); var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true }) if (local is not { IsFederated: true })
return NotFound(); return NotFound();
var total = local.Kind == LocalActorKind.Group if (!page)
? 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); var total = local.Kind == LocalActorKind.Group
? 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 var query = 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.GroupId == local.Id && !p.DeletedAt.HasValue
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))) && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
.Sort(p => p.CreationDate, Order.Descending) : _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue
.Limit(OutboxSize) && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted));
.ExecuteAsync(token); 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>(); var items = new List<JsonNode>();
foreach (var post in latest) foreach (var post in latest)
@@ -70,15 +83,15 @@ namespace PrivaPub.Federation.Controllers
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}")); items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
continue; continue;
} }
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); items.Add(await CreateFor(post, local, token));
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
} }
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) public async Task<IActionResult> Followers(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, 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)); 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) public async Task<IActionResult> Following(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, 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) public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var (local, post) = await PublicPost(actor, postId, 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);
if (post == default) if (post == default)
return NotFound(); 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 group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI); var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
note["@context"] = ActivityPubRenderer.ActivityStreams; note["@context"] = ActivityPubRenderer.Context();
return Activity(note); 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)] [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> Inbox(string actor, CancellationToken token) public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{ {
@@ -132,6 +150,33 @@ namespace PrivaPub.Federation.Controllers
public async Task<IActionResult> SharedInbox(CancellationToken token) => public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, 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) IActionResult Answer(InboxResult result)
{ {
if (result.Error != default) if (result.Error != default)
@@ -49,10 +49,12 @@ namespace PrivaPub.Federation.Controllers
var document = new JsonObject var document = new JsonObject
{ {
["subject"] = $"acct:{actor.Handle}", ["subject"] = $"acct:{actor.Handle}",
["aliases"] = new JsonArray(actor.Uri), ["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
["links"] = new JsonArray( ["links"] = actor.Kind == LocalActorKind.Application
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri }, ? 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 }) : 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"); return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
} }
@@ -1,33 +1,65 @@
using Markdig; using Markdig;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using System.Globalization; using System.Globalization;
using System.Net;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Federation.Rendering namespace PrivaPub.Federation.Rendering
{ {
public static class ActivityPubRenderer public static class ActivityPubRenderer
{ {
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams"; 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(); 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) => public static string Html(string markdown) =>
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim(); string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
public static string Timestamp(DateTime value) => public static string Timestamp(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); 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) public static JsonObject Actor(LocalActor actor)
{ {
var document = new JsonObject var document = new JsonObject
{ {
["@context"] = new JsonArray(ActivityStreams, "https://w3id.org/security/v1"), ["@context"] = Context(),
["id"] = actor.Uri, ["id"] = actor.Uri,
["type"] = actor.Kind switch ["type"] = actor.Kind switch
{ {
@@ -38,21 +70,29 @@ namespace PrivaPub.Federation.Rendering
["preferredUsername"] = actor.UserName, ["preferredUsername"] = actor.UserName,
["name"] = actor.Name, ["name"] = actor.Name,
["summary"] = Html(actor.Summary), ["summary"] = Html(actor.Summary),
["url"] = actor.Uri, ["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
["inbox"] = actor.Inbox, ["inbox"] = actor.Inbox,
["outbox"] = actor.Outbox, ["outbox"] = actor.Outbox,
["followers"] = actor.Followers, ["followers"] = actor.Followers,
["following"] = actor.Following, ["following"] = actor.Following,
["published"] = Timestamp(actor.Published), ["published"] = Day(actor.Published),
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers, ["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
["discoverable"] = actor.Discoverable, ["discoverable"] = actor.Discoverable,
["indexable"] = false,
["webfinger"] = actor.Handle,
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox }, ["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
["publicKey"] = new JsonObject ["publicKey"] = new JsonObject
{ {
["id"] = actor.KeyId, ["id"] = actor.KeyId,
["owner"] = actor.Uri, ["owner"] = actor.Uri,
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem) ["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)) if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = 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) public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
{ {
var to = new JsonArray(Public); var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var cc = new JsonArray(author.Followers); 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) if (group != default)
cc.Add(group.Uri); cc.Add(group.Uri);
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning, var note = NoteBody(post, author, to, cc, inReplyTo);
post.CreationDate, to, cc, inReplyTo);
if (group != default) if (group != default)
note["audience"] = group.Uri; note["audience"] = group.Uri;
return note; return note;
@@ -78,48 +123,84 @@ namespace PrivaPub.Federation.Rendering
public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients, public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
string context) string context)
{ {
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning, var note = NoteBody(post, author, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), post.InReplyToURI);
post.CreationDate, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), default); var named = post.Mentions.Select(m => m.ActorURI).ToHashSet();
note["tag"] = new JsonArray(recipients.Select(r => (JsonNode)new JsonObject var tags = note["tag"]!.AsArray();
{ foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri)))
["type"] = "Mention", tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle });
["href"] = r.Uri, var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList();
["name"] = "@" + r.Handle if (unmentioned.Count > 0)
}).ToArray()); note["content"] = "<p>" + string.Join(" ", unmentioned.Select(r =>
note["content"] = string.Join(" ", recipients.Select(r => $"<span class=\"h-card\"><a href=\"{r.Uri}\" class=\"u-url mention\">@{r.Handle}</a></span>")) $"<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>"))
+ " " + note["content"]!.GetValue<string>(); + "</p>" + note["content"]!.GetValue<string>();
if (!string.IsNullOrEmpty(context)) if (!string.IsNullOrEmpty(context))
note["context"] = context; note["context"] = context;
return note; return note;
} }
static JsonObject NoteBody(string id, LocalActor author, string title, string text, bool sensitive, static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
DateTime published, 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 var note = new JsonObject
{ {
["id"] = id, ["id"] = author.PostUri(post.ID),
["type"] = "Note", ["type"] = "Note",
["attributedTo"] = author.Uri, ["attributedTo"] = author.Uri,
["content"] = Html(text), ["content"] = content,
["published"] = Timestamp(published), ["published"] = Timestamp(post.CreationDate),
["url"] = id, ["url"] = author.PostHtmlUrl(post.ID),
["to"] = to, ["to"] = to,
["cc"] = cc, ["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)) if (!string.IsNullOrEmpty(post.Language))
note["summary"] = title; note["contentMap"] = new JsonObject { [post.Language] = content };
else if (!string.IsNullOrEmpty(title)) if (!string.IsNullOrEmpty(post.Title))
note["name"] = 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)) if (!string.IsNullOrEmpty(inReplyTo))
note["inReplyTo"] = inReplyTo; note["inReplyTo"] = inReplyTo;
if (post.EditedAt.HasValue)
note["updated"] = Timestamp(post.EditedAt.Value);
return note; 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() public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
{ {
["@context"] = ActivityStreams, ["@context"] = Context(),
["id"] = actor.ActivityUri(activityId), ["id"] = actor.ActivityUri(activityId),
["type"] = "Create", ["type"] = "Create",
["actor"] = actor.Uri, ["actor"] = actor.Uri,
@@ -161,7 +242,7 @@ namespace PrivaPub.Federation.Rendering
["object"] = follow.DeepClone() ["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 var collection = new JsonObject
{ {
@@ -170,9 +251,28 @@ namespace PrivaPub.Federation.Rendering
["type"] = "OrderedCollection", ["type"] = "OrderedCollection",
["totalItems"] = totalItems ["totalItems"] = totalItems
}; };
if (first != default)
collection["first"] = first;
if (items != default) if (items != default)
collection["orderedItems"] = new JsonArray(items.ToArray()); collection["orderedItems"] = new JsonArray(items.ToArray());
return collection; 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.Outbox;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Domain.Content;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -49,6 +50,7 @@ namespace PrivaPub.Middleware
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value)); SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
return service return service
.AddSingleton<IFederationHttp, FederationHttp>() .AddSingleton<IFederationHttp, FederationHttp>()
.AddSingleton<IContentRenderer, ContentRenderer>()
.AddSingleton<ILocalActorService, LocalActorService>() .AddSingleton<ILocalActorService, LocalActorService>()
.AddSingleton<IRemoteActorService, RemoteActorService>() .AddSingleton<IRemoteActorService, RemoteActorService>()
.AddSingleton<IDeliveryService, DeliveryService>() .AddSingleton<IDeliveryService, DeliveryService>()
+24 -6
View File
@@ -13,6 +13,7 @@ using PrivaPub.StaticServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post; using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -36,6 +37,7 @@ namespace PrivaPub.Services
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery; readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IStringLocalizer<GenericRes> _localizer; readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger; readonly ILogger<PostsService> _logger;
@@ -43,6 +45,7 @@ namespace PrivaPub.Services
ILocalActorService localActors, ILocalActorService localActors,
IRemoteActorService remoteActors, IRemoteActorService remoteActors,
IDeliveryService delivery, IDeliveryService delivery,
IContentRenderer content,
IStringLocalizer<GenericRes> localizer, IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger) ILogger<PostsService> logger)
{ {
@@ -50,6 +53,7 @@ namespace PrivaPub.Services
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
_delivery = delivery; _delivery = delivery;
_content = content;
_localizer = localizer; _localizer = localizer;
_logger = logger; _logger = logger;
} }
@@ -76,6 +80,7 @@ namespace PrivaPub.Services
} }
var parent = await Parent(form.AnsweringToPostId, token); var parent = await Parent(form.AnsweringToPostId, token);
var rendered = await _content.Markdown(form.Text, token);
var post = new PostEntity var post = new PostEntity
{ {
GroupUserId = author.Id, GroupUserId = author.Id,
@@ -84,8 +89,10 @@ namespace PrivaPub.Services
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public, Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
Title = form.Title, Title = form.Title,
Text = form.Text, Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text), ContentHtml = rendered.Html,
ContentFormat = ContentFormat.Markdown, ContentFormat = ContentFormat.Markdown,
Mentions = rendered.Mentions.Select(ToMention).ToList(),
Tags = rendered.Tags.ToList(),
HasContentWarning = form.HasContentWarning, HasContentWarning = form.HasContentWarning,
AnsweringToPostId = parent?.ID, AnsweringToPostId = parent?.ID,
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId), InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
@@ -95,7 +102,7 @@ namespace PrivaPub.Services
}; };
post.ID = (string)post.GenerateNewID(); post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID); post.ObjectURI = author.PostUri(post.ID);
post.Url = post.ObjectURI; post.Url = author.PostHtmlUrl(post.ID);
if (isLocalOnly) if (isLocalOnly)
{ {
@@ -113,7 +120,10 @@ namespace PrivaPub.Services
if (parent != default) if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); 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) if (group != default)
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token); 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 = new DmGroup { Members = members, ParticipantsKey = key };
dmGroup.ID = (string)dmGroup.GenerateNewID(); dmGroup.ID = (string)dmGroup.GenerateNewID();
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}"; dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID);
await DB.Default.SaveAsync(dmGroup, token); await DB.Default.SaveAsync(dmGroup, token);
} }
} }
@@ -261,14 +271,14 @@ namespace PrivaPub.Services
Visibility = PostVisibility.Direct, Visibility = PostVisibility.Direct,
ContextURI = dmGroup.ConversationURI, ContextURI = dmGroup.ConversationURI,
Text = form.Text, Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text), ContentHtml = (await _content.Markdown(form.Text, token)).Html,
ContentFormat = ContentFormat.Markdown, ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning, HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri ActorURI = author.Uri
}; };
dm.ID = (string)dm.GenerateNewID(); dm.ID = (string)dm.GenerateNewID();
dm.ObjectURI = author.PostUri(dm.ID); 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 remote = new List<(string Uri, string Handle)>();
var inboxes = new List<string>(); var inboxes = new List<string>();
@@ -377,6 +387,14 @@ namespace PrivaPub.Services
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token); : 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) => static string RemoteUri(string answeringTo) =>
answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default; answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default;