Files
SocialPub/PrivaPub/Federation/Objects/ContentSanitizer.cs
T
thepraandClaude Opus 5.5 6f1ab0073c
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 54s
P6: quote posts, received and sent (FEP-044f and the older keys)
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post
  is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent
  quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts
  and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed
  from content when the real quote is shown.
- Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until
  an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted
  the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts
  are delivered to the quoted author too.
- Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote
  policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7.

Checked live: GoToSocial's author-only quote policy is respected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:53:28 +02:00

81 lines
2.7 KiB
C#

using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using Ganss.Xss;
using System.Text.RegularExpressions;
namespace PrivaPub.Federation.Objects
{
public static partial class ContentSanitizer
{
static readonly HtmlSanitizer Sanitizer = Build();
public static string Html(string html) =>
string.IsNullOrWhiteSpace(html) ? string.Empty : Sanitizer.Sanitize(html).Trim();
static HtmlSanitizer Build()
{
var sanitizer = new HtmlSanitizer(new HtmlSanitizerOptions
{
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"p", "br", "span", "a", "abbr", "del", "s", "pre", "blockquote", "code", "b", "strong", "u", "i", "em",
"sub", "sup", "ul", "ol", "li", "ruby", "rt", "rp", "h1", "h2", "h3", "h4", "h5", "h6"
},
AllowedAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"href", "rel", "class", "translate", "start", "reversed", "value", "title"
},
AllowedCssProperties = new HashSet<string>(),
AllowedAtRules = new HashSet<CssRuleType>(),
AllowedSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"http", "https", "dat", "dweb", "ipfs", "ipns", "ssb", "gopher", "xmpp", "magnet", "gemini"
},
UriAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "href" }
})
{
KeepChildNodes = true
};
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible", "quote-inline" })
sanitizer.AllowedClasses.Add(allowed);
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
sanitizer.RemovingTag += (_, e) =>
{
if (e.Tag.LocalName is "script" or "style" or "template" or "iframe" or "object" or "embed" or "noscript" or "svg" or "math")
e.Tag.InnerHtml = string.Empty;
};
sanitizer.PostProcessNode += (_, e) =>
{
if (e.Node is not IElement element)
return;
switch (element.LocalName)
{
case "a":
if (!SchemePrefix().IsMatch(element.GetAttribute("href") ?? string.Empty))
element.RemoveAttribute("href");
element.SetAttribute("rel", "nofollow noopener noreferrer");
element.SetAttribute("target", "_blank");
break;
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
var paragraph = e.Document.CreateElement("p");
var strong = e.Document.CreateElement("strong");
while (element.FirstChild != default)
strong.AppendChild(element.FirstChild);
paragraph.AppendChild(strong);
e.ReplacementNodes.Add(paragraph);
break;
}
};
return sanitizer;
}
[GeneratedRegex("^[a-z][a-z0-9+.-]*:", RegexOptions.IgnoreCase)]
private static partial Regex SchemePrefix();
[GeneratedRegex("^(h|p|u|dt|e)-[a-z0-9-]+$")]
private static partial Regex MicroformatClass();
}
}