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 Mentions, IReadOnlyList Tags); public interface IContentRenderer { Task Markdown(string text, CancellationToken token); Task 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 Markdown(string text, CancellationToken token) => Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token); public Task 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 => "

" + Linkify(WebUtility.HtmlEncode(p)).Replace("\n", "
") + "

"); return Decorate(string.Concat(paragraphs), token); } async Task Decorate(string html, CancellationToken token) { var document = Parser.ParseDocument($"{html}"); var textNodes = document.Body.Descendants() .Where(node => !node.Ancestors().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(StringComparer.OrdinalIgnoreCase); foreach (var handle in handles) { var resolved = await Resolve(handle, token); if (resolved != default) mentions[handle] = resolved; } var tags = new List(); 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 mentions, List 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 $"@{WebUtility.HtmlEncode(mention.UserName)}"; }); 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}#{tag}"; }); return changed ? encoded : default; } async Task 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 => $"{match.Value}"); [GeneratedRegex(@"(?^|[^\w&#/])#(?[\p{L}\p{N}_]{1,100})(?![\p{L}\p{N}_])")] private static partial Regex HashtagPattern(); [GeneratedRegex(@"https?://[^\s<]+[^\s<.,;:!?)\]'""]")] private static partial Regex UrlPattern(); } }