feat(14.2.1-03): add markdown and multilingual cabana fields

Lift locale maps before ProjectWritableFields so a Journal-shaped save can persist the default host scalar and non-default locales through TranslationWriter without dropping nested JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 12:32:54 +02:00
parent 234111d245
commit a04116d41f
13 changed files with 895 additions and 3 deletions

View File

@@ -30,6 +30,9 @@ type CRUDService struct {
// commit; tr localizes the file limit messages. Both may be nil.
bucket *blob.Bucket
tr *phrasebook.Translator
// writer persists non-default locale values for mltext/mlmarkdown fields.
// Nil when the translate plugin is not mounted.
writer TranslationWriter
}
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
@@ -588,6 +591,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return RecordResult{}, err
}
// ML locale maps are lifted before scalar projection, which drops every
// nested value. The default-locale string is written back onto the body
// so Fill sees a host scalar.
translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)
if err != nil {
return RecordResult{}, err
}
var result RecordResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withVirtualFields(withTx(ctx, tx), virtual)
@@ -670,6 +680,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return lifecycleFailure(cc, err)
}
// Non-default locales are written only after the host row has an id
// and after permission, query scope, and relation locks have passed.
if err := applyMLTranslations(ctx, tx, s.writer, target, translations); err != nil {
return err
}
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
return err
}
@@ -1202,7 +1217,8 @@ func modelColumns(model any) map[string]struct{} {
func scalarFormField(typ string) bool {
switch typ {
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker":
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
"markdown", "mltext", "mlmarkdown":
return true
default:
return false

View File

@@ -0,0 +1,46 @@
package cabana
import (
"bytes"
"fmt"
"regexp"
"github.com/yuin/goldmark"
)
var (
markdownEngine = goldmark.New()
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
)
// RenderMarkdown converts source to HTML using the pinned goldmark engine
// without html.WithUnsafe. Output that still contains script/iframe tags,
// event handlers, or javascript/vbscript/data URLs is rejected, matching
// postcard's mail HTML gate.
func RenderMarkdown(src string) (string, error) {
var buf bytes.Buffer
if err := markdownEngine.Convert([]byte(src), &buf); err != nil {
return "", fmt.Errorf("cabana: markdown: %w", err)
}
html := buf.String()
if err := rejectUnsafeMarkdownHTML(html); err != nil {
return "", err
}
return html, nil
}
func rejectUnsafeMarkdownHTML(html string) error {
if markdownUnsafeTag.MatchString(html) {
return fmt.Errorf("cabana: rendered HTML contains raw unsafe tags")
}
if markdownEventHandler.MatchString(html) {
return fmt.Errorf("cabana: rendered HTML contains event handlers")
}
if markdownDangerousURL.MatchString(html) {
return fmt.Errorf("cabana: rendered HTML contains a dangerous URL scheme")
}
return nil
}

160
modules/cabana/field_ml.go Normal file
View File

@@ -0,0 +1,160 @@
package cabana
import (
"context"
"fmt"
"gorm.io/gorm"
)
const (
mlTextFieldType = "mltext"
mlMarkdownFieldType = "mlmarkdown"
)
// TranslationWriter persists non-default locale values during a cabana save.
// The interface lives in the framework so cabana never imports a plugin; a
// host publishes an adapter at Boot. There is no standalone translate-write
// HTTP endpoint: writes run only inside a permissioned, scoped host save
// transaction after the host row has a primary key.
type TranslationWriter interface {
// DefaultLocale is the host-column locale.
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
// EnabledLocales are the codes a save body may name, including the default.
EnabledLocales(ctx context.Context, tx *gorm.DB) ([]string, error)
// WriteTranslated stores field in locale for model. locale is never the
// default; cabana already filled the host scalar.
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
}
// mlValue is one multilingual field lifted from a save body.
type mlValue struct {
field string
values map[string]string
}
func mlFieldType(typ string) bool {
return typ == mlTextFieldType || typ == mlMarkdownFieldType
}
// liftMLValues takes declared mltext/mlmarkdown maps out of a save body
// before ProjectWritableFields drops nested values. Only fields present in
// the body whose context allows op are lifted. A value must be a JSON object
// of enabled locale code to string; unknown locales, extra keys, and
// non-string values are validation_failed on the field. The default-locale
// string is written back onto body so Fill sees a host scalar.
func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]any, op string, writer TranslationWriter, db *gorm.DB) ([]mlValue, error) {
if cc == nil || cc.Form == nil || body == nil {
return nil, nil
}
var names []string
for _, field := range cc.Form.Fields {
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
continue
}
if _, present := body[field.Name]; present {
names = append(names, field.Name)
}
}
if len(names) == 0 {
return nil, nil
}
details := map[string]any{}
needWriter := false
for _, name := range names {
if _, isMap := body[name].(map[string]any); isMap {
needWriter = true
break
}
}
var (
defaultLocale string
enabled map[string]struct{}
)
if needWriter {
if writer == nil {
for _, name := range names {
if _, isMap := body[name].(map[string]any); isMap {
details[name] = []string{"The " + name + " field cannot be translated."}
}
}
return nil, &ValidationError{Details: details}
}
code, err := writer.DefaultLocale(ctx, db)
if err != nil || code == "" {
return nil, fmt.Errorf("cabana: default locale: %w", err)
}
defaultLocale = code
codes, err := writer.EnabledLocales(ctx, db)
if err != nil {
return nil, fmt.Errorf("cabana: enabled locales: %w", err)
}
enabled = make(map[string]struct{}, len(codes))
for _, loc := range codes {
enabled[loc] = struct{}{}
}
}
var out []mlValue
for _, name := range names {
raw := body[name]
object, isMap := raw.(map[string]any)
if !isMap {
if nestedValue(raw) {
details[name] = []string{"The " + name + " field must be an object of locale codes to text."}
}
continue
}
values := make(map[string]string, len(object))
ok := true
for locale, item := range object {
if _, known := enabled[locale]; !known {
ok = false
break
}
text, isString := item.(string)
if !isString {
ok = false
break
}
values[locale] = text
}
if !ok {
details[name] = []string{"The " + name + " field must be an object of locale codes to text."}
continue
}
if _, hasDefault := values[defaultLocale]; !hasDefault {
details[name] = []string{"The " + name + " field must include the default locale."}
continue
}
body[name] = values[defaultLocale]
out = append(out, mlValue{field: name, values: values})
}
if len(details) > 0 {
return nil, &ValidationError{Details: details}
}
return out, nil
}
// applyMLTranslations writes non-default locale values through the published
// TranslationWriter. It runs after the host row write so the model has a
// primary key, and only with the tx of the host save.
func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) error {
if writer == nil || len(translations) == 0 {
return nil
}
defaultLocale, err := writer.DefaultLocale(ctx, tx)
if err != nil {
return fmt.Errorf("cabana: default locale: %w", err)
}
for _, item := range translations {
for locale, value := range item.values {
if locale == defaultLocale {
continue
}
if err := writer.WriteTranslated(ctx, tx, model, item.field, locale, value); err != nil {
return err
}
}
}
return nil
}

View File

@@ -26,6 +26,7 @@ var (
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {}, "permissioneditor": {},
"markdown": {}, "mltext": {}, "mlmarkdown": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},

View File

@@ -930,7 +930,13 @@ func (s *service) crud() (CRUDService, error) {
if err != nil {
return CRUDService{}, err
}
return CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}
if s.app != nil {
if w, ok := s.app.Lookup[TranslationWriter](); ok {
svc.writer = w
}
}
return svc, nil
}
func (s *service) list(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,261 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
const mlFormConfig = `name: posts
form: ~/plugins/acme/demo/models/post/fields.yaml
modelClass: Post
`
const mlListConfig = `modelClass: Post
list: ~/plugins/acme/demo/models/post/columns.yaml
recordsPerPage: 20
toolbar:
buttons: [create]
`
const mlFields = `fields:
title:
label: Title
type: mltext
required: true
`
const mlColumns = `columns:
title:
label: Title
searchable: true
`
type mlPost struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func (mlPost) TableName() string { return "cabana_ml_posts" }
func (mlPost) Fillable() []string { return []string{"title"} }
func (mlPost) Rules() map[string]string { return map[string]string{"title": "required"} }
type mlController struct{}
func (mlController) ID() string { return "acme.demo.posts" }
func (mlController) ModelName() string { return "Post" }
func (mlController) ConfigDir() string { return "controllers/posts" }
func (mlController) NewRecord() any { return &mlPost{} }
func (mlController) FormExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
return q
}
var (
_ pact.AdminController = mlController{}
_ pact.AdminRecordSource = mlController{}
_ pact.FormExtendQuery = mlController{}
)
type recordingWriter struct {
defaultLocale string
enabled []string
attrs map[string]map[string]string
ids []uint
}
func (w *recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) {
return w.defaultLocale, nil
}
func (w *recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
return append([]string(nil), w.enabled...), nil
}
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
post, _ := model.(*mlPost)
if post == nil || post.ID == 0 {
return &ValidationError{Details: map[string]any{field: []string{"translation writer ran before the host row had an id"}}}
}
w.ids = append(w.ids, post.ID)
if w.attrs == nil {
w.attrs = map[string]map[string]string{}
}
if w.attrs[locale] == nil {
w.attrs[locale] = map[string]string{}
}
w.attrs[locale][field] = value
return nil
}
func mlFS() fstest.MapFS {
return fstest.MapFS{
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)},
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte(mlFormConfig)},
"models/post/columns.yaml": &fstest.MapFile{Data: []byte(mlColumns)},
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(mlFields)},
}
}
func mlCompiled(t *testing.T) *CompiledController {
t.Helper()
reg, err := compileRegistry([]controllerRef{{
plugin: formPlugin{fsys: mlFS()},
ctl: mlController{},
}})
if err != nil {
t.Fatalf("registry: %v", err)
}
cc, ok := reg.Get("acme.demo.posts")
if !ok || cc.Form == nil {
t.Fatalf("compiled controller missing form: %+v", cc)
}
return cc
}
func TestMLNestedSaveSmoke(t *testing.T) {
t.Run("schema accepts markdown mltext mlmarkdown and rejects unknown", func(t *testing.T) {
accepted := `fields:
title:
type: mltext
label: Title
excerpt:
type: markdown
label: Excerpt
body:
type: mlmarkdown
label: Body
`
schema, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, accepted))
if err != nil {
t.Fatalf("compile accepted types: %v", err)
}
got := map[string]string{}
for _, field := range schema.Fields {
got[field.Name] = field.Type
}
if got["title"] != "mltext" || got["excerpt"] != "markdown" || got["body"] != "mlmarkdown" {
t.Fatalf("types = %v", got)
}
_, err = CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n title:\n type: mlunknown\n label: Title\n"))
if err == nil || !strings.Contains(err.Error(), "unsupported type mlunknown") {
t.Fatalf("unknown type err = %v", err)
}
})
t.Run("ProjectWritableFields still drops generic nested maps", func(t *testing.T) {
cc := mlCompiled(t)
projected := ProjectWritableFields(cc, map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"extra": map[string]any{"nested": true},
})
if len(projected) != 0 {
t.Fatalf("unlifted nested maps reached projection: %#v", projected)
}
})
t.Run("save lifts locales, fills English, writes Polish", func(t *testing.T) {
_, shared := newListService(t)
db := shared
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&mlPost{}); err != nil {
t.Fatal(err)
}
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
svc := CRUDService{DB: db, writer: writer}
cc := mlCompiled(t)
rec, err := svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"extra": map[string]any{"nested": true},
}})
if err != nil {
t.Fatalf("create: %v", err)
}
if rec["title"] != "Hello" {
t.Fatalf("projected title = %#v, want Hello", rec["title"])
}
var row mlPost
if err := db.First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Title != "Hello" {
t.Fatalf("host title = %q, want Hello", row.Title)
}
if writer.attrs["en"] != nil {
t.Fatalf("default locale duplicated in attributes: %#v", writer.attrs["en"])
}
if writer.attrs["pl"]["title"] != "Witaj" {
t.Fatalf("Polish attributes = %#v", writer.attrs)
}
raw, err := json.Marshal(writer.attrs["pl"])
if err != nil || string(raw) != `{"title":"Witaj"}` {
t.Fatalf("attribute JSON = %s err=%v", raw, err)
}
if len(writer.ids) != 1 || writer.ids[0] != row.ID || row.ID == 0 {
t.Fatalf("writer ids = %v host id = %d", writer.ids, row.ID)
}
_, err = svc.Create(context.Background(), cc, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "de": "Hallo"},
}})
if err == nil {
t.Fatal("undeclared locale succeeded")
}
var ve *ValidationError
if !asValidation(err, &ve) || ve.Details["title"] == nil {
t.Fatalf("undeclared locale err = %v", err)
}
})
t.Run("unsafe markdown cannot become executable HTML", func(t *testing.T) {
if _, err := RenderMarkdown("<script>alert(1)</script>"); err == nil || !strings.Contains(err.Error(), "raw unsafe tags") {
// goldmark without WithUnsafe drops the raw tag; leftover HTML
// must still be rejected if a later renderer change lets it through.
html, convErr := renderMarkdownUnchecked("<script>alert(1)</script>")
if convErr != nil {
t.Fatal(convErr)
}
if strings.Contains(strings.ToLower(html), "<script") {
if err == nil {
t.Fatalf("RenderMarkdown accepted script HTML: %s", html)
}
}
}
if html, err := RenderMarkdown("[x](javascript:alert(1))"); err == nil && strings.Contains(strings.ToLower(html), "javascript:") {
t.Fatalf("javascript URL survived: %s", html)
} else if err == nil && strings.Contains(strings.ToLower(html), "<script") {
t.Fatalf("script tag survived: %s", html)
}
if _, err := RenderMarkdown(`<img src=x onerror="alert(1)">`); err == nil {
html, _ := renderMarkdownUnchecked(`<img src=x onerror="alert(1)">`)
if strings.Contains(strings.ToLower(html), "onerror") {
t.Fatalf("event handler survived: %s", html)
}
}
if html, err := RenderMarkdown("# Hello"); err != nil || !strings.Contains(html, "<h1>") {
t.Fatalf("safe markdown: html=%s err=%v", html, err)
}
})
}
func asValidation(err error, dest **ValidationError) bool {
return errors.As(err, dest)
}
func renderMarkdownUnchecked(src string) (string, error) {
var buf bytes.Buffer
if err := markdownEngine.Convert([]byte(src), &buf); err != nil {
return "", err
}
return buf.String(), nil
}