Files
summercms/modules/cabana/field_richeditor.go
Jakub Zych 71fa597830 feat(17-01): richeditor field type on the seaglass sanitizer
- seaglass (new module): bluemonday v1.0.27 policy ported from Journal
  without the Blog Editor rules, byte-stable token comparison,
  SanitizeReport, Sanitize, Check, Removals
- cabana: type richeditor (common keys plus size); every create and update
  value sanitized before Fill, non-string values are a 422, removals become
  meta.notices; settings and relation forms refuse the type
- backend lang richeditor.removed_* and markup_changed (en, pl plurals)
- TestSeaglassSmoke, TestCompanionsRichEditorSmoke, README and docs
2026-10-12 15:15:38 +02:00

136 lines
4.0 KiB
Go

package cabana
import (
"fmt"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/seaglass"
"github.com/goccy/go-yaml/ast"
)
// richEditorType is the core HTML WYSIWYG field type (D-10).
const richEditorType = "richeditor"
// The phrase keys of the notices a richeditor save reports (backend::lang).
const (
msgRichRemovedElements = "backend::lang.richeditor.removed_elements"
msgRichRemovedAttributes = "backend::lang.richeditor.removed_attributes"
msgRichRemovedComments = "backend::lang.richeditor.removed_comments"
msgRichMarkupChanged = "backend::lang.richeditor.markup_changed"
)
// richEditorKeys are the fields.yaml keys a richeditor field accepts: the
// common field keys plus size.
var richEditorKeys = map[string]bool{
"label": true, "comment": true, "span": true, "type": true, "required": true,
"tab": true, "context": true, "size": true, "default": true,
}
// compileRichEditorKeys refuses every key a richeditor field does not take.
func compileRichEditorKeys(typ string, values map[string]ast.Node) error {
if typ != richEditorType {
return nil
}
for key := range values {
if !richEditorKeys[key] {
return fmt.Errorf("%s is not valid on type: richeditor", key)
}
}
return nil
}
// checkRichEditorValues refuses, before the save transaction, a submitted
// richeditor value that is neither a string nor null: it is a 422 on the
// field, never silently dropped.
func checkRichEditorValues(cc *CompiledController, body map[string]any, op string) error {
if cc == nil || cc.Form == nil {
return nil
}
for _, field := range cc.Form.Fields {
if field.Type != richEditorType || !contextAllows(cc, field.Name, op) {
continue
}
value, present := body[field.Name]
if !present {
continue
}
switch value.(type) {
case nil, string:
default:
return &ValidationError{Details: fillTypeDetails(field.Name)}
}
}
return nil
}
// sanitizeRichEditors runs every projected richeditor value through
// seaglass.SanitizeReport and writes the clean value back, so no richeditor
// value reaches Fill unsanitized. HTML the policy keeps whole is stored byte
// for byte; what it removed is returned as notices on the field.
func sanitizeRichEditors(cc *CompiledController, projected map[string]any) []pluginNotice {
if cc == nil || cc.Form == nil {
return nil
}
var notices []pluginNotice
for _, field := range cc.Form.Fields {
if field.Type != richEditorType {
continue
}
key := fillKeyOf(cc, field.Name)
value, present := projected[key]
if !present {
continue
}
text, ok := value.(string)
if !ok {
continue
}
clean, report := seaglass.SanitizeReport(text)
projected[key] = clean
for _, notice := range richEditorNotices(report) {
notices = append(notices, pluginNotice{field: field.Name, notice: notice})
}
}
return notices
}
// fillKeyOf is the fill key bound to a writable field name.
func fillKeyOf(cc *CompiledController, name string) string {
for _, field := range cc.Writable {
if field.Name == name {
return field.FillKey
}
}
return name
}
// richEditorNotices turns a removal report into admin notices: removed
// elements, removed attributes, removed comments, in that order and only when
// non-zero, and markup_changed when the policy changed the HTML in a way none
// of them describe.
func richEditorNotices(r seaglass.Removals) []pact.FormFieldNotice {
var out []pact.FormFieldNotice
if r.ElementCount > 0 {
out = append(out, pact.FormFieldNotice{
Message: msgRichRemovedElements,
Params: map[string]string{"names": strings.Join(r.Elements, ", ")},
Count: r.ElementCount,
})
}
if r.AttributeCount > 0 {
out = append(out, pact.FormFieldNotice{
Message: msgRichRemovedAttributes,
Params: map[string]string{"names": strings.Join(r.Attributes, ", ")},
Count: r.AttributeCount,
})
}
if r.Comments > 0 {
out = append(out, pact.FormFieldNotice{Message: msgRichRemovedComments, Count: r.Comments})
}
if r.Changed && len(out) == 0 {
out = append(out, pact.FormFieldNotice{Message: msgRichMarkupChanged})
}
return out
}