Communities: - a post addressed to a community (to, cc or audience) is accepted according to its posting policy - followers, anyone, or moderators - and GroupDistributor announces the whole activity with `audience` to the community's followers, plus the object for new posts so Mastodon shows them; updates and deletes of community content are announced too; - top-level posts are Pages with a name (the title, or a headline from the text); /flock counts members, /wardens lists moderators; - a Mastodon client posts into a community by mentioning it, or into a remote group, which sets `audience`; - an Announce of an activity from a remote group a persona follows (Lemmy) is followed through: the object is fetched from its own origin, kept with its AudienceURI, and fanned out to the group's local followers; updates are applied in place and deletes checked against the origin. Circles stop being local-only: an undiscoverable Group actor whose follows are all requests the owner approves; posts addressed to the circle and its /flock and delivered to members' own inboxes, never announced, never public; a remote member's post into the circle is accepted from members only. SignedFetchAuthorizer serves circle posts and collections only to a signed request from a member or a member server's instance actor - 404 for anyone else. Circles never surface in search, lookups, mentions, account ids or profile pages. Federation:SecureMode requires a valid signature on every GET under /peasants except the instance actor. Group forms take a posting policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
151 lines
5.7 KiB
C#
151 lines
5.7 KiB
C#
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, IsCircle: false, 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();
|
|
}
|
|
}
|