feat(classes): report what the HTML policy removes
- SanitizeHTMLReport returns the policy output plus HTMLRemovals: removed elements, attributes (repeats and changed values included) and comments, at most ten distinct names per list with exact counts - SanitizeHTML is the string half of it; FormatHTML, FormatTranslation and CheckHTML keep their behaviour
This commit is contained in:
@@ -79,12 +79,200 @@ func FormatHTML(src string) (string, error) {
|
||||
// FormatHTML allow-list and strips what the policy does not allow. HTML the
|
||||
// policy keeps whole is returned byte for byte. It is the gate for
|
||||
// content_html supplied without markdown, such as a translation that carries
|
||||
// only content_html.
|
||||
// only content_html. It is the string half of SanitizeHTMLReport.
|
||||
func SanitizeHTML(doc string) string {
|
||||
out, _ := sanitizeHTML(doc)
|
||||
out, _ := SanitizeHTMLReport(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// maxRemovalNames is how many distinct names an HTMLRemovals list keeps.
|
||||
const maxRemovalNames = 10
|
||||
|
||||
// HTMLRemovals reports what SanitizeHTMLReport removed from a document.
|
||||
// Elements and Attributes are the distinct removed element and attribute
|
||||
// names in first-seen order, at most ten each; ElementCount and
|
||||
// AttributeCount count every removal, so they stay exact when a list is cut.
|
||||
// An attribute counts as removed when the policy dropped it, when it is a
|
||||
// repeat of a name the tag already has, or when the policy changed its value
|
||||
// (a style attribute that lost a property counts under "style"). Comments
|
||||
// counts removed HTML comments. Other is set when the policy changed the
|
||||
// document in a way none of the other fields describe.
|
||||
type HTMLRemovals struct {
|
||||
Elements []string
|
||||
ElementCount int
|
||||
Attributes []string
|
||||
AttributeCount int
|
||||
Comments int
|
||||
Other bool
|
||||
|
||||
// moreElements and moreAttributes are set when a list was cut.
|
||||
moreElements bool
|
||||
moreAttributes bool
|
||||
}
|
||||
|
||||
// Empty reports whether nothing was removed, which is exactly when
|
||||
// SanitizeHTMLReport returned its input unchanged.
|
||||
func (r HTMLRemovals) Empty() bool {
|
||||
return r.ElementCount == 0 && r.AttributeCount == 0 && r.Comments == 0 && !r.Other
|
||||
}
|
||||
|
||||
func (r *HTMLRemovals) addElement(name string) {
|
||||
r.ElementCount++
|
||||
r.Elements, r.moreElements = addRemovalName(r.Elements, r.moreElements, name)
|
||||
}
|
||||
|
||||
func (r *HTMLRemovals) addAttribute(name string) {
|
||||
r.AttributeCount++
|
||||
r.Attributes, r.moreAttributes = addRemovalName(r.Attributes, r.moreAttributes, name)
|
||||
}
|
||||
|
||||
func addRemovalName(names []string, cut bool, name string) ([]string, bool) {
|
||||
if slices.Contains(names, name) {
|
||||
return names, cut
|
||||
}
|
||||
if len(names) >= maxRemovalNames {
|
||||
return names, true
|
||||
}
|
||||
return append(names, name), cut
|
||||
}
|
||||
|
||||
// SanitizeHTMLReport is SanitizeHTML with a report of what the policy
|
||||
// removed. HTML the policy keeps whole is returned byte for byte with an
|
||||
// empty report. Otherwise the policy's output is returned and the report is
|
||||
// built by aligning the start tags of doc with those of the output in order
|
||||
// (the policy only removes, it never adds or reorders): an input tag without
|
||||
// a counterpart is a removed element, and an attribute of a matched tag that
|
||||
// the output lacks or holds with another value is a removed attribute.
|
||||
// Comments are counted on their own. The report is empty exactly when the
|
||||
// returned string equals doc.
|
||||
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
|
||||
clean, changed := sanitizeHTML(doc)
|
||||
if !changed || clean == doc {
|
||||
return doc, HTMLRemovals{}
|
||||
}
|
||||
report := removalReport(doc, clean)
|
||||
if report.Empty() {
|
||||
report.Other = true
|
||||
}
|
||||
return clean, report
|
||||
}
|
||||
|
||||
// reportTag is one start tag of a document as removalReport sees it.
|
||||
type reportTag struct {
|
||||
name string
|
||||
raw []string // attribute names in source order, repeats included
|
||||
attrs map[string]string // decoded values, the first of a repeated name
|
||||
}
|
||||
|
||||
// reportTokens returns doc's start tags and its number of comments.
|
||||
func reportTokens(doc string) ([]reportTag, int) {
|
||||
var (
|
||||
tags []reportTag
|
||||
comments int
|
||||
)
|
||||
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||
for {
|
||||
tt := z.Next()
|
||||
switch tt {
|
||||
case xhtml.ErrorToken:
|
||||
return tags, comments
|
||||
case xhtml.CommentToken:
|
||||
comments++
|
||||
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||
raw := rawAttrNames(z.Raw())
|
||||
t := z.Token()
|
||||
attrs := make(map[string]string, len(t.Attr))
|
||||
for _, a := range t.Attr {
|
||||
key := a.Key
|
||||
if a.Namespace != "" {
|
||||
key = a.Namespace + ":" + a.Key
|
||||
}
|
||||
if _, seen := attrs[key]; !seen {
|
||||
attrs[key] = a.Val
|
||||
}
|
||||
}
|
||||
tags = append(tags, reportTag{name: t.Data, raw: raw, attrs: attrs})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keeps reports whether out can be the policy's output of in: the same
|
||||
// element, and every output attribute present in the input with the same
|
||||
// value, except style, whose properties the policy filters one by one.
|
||||
func (in reportTag) keeps(out reportTag) bool {
|
||||
if in.name != out.name {
|
||||
return false
|
||||
}
|
||||
for key, val := range out.attrs {
|
||||
got, ok := in.attrs[key]
|
||||
if !ok || (got != val && key != "style") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// removalReport compares doc with the policy's output clean. It aligns the
|
||||
// start tags greedily, preferring an output tag whose attributes the input
|
||||
// tag can have produced; when that leaves output tags unmatched, it aligns
|
||||
// by element name only, which always succeeds when the output's tags are a
|
||||
// subsequence of the input's.
|
||||
func removalReport(doc, clean string) HTMLRemovals {
|
||||
in, inComments := reportTokens(doc)
|
||||
out, outComments := reportTokens(clean)
|
||||
match := alignTags(in, out, reportTag.keeps)
|
||||
if match == nil {
|
||||
match = alignTags(in, out, func(a, b reportTag) bool { return a.name == b.name })
|
||||
}
|
||||
var r HTMLRemovals
|
||||
for i, tag := range in {
|
||||
j := -1
|
||||
if match != nil {
|
||||
j = match[i]
|
||||
}
|
||||
if j < 0 {
|
||||
r.addElement(tag.name)
|
||||
continue
|
||||
}
|
||||
kept := out[j]
|
||||
seen := make(map[string]bool, len(tag.raw))
|
||||
for _, name := range tag.raw {
|
||||
if seen[name] {
|
||||
r.addAttribute(name) // a repeat the tokenizer dropped
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
val, ok := kept.attrs[name]
|
||||
if !ok || val != tag.attrs[name] {
|
||||
r.addAttribute(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if inComments > outComments {
|
||||
r.Comments = inComments - outComments
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// alignTags matches every tag of out to a tag of in, in order, with the
|
||||
// first input tag for which same holds. The result maps each input index to
|
||||
// its output index or -1. It is nil when an output tag stays unmatched.
|
||||
func alignTags(in, out []reportTag, same func(in, out reportTag) bool) []int {
|
||||
match := make([]int, len(in))
|
||||
j := 0
|
||||
for i := range in {
|
||||
match[i] = -1
|
||||
if j < len(out) && same(in[i], out[j]) {
|
||||
match[i] = j
|
||||
j++
|
||||
}
|
||||
}
|
||||
if j < len(out) {
|
||||
return nil
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// FormatTranslation sets attrs["content_html"] for one locale's translated
|
||||
// post attributes, so a translated content_html is stored under the same
|
||||
// policy as the host column. When attrs["content"] is not blank, content_html
|
||||
|
||||
Reference in New Issue
Block a user