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:
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'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 <two></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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> <b>cats</b></p>", actor["summary"]!.GetValue<string>());
|
||||||
|
var fields = actor["attachment"]!.AsArray();
|
||||||
|
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
|
||||||
|
Assert.Equal("<i>ok</i>", 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 <cats></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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
if (!page)
|
||||||
|
{
|
||||||
var total = local.Kind == LocalActorKind.Group
|
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.GroupId == local.Id && !p.DeletedAt.HasValue
|
||||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
|
&& (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>()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user