Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
156 lines
6.1 KiB
C#
156 lines
6.1 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)}";
|
|
|
|
/// <summary>A text of the server's own (its description, privacy policy, terms, an announcement) from Markdown to
|
|
/// sanitized HTML: the posts' pipeline (raw HTML off), without mentions or hashtags, so nothing is looked up.</summary>
|
|
public static string Document(string markdown) =>
|
|
string.IsNullOrWhiteSpace(markdown) ? string.Empty : ContentSanitizer.Html(Markdig.Markdown.ToHtml(markdown, Pipeline));
|
|
|
|
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, IsServerActor: false }
|
|
? 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();
|
|
}
|
|
}
|