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
This commit is contained in:
2026-10-12 15:15:38 +02:00
parent 69bb1cc95d
commit 71fa597830
16 changed files with 811 additions and 8 deletions

View File

@@ -38,6 +38,7 @@ Use cases:
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Rich text: `type: richeditor` edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus `size`; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through [seaglass](../seaglass/README.md) before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in `meta.notices` under the field (`backend::lang.richeditor.removed_elements`, `removed_attributes`, `removed_comments` with the count and names, or `markup_changed`).
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); the form schema carries `dependsOn`. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
@@ -335,7 +336,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
## Dependencies
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md).
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md).
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture.

View File

@@ -3,12 +3,15 @@ package cabana_test
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
@@ -33,10 +36,14 @@ type companionsLink struct {
Title string `gorm:"column:title"`
Type *string `gorm:"column:type"`
Reference *string `gorm:"column:reference"`
// Description is the richeditor field.
Description *string `gorm:"column:description"`
}
func (companionsLink) TableName() string { return "cabana_companions_links" }
func (companionsLink) Fillable() []string { return []string{"title", "type", "reference"} }
func (companionsLink) TableName() string { return "cabana_companions_links" }
func (companionsLink) Fillable() []string {
return []string{"title", "type", "reference", "description"}
}
func (companionsLink) Rules() map[string]string { return map[string]string{"title": "required"} }
// companionsModels are the fixture tables, dropped and recreated per env.
@@ -73,7 +80,7 @@ func (companionsPlugin) MenuItemTypes() []pact.MenuItemType {
}
var (
_ pact.HasMenuItemTypes = companionsPlugin{}
_ pact.HasMenuItemTypes = companionsPlugin{}
_ pact.DependentOptionsProvider = companionsLinks{}
_ pact.DropdownOptionsProvider = companionsLinks{}
)
@@ -116,7 +123,7 @@ func newCompanionsEnv(t *testing.T) *conformEnv {
t.Helper()
gdb := adminGorm(t)
resetCompanions(t, gdb)
login := "companions-" + filepath.Base(t.TempDir())
login := fmt.Sprintf("companions-%d", time.Now().UnixNano())
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
@@ -223,3 +230,72 @@ func TestCompanionsDependentSmoke(t *testing.T) {
t.Fatalf("form schema does not carry dependsOn: %s", schema.Body.String())
}
}
// companionsRecord is a create or update answer of the links controller.
type companionsRecord struct {
Data map[string]any `json:"data"`
Meta struct {
Notices []cabana.FieldNotice `json:"notices"`
} `json:"meta"`
}
func decodeCompanionsRecord(t *testing.T, raw []byte) companionsRecord {
t.Helper()
var rec companionsRecord
if err := json.Unmarshal(raw, &rec); err != nil {
t.Fatalf("record: %v\n%s", err, raw)
}
return rec
}
// TestCompanionsRichEditorSmoke checks the D-10 save gate: a richeditor value
// is stored sanitized with notices naming what was removed, clean HTML is
// stored byte for byte with no notice, and a value that is not a string is a
// 422 on the field.
func TestCompanionsRichEditorSmoke(t *testing.T) {
e := newCompanionsEnv(t)
dirty := `<p onclick="steal()">Answer</p><script>alert(1)</script>`
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dirty", "description": dirty}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
created := decodeCompanionsRecord(t, rec.Body.Bytes())
id := dataID(t, rec.Body.Bytes())
var stored companionsLink
if err := e.db.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
if stored.Description == nil || *stored.Description != "<p>Answer</p>" {
t.Fatalf("stored description = %v", stored.Description)
}
var fields []string
for _, n := range created.Meta.Notices {
fields = append(fields, n.Field)
if n.Field != "description" || n.Message == "" {
t.Fatalf("notice = %+v", n)
}
}
if len(fields) != 2 {
t.Fatalf("notices = %+v, want removed elements and attributes on description", created.Meta.Notices)
}
clean := "<p>It's <strong>fine</strong> &amp; \"quoted\"</p>\n<ul><li>one</li></ul>"
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), map[string]any{"title": "Clean", "description": clean}, true)
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
if updated := decodeCompanionsRecord(t, rec.Body.Bytes()); len(updated.Meta.Notices) != 0 {
t.Fatalf("clean HTML reported notices: %+v", updated.Meta.Notices)
}
if err := e.db.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
if stored.Description == nil || *stored.Description != clean {
t.Fatalf("clean description not stored byte for byte: %v", stored.Description)
}
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), map[string]any{"title": "Clean", "description": []any{"<p>x</p>"}}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"description"`) {
t.Fatalf("non-string description status=%d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -607,6 +607,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
// Plugin field values are lifted the same way and decoded by their type
// inside the transaction, once the record is loaded.
pluginValues := liftPluginValues(cc, in.Body, op)
// A richeditor value is a string or null; anything else is refused on
// the field instead of being dropped by the projection.
if err := checkRichEditorValues(cc, in.Body, op); err != nil {
return RecordResult{}, err
}
var (
result RecordResult
notices []pluginNotice
@@ -631,7 +636,8 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return err
}
notices = decoded.notices
// D-10: every richeditor value is sanitized before Fill sees it.
notices = append(decoded.notices, sanitizeRichEditors(cc, projected)...)
// The Form hooks read what this save writes for plugin field types.
ctx = withFieldWrites(ctx, decoded.writes)
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
@@ -1244,7 +1250,7 @@ func modelColumns(model any) map[string]struct{} {
func scalarFormField(typ string) bool {
switch typ {
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
"markdown", "mltext", "mltextarea", "mlmarkdown":
"markdown", "mltext", "mltextarea", "mlmarkdown", "richeditor":
return true
default:
return false

View File

@@ -106,6 +106,9 @@ func requireDependentProvider(ctl pact.AdminController, fields []FormField) erro
// that only an admin controller's save path supports: its checks run in the
// controller save and nowhere else.
func controllerFormOnly(field FormField) error {
if field.Type == richEditorType {
return fmt.Errorf("richeditor is supported in controller forms only")
}
if field.DependsOn != "" {
return fmt.Errorf("dependsOn is supported in controller forms only")
}

View File

@@ -0,0 +1,135 @@
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
}

View File

@@ -27,6 +27,7 @@ var (
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {}, "permissioneditor": {},
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
"richeditor": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -512,6 +513,9 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
if _, ok := formFieldTypes[typ]; !ok && plugin == nil {
return FormField{}, fmt.Errorf("unsupported type %s", typ)
}
if err := compileRichEditorKeys(typ, values); err != nil {
return FormField{}, err
}
field := FormField{Name: name, Type: typ}
if node, ok := values["label"]; ok {
field.Label, err = nodeString(node)

View File

@@ -12,3 +12,7 @@ fields:
type: dropdown
dependsOn: type
emptyOption: backend::lang.form.none
description:
label: Description
type: richeditor
size: large