feat(admin): post content on the Blog Editor field type
- blogeditor and mlblogeditor field types (classes.BlogEditorTypes) on the plugin; post content is mlblogeditor with htmlColumn content_html - Decode stores SanitizeHTMLReport output per locale with notices naming what was removed; clean HTML is stored byte for byte, nothing is refused because of markup - Load serves HTML as stored and markdown posts as their content_html - the admin hooks copy written content to content_html per locale without goldmark; untouched content leaves both columns as they are - editor lang keys (en, pl plural notices), tracer smoke test, updated Postgres admin tests
This commit is contained in:
261
classes/blog_editor.go
Normal file
261
classes/blog_editor.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// BlogEditorElement is the custom element of the blogeditor and mlblogeditor
|
||||
// field types, defined by assets/dist/blogeditor.js.
|
||||
const BlogEditorElement = "golem15-journal-blogeditor"
|
||||
|
||||
// Phrase keys of the Blog Editor field types.
|
||||
const (
|
||||
editorContentInvalid = "golem15.journal::lang.editor.content_invalid"
|
||||
editorRemovedElements = "golem15.journal::lang.editor.removed_elements"
|
||||
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
|
||||
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
|
||||
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
|
||||
)
|
||||
|
||||
// blogListValueRunes is how many runes of text a list column shows.
|
||||
const blogListValueRunes = 120
|
||||
|
||||
var (
|
||||
blogHTMLColumnPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
blogUploadFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
||||
)
|
||||
|
||||
// BlogEditorTypes returns the Blog Editor admin field types: blogeditor for
|
||||
// a column that holds one HTML string, and mlblogeditor for a translatable
|
||||
// one. Both render BlogEditorElement. Decode stores what the HTML policy
|
||||
// keeps (SanitizeHTMLReport) and reports what it removed as notices; it
|
||||
// never refuses a string because of its markup. Load serves HTML content as
|
||||
// it is and a markdown value (one that does not start with '<') as the
|
||||
// rendered HTML of the htmlColumn option. app is asked for the booted
|
||||
// application when a type needs it at request time; it may be nil in tests.
|
||||
//
|
||||
// Options: htmlColumn (mlblogeditor only) names the translatable attribute
|
||||
// that holds the rendered HTML of a markdown value; uploadFolder is the
|
||||
// folder under /journal/ that editor image uploads are stored in.
|
||||
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
_ = app // used by the editor image upload
|
||||
return []pact.FormFieldType{
|
||||
{
|
||||
Name: "blogeditor",
|
||||
Element: BlogEditorElement,
|
||||
Scripts: []string{"assets/dist/blogeditor.js"},
|
||||
Styles: []string{"assets/dist/blogeditor.css"},
|
||||
Options: []string{"uploadFolder"},
|
||||
CheckOptions: checkBlogOptions,
|
||||
Decode: decodeBlogHTML,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
},
|
||||
{
|
||||
Name: "mlblogeditor",
|
||||
Element: BlogEditorElement,
|
||||
Translatable: true,
|
||||
Scripts: []string{"assets/dist/blogeditor.js"},
|
||||
Styles: []string{"assets/dist/blogeditor.css"},
|
||||
Options: []string{"htmlColumn", "uploadFolder"},
|
||||
CheckOptions: checkBlogOptions,
|
||||
Decode: decodeBlogHTML,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LooksLikeHTML reports whether s is stored HTML rather than markdown: its
|
||||
// first character that is not white space is '<' (an HTML comment
|
||||
// included). Phase 16.2 assumption A1.
|
||||
func LooksLikeHTML(s string) bool {
|
||||
t := strings.TrimLeftFunc(s, unicode.IsSpace)
|
||||
return strings.HasPrefix(t, "<")
|
||||
}
|
||||
|
||||
// checkBlogOptions checks a Blog Editor field's options at boot.
|
||||
func checkBlogOptions(options map[string]any) error {
|
||||
if raw, ok := options["htmlColumn"]; ok {
|
||||
column, isString := raw.(string)
|
||||
if !isString || !blogHTMLColumnPattern.MatchString(column) {
|
||||
return fmt.Errorf("htmlColumn must be an attribute name, got %v", raw)
|
||||
}
|
||||
}
|
||||
if raw, ok := options["uploadFolder"]; ok {
|
||||
folder, isString := raw.(string)
|
||||
if !isString {
|
||||
return fmt.Errorf("uploadFolder must be a string, got %v", raw)
|
||||
}
|
||||
if err := checkUploadFolder(folder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUploadFolder applies the public media upload route's folder rule.
|
||||
func checkUploadFolder(folder string) error {
|
||||
if len(folder) > 100 || !blogUploadFolderPattern.MatchString(folder) || strings.Contains(folder, "..") {
|
||||
return errors.New("uploadFolder must be at most 100 letters, digits, '_', '-' or '/' without '..'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeBlogHTML stores the HTML policy's output for a submitted value: the
|
||||
// exact submitted bytes with no notice when the policy keeps everything,
|
||||
// otherwise the stripped HTML with notices naming what was removed. nil
|
||||
// decodes to the empty string; any other value that is not a string is
|
||||
// refused.
|
||||
func decodeBlogHTML(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
var text string
|
||||
switch v := in.Value.(type) {
|
||||
case nil:
|
||||
return pact.FormFieldDecoded{Value: ""}, nil
|
||||
case string:
|
||||
text = v
|
||||
default:
|
||||
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{editorContentInvalid}}
|
||||
}
|
||||
clean, report := SanitizeHTMLReport(text)
|
||||
return pact.FormFieldDecoded{Value: clean, Notices: removalNotices(report)}, nil
|
||||
}
|
||||
|
||||
// removalNotices turns a removal report into admin notices: removed
|
||||
// elements, removed attributes, removed comments and other changes, in that
|
||||
// order and only when non-zero.
|
||||
func removalNotices(r HTMLRemovals) []pact.FormFieldNotice {
|
||||
var out []pact.FormFieldNotice
|
||||
if r.ElementCount > 0 {
|
||||
out = append(out, pact.FormFieldNotice{
|
||||
Message: editorRemovedElements,
|
||||
Params: map[string]string{"names": removalNames(r.Elements, r.moreElements)},
|
||||
Count: r.ElementCount,
|
||||
})
|
||||
}
|
||||
if r.AttributeCount > 0 {
|
||||
out = append(out, pact.FormFieldNotice{
|
||||
Message: editorRemovedAttributes,
|
||||
Params: map[string]string{"names": removalNames(r.Attributes, r.moreAttributes)},
|
||||
Count: r.AttributeCount,
|
||||
})
|
||||
}
|
||||
if r.Comments > 0 {
|
||||
out = append(out, pact.FormFieldNotice{Message: editorRemovedComments, Count: r.Comments})
|
||||
}
|
||||
if r.Other {
|
||||
out = append(out, pact.FormFieldNotice{Message: editorMarkupChanged})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removalNames(names []string, cut bool) string {
|
||||
joined := strings.Join(names, ", ")
|
||||
if cut {
|
||||
joined += ", …"
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
// loadBlogHTML serves a stored value to the editor (D-07). HTML content is
|
||||
// served as it is. A markdown value is served as its rendered HTML: the
|
||||
// htmlColumn attribute in the same locale, or FormatHTML of the value when
|
||||
// that is empty or cannot be read (the value itself when rendering fails).
|
||||
func loadBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
text := storedText(in.Value)
|
||||
column, _ := in.Options["htmlColumn"].(string)
|
||||
if column == "" || LooksLikeHTML(text) || strings.TrimSpace(text) == "" {
|
||||
return text, nil
|
||||
}
|
||||
if in.Translated != nil {
|
||||
html, found, err := in.Translated(column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found && html != "" {
|
||||
return html, nil
|
||||
}
|
||||
}
|
||||
html, err := FormatHTML(text)
|
||||
if err != nil {
|
||||
return text, nil
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
// storedText is a stored column value as text: a string, a *string (nil is
|
||||
// empty) or nil.
|
||||
func storedText(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case *string:
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return *t
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
// inlineListTags are the elements that do not separate words in a list
|
||||
// value's text.
|
||||
var inlineListTags = map[string]bool{
|
||||
"a": true, "abbr": true, "b": true, "cite": true, "code": true, "del": true,
|
||||
"em": true, "i": true, "ins": true, "kbd": true, "mark": true, "q": true,
|
||||
"s": true, "small": true, "span": true, "strong": true, "sub": true,
|
||||
"sup": true, "u": true,
|
||||
}
|
||||
|
||||
// blogListValue is the text a list column of a Blog Editor type shows: the
|
||||
// value without tags, white space collapsed, cut to 120 runes with an
|
||||
// ellipsis.
|
||||
func blogListValue(_ context.Context, value any) string {
|
||||
var b strings.Builder
|
||||
z := xhtml.NewTokenizer(strings.NewReader(storedText(value)))
|
||||
skip := 0 // inside script or style
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == xhtml.ErrorToken {
|
||||
break
|
||||
}
|
||||
switch tt {
|
||||
case xhtml.TextToken:
|
||||
if skip == 0 {
|
||||
b.WriteString(z.Token().Data)
|
||||
}
|
||||
case xhtml.StartTagToken, xhtml.EndTagToken, xhtml.SelfClosingTagToken:
|
||||
name, _ := z.TagName()
|
||||
switch string(name) {
|
||||
case "script", "style":
|
||||
if tt == xhtml.StartTagToken {
|
||||
skip++
|
||||
} else if tt == xhtml.EndTagToken && skip > 0 {
|
||||
skip--
|
||||
}
|
||||
}
|
||||
if !inlineListTags[string(name)] {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
text := strings.Join(strings.Fields(b.String()), " ")
|
||||
if utf8.RuneCountInString(text) <= blogListValueRunes {
|
||||
return text
|
||||
}
|
||||
runes := []rune(text)
|
||||
return strings.TrimRightFunc(string(runes[:blogListValueRunes]), unicode.IsSpace) + "…"
|
||||
}
|
||||
@@ -27,6 +27,8 @@ func init() {
|
||||
var _ pact.FilterOptions = postsAdminController{}
|
||||
var _ pact.FormBeforeCreate = postsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = postsAdminController{}
|
||||
var _ pact.FormAfterCreate = postsAdminController{}
|
||||
var _ pact.FormAfterUpdate = postsAdminController{}
|
||||
var _ pact.FormBeforeDelete = postsAdminController{}
|
||||
var _ pact.HasAdminActions = postsAdminController{}
|
||||
var _ cabana.FieldRelationProvider = postsAdminController{}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -210,13 +209,75 @@ func preparePostWrite(ctx context.Context, post *models.Post) error {
|
||||
"published_at": []string{"golem15.journal::lang.post.published_validation"},
|
||||
}}
|
||||
}
|
||||
html, err := classes.FormatHTML(post.Content)
|
||||
if err != nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{
|
||||
"content": []string{err.Error()},
|
||||
}}
|
||||
// D-02: the Blog Editor's Decode already stored the policy's output in
|
||||
// content; content_html gets the same HTML, without goldmark. Content the
|
||||
// save does not write (untouched, D-03) leaves content_html as it is.
|
||||
if writes, ok := cabana.FieldWritesFromContext(ctx); ok {
|
||||
for _, w := range writes["content"] {
|
||||
if !w.Default {
|
||||
continue
|
||||
}
|
||||
if html, isString := w.Value.(string); isString {
|
||||
post.ContentHTML = html
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormAfterCreate writes content_html for every non-default locale whose
|
||||
// content the save wrote (D-02).
|
||||
func (c postsAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
return c.writeContentHTMLTranslations(ctx, model)
|
||||
}
|
||||
|
||||
// FormAfterUpdate writes content_html for every non-default locale whose
|
||||
// content the save wrote (D-02). Untouched locales are not listed and keep
|
||||
// their content_html.
|
||||
func (c postsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
return c.writeContentHTMLTranslations(ctx, model)
|
||||
}
|
||||
|
||||
// writeContentHTMLTranslations stores each written non-default content
|
||||
// value as that locale's content_html through the published
|
||||
// cabana.TranslationWriter, inside the save transaction. Without a writer
|
||||
// there is nothing to write.
|
||||
func (c postsAdminController) writeContentHTMLTranslations(ctx context.Context, model any) error {
|
||||
post, ok := model.(*models.Post)
|
||||
if !ok || post == nil {
|
||||
return errors.New("posts admin: form model is not a post")
|
||||
}
|
||||
writes, ok := cabana.FieldWritesFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var pending []cabana.FieldWrite
|
||||
for _, w := range writes["content"] {
|
||||
if !w.Default && w.Locale != "" {
|
||||
pending = append(pending, w)
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
writer, ok := app.Lookup[cabana.TranslationWriter]()
|
||||
if !ok || writer == nil {
|
||||
return nil
|
||||
}
|
||||
tx := c.db(ctx)
|
||||
for _, w := range pending {
|
||||
html, isString := w.Value.(string)
|
||||
if !isString {
|
||||
return fmt.Errorf("posts admin: content for %s is %T, want a string", w.Locale, w.Value)
|
||||
}
|
||||
if err := writer.WriteTranslated(ctx, tx, post, "content_html", w.Locale, html); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
post.ContentHTML = html
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
336
controllers/posts_editor_smoke_test.go
Normal file
336
controllers/posts_editor_smoke_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// recordingWriter is a cabana.TranslationWriter that records writes.
|
||||
type recordingWriter struct {
|
||||
writes []recordedWrite
|
||||
}
|
||||
|
||||
type recordedWrite struct {
|
||||
field, locale, value string
|
||||
}
|
||||
|
||||
func (*recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "pl", nil }
|
||||
|
||||
func (*recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||
return []string{"pl", "en"}, nil
|
||||
}
|
||||
|
||||
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, _ any, field, locale, value string) error {
|
||||
w.writes = append(w.writes, recordedWrite{field: field, locale: locale, value: value})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*recordingWriter) TranslatedExact(context.Context, *gorm.DB, any, string, string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// blogType returns the named Blog Editor field type.
|
||||
func blogType(t *testing.T, name string) pact.FormFieldType {
|
||||
t.Helper()
|
||||
for _, ft := range classes.BlogEditorTypes(nil) {
|
||||
if ft.Name == name {
|
||||
return ft
|
||||
}
|
||||
}
|
||||
t.Fatalf("no field type %s", name)
|
||||
return pact.FormFieldType{}
|
||||
}
|
||||
|
||||
// formField returns a compiled form field by name.
|
||||
func formField(t *testing.T, schema *cabana.FormSchema, name string) cabana.FormField {
|
||||
t.Helper()
|
||||
for _, f := range schema.Fields {
|
||||
if f.Name == name {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("form has no field %s", name)
|
||||
return cabana.FormField{}
|
||||
}
|
||||
|
||||
// editorPrincipal is a backend administrator who may edit and publish any
|
||||
// post.
|
||||
func editorPrincipal() *bouncer.Principal {
|
||||
return &bouncer.Principal{ID: 7, Backend: true, IsSuperuser: true}
|
||||
}
|
||||
|
||||
// TestBlogEditorTracerSmoke is the Phase 16.2 plan 03 tracer: post content
|
||||
// compiles as mlblogeditor, Decode stores the policy's output with notices
|
||||
// naming what it removed, Load serves HTML and markdown posts per D-07, and
|
||||
// the Form hooks write the written content to content_html per locale
|
||||
// without goldmark.
|
||||
func TestBlogEditorTracerSmoke(t *testing.T) {
|
||||
ml := blogType(t, "mlblogeditor")
|
||||
|
||||
t.Run("posts form compiles with mlblogeditor", func(t *testing.T) {
|
||||
schema, err := cabana.CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := formField(t, schema, "content")
|
||||
if content.Type != "mlblogeditor" || content.Element != classes.BlogEditorElement || !content.Translatable {
|
||||
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
|
||||
}
|
||||
if content.TypeOptions["htmlColumn"] != "content_html" || content.TypeOptions["uploadFolder"] != "posts" {
|
||||
t.Fatalf("content typeOptions = %#v", content.TypeOptions)
|
||||
}
|
||||
if excerpt := formField(t, schema, "excerpt"); excerpt.Type != "mltextarea" {
|
||||
t.Fatalf("excerpt type = %q, want mltextarea", excerpt.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type definitions", func(t *testing.T) {
|
||||
if ml.Element != "golem15-journal-blogeditor" || len(ml.Scripts) != 1 || ml.Scripts[0] != "assets/dist/blogeditor.js" ||
|
||||
len(ml.Styles) != 1 || ml.Styles[0] != "assets/dist/blogeditor.css" {
|
||||
t.Fatalf("mlblogeditor = %+v", ml)
|
||||
}
|
||||
for _, asset := range append(append([]string{}, ml.Scripts...), ml.Styles...) {
|
||||
if _, err := os.Stat("../" + asset); err != nil {
|
||||
t.Fatalf("asset %s: %v", asset, err)
|
||||
}
|
||||
}
|
||||
plain := blogType(t, "blogeditor")
|
||||
if plain.Translatable || len(plain.Options) != 1 || plain.Options[0] != "uploadFolder" {
|
||||
t.Fatalf("blogeditor = %+v", plain)
|
||||
}
|
||||
for _, bad := range []map[string]any{
|
||||
{"htmlColumn": "content html"},
|
||||
{"htmlColumn": 3},
|
||||
{"uploadFolder": "../up"},
|
||||
{"uploadFolder": "a b"},
|
||||
{"uploadFolder": strings.Repeat("a", 101)},
|
||||
} {
|
||||
if err := ml.CheckOptions(bad); err == nil {
|
||||
t.Fatalf("CheckOptions(%v) = nil, want an error", bad)
|
||||
}
|
||||
}
|
||||
if err := ml.CheckOptions(map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts/2026"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
decode := func(t *testing.T, value any) (pact.FormFieldDecoded, error) {
|
||||
t.Helper()
|
||||
return ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value})
|
||||
}
|
||||
|
||||
t.Run("decode keeps clean HTML byte for byte", func(t *testing.T) {
|
||||
const clean = "<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> & <strong>pogrubieniem</strong>.</p>\n"
|
||||
got, err := decode(t, clean)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Value != clean || len(got.Notices) != 0 {
|
||||
t.Fatalf("Decode(clean) = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode strips and reports", func(t *testing.T) {
|
||||
got, err := decode(t, `<p>a<script>alert(1)</script></p><img src="https://example.test/x.png" alt="x" onerror="alert(1)">`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Value != `<p>a</p><img src="https://example.test/x.png" alt="x">` {
|
||||
t.Fatalf("Decode value = %q", got.Value)
|
||||
}
|
||||
if len(got.Notices) != 2 {
|
||||
t.Fatalf("notices = %#v, want two", got.Notices)
|
||||
}
|
||||
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_elements" || n.Count != 1 || n.Params["names"] != "script" {
|
||||
t.Fatalf("first notice = %#v", n)
|
||||
}
|
||||
if n := got.Notices[1]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onerror" {
|
||||
t.Fatalf("second notice = %#v", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode nil and non-string", func(t *testing.T) {
|
||||
got, err := decode(t, nil)
|
||||
if err != nil || got.Value != "" || len(got.Notices) != 0 {
|
||||
t.Fatalf("Decode(nil) = %#v, %v", got, err)
|
||||
}
|
||||
_, err = decode(t, 42.0)
|
||||
var refused *pact.FormFieldError
|
||||
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != "golem15.journal::lang.editor.content_invalid" {
|
||||
t.Fatalf("Decode(42) error = %v, want a FormFieldError", err)
|
||||
}
|
||||
})
|
||||
|
||||
load := func(t *testing.T, value any, translated func(string) (string, bool, error)) any {
|
||||
t.Helper()
|
||||
got, err := ml.Load(context.Background(), pact.FormFieldValue{
|
||||
Field: "content",
|
||||
Options: map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"},
|
||||
Locale: "en",
|
||||
Value: value,
|
||||
Translated: translated,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
noRead := func(string) (string, bool, error) {
|
||||
t.Fatal("Translated read for an HTML value")
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
t.Run("load serves HTML content as it is", func(t *testing.T) {
|
||||
const html = " <!-- lead -->\n<p>Stored <em>HTML</em></p>"
|
||||
if got := load(t, html, noRead); got != html {
|
||||
t.Fatalf("Load(HTML) = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load serves a markdown post's content_html", func(t *testing.T) {
|
||||
got := load(t, "Hello **world**", func(attribute string) (string, bool, error) {
|
||||
if attribute != "content_html" {
|
||||
t.Fatalf("Translated(%q), want content_html", attribute)
|
||||
}
|
||||
return "<p>Stored render</p>", true, nil
|
||||
})
|
||||
if got != "<p>Stored render</p>" {
|
||||
t.Fatalf("Load(markdown) = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load renders markdown when content_html is empty", func(t *testing.T) {
|
||||
got := load(t, "Hello **world**", func(string) (string, bool, error) { return "", true, nil })
|
||||
if got != "<p>Hello <strong>world</strong></p>\n" {
|
||||
t.Fatalf("Load(markdown, empty content_html) = %q", got)
|
||||
}
|
||||
if got := load(t, "Hello **world**", nil); got != "<p>Hello <strong>world</strong></p>\n" {
|
||||
t.Fatalf("Load(markdown, no reader) = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list value", func(t *testing.T) {
|
||||
got := ml.ListValue(context.Background(), "<h2>Title</h2><p>Some <strong>bold</strong>\n text</p><style>p{}</style>")
|
||||
if got != "Title Some bold text" {
|
||||
t.Fatalf("ListValue = %q", got)
|
||||
}
|
||||
long := ml.ListValue(context.Background(), "<p>"+strings.Repeat("ż", 130)+"</p>")
|
||||
if []rune(long)[120] != '…' || len([]rune(long)) != 121 {
|
||||
t.Fatalf("ListValue(long) = %q", long)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hooks write content_html per written locale", func(t *testing.T) {
|
||||
writer := &recordingWriter{}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{
|
||||
"content": {
|
||||
{Locale: "pl", Default: true, Value: "<p>A</p>"},
|
||||
{Locale: "en", Value: "<p>B</p>"},
|
||||
},
|
||||
})
|
||||
post := &models.Post{Content: "<p>A</p>", ContentHTML: "old"}
|
||||
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>A</p>" {
|
||||
t.Fatalf("ContentHTML = %q, want <p>A</p>", post.ContentHTML)
|
||||
}
|
||||
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: "<p>B</p>"}) {
|
||||
t.Fatalf("translation writes = %#v", writer.writes)
|
||||
}
|
||||
if err := ctl.FormAfterCreate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(writer.writes) != 2 {
|
||||
t.Fatalf("FormAfterCreate writes = %#v", writer.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hooks leave untouched content alone", func(t *testing.T) {
|
||||
writer := &recordingWriter{}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||
const markdown = "Hello **world**"
|
||||
post := &models.Post{Content: markdown, ContentHTML: "<p>kept</p>"}
|
||||
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>kept</p>" {
|
||||
t.Fatalf("ContentHTML = %q, want it unchanged (no goldmark)", post.ContentHTML)
|
||||
}
|
||||
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"title": {{Locale: "en", Value: "x"}}})
|
||||
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>kept</p>" || len(writer.writes) != 0 {
|
||||
t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("notice phrases have plural forms", func(t *testing.T) {
|
||||
fsys := fstest.MapFS{}
|
||||
for _, locale := range []string{"en", "pl"} {
|
||||
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fsys["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: raw}
|
||||
}
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("golem15.journal", fsys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
names := map[string]string{"names": "script, iframe"}
|
||||
for _, tc := range []struct {
|
||||
locale, key string
|
||||
count int
|
||||
want string
|
||||
}{
|
||||
{"en", "removed_elements", 1, "1 element was removed: script, iframe"},
|
||||
{"en", "removed_elements", 2, "2 elements were removed: script, iframe"},
|
||||
{"en", "removed_attributes", 3, "3 attributes were removed: script, iframe"},
|
||||
{"en", "removed_comments", 1, "1 HTML comment was removed"},
|
||||
{"pl", "removed_elements", 1, "Usunięto 1 element: script, iframe"},
|
||||
{"pl", "removed_elements", 3, "Usunięto 3 elementy: script, iframe"},
|
||||
{"pl", "removed_attributes", 5, "Usunięto 5 atrybutów: script, iframe"},
|
||||
{"pl", "removed_comments", 2, "Usunięto 2 komentarze HTML"},
|
||||
} {
|
||||
if got := tr.ChoiceIn(tc.locale, "golem15.journal::lang.editor."+tc.key, tc.count, names); got != tc.want {
|
||||
t.Fatalf("%s %s %d = %q, want %q", tc.locale, tc.key, tc.count, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.journal::lang.editor.content_invalid", nil); got != "Treść musi być tekstem HTML." {
|
||||
t.Fatalf("pl content_invalid = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
21
field_types.go
Normal file
21
field_types.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// FormFieldTypes registers the Blog Editor field types, blogeditor and
|
||||
// mlblogeditor (classes.BlogEditorTypes), with a lazy accessor of the booted
|
||||
// app.
|
||||
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return classes.BlogEditorTypes(func() *backpack.App {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.app
|
||||
})
|
||||
}
|
||||
|
||||
var _ pact.HasFormFieldTypes = (*Plugin)(nil)
|
||||
@@ -131,3 +131,17 @@ tag:
|
||||
delete_confirm: Delete this tag?
|
||||
delete_success: Successfully deleted those tags.
|
||||
return_to_tags: Return to the journal tag list
|
||||
editor:
|
||||
content_invalid: The content must be HTML text.
|
||||
image_invalid: The image must be a JPEG, PNG, GIF or WebP file.
|
||||
upload_failed: The image could not be uploaded.
|
||||
removed_elements:
|
||||
one: ":count element was removed: :names"
|
||||
other: ":count elements were removed: :names"
|
||||
removed_attributes:
|
||||
one: ":count attribute was removed: :names"
|
||||
other: ":count attributes were removed: :names"
|
||||
removed_comments:
|
||||
one: ":count HTML comment was removed"
|
||||
other: ":count HTML comments were removed"
|
||||
markup_changed: The HTML filter changed some markup.
|
||||
|
||||
@@ -83,3 +83,23 @@ category:
|
||||
delete_success: Kategorie zostały pomyślnie usunięte.
|
||||
return_to_categories: Wróć do listy kategorii
|
||||
reorder: Zmień kolejność kategorii
|
||||
editor:
|
||||
content_invalid: Treść musi być tekstem HTML.
|
||||
image_invalid: Obraz musi być plikiem JPEG, PNG, GIF lub WebP.
|
||||
upload_failed: Nie udało się przesłać obrazu.
|
||||
removed_elements:
|
||||
one: "Usunięto :count element: :names"
|
||||
few: "Usunięto :count elementy: :names"
|
||||
many: "Usunięto :count elementów: :names"
|
||||
other: "Usunięto :count elementu: :names"
|
||||
removed_attributes:
|
||||
one: "Usunięto :count atrybut: :names"
|
||||
few: "Usunięto :count atrybuty: :names"
|
||||
many: "Usunięto :count atrybutów: :names"
|
||||
other: "Usunięto :count atrybutu: :names"
|
||||
removed_comments:
|
||||
one: "Usunięto :count komentarz HTML"
|
||||
few: "Usunięto :count komentarze HTML"
|
||||
many: "Usunięto :count komentarzy HTML"
|
||||
other: "Usunięto :count komentarza HTML"
|
||||
markup_changed: Filtr HTML zmienił część znaczników.
|
||||
|
||||
@@ -16,7 +16,9 @@ fields:
|
||||
type: slug
|
||||
content:
|
||||
label: golem15.journal::lang.post.content
|
||||
type: mlmarkdown
|
||||
type: mlblogeditor
|
||||
htmlColumn: content_html
|
||||
uploadFolder: posts
|
||||
size: huge
|
||||
tab: golem15.journal::lang.post.tab_edit
|
||||
required: true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,8 +17,8 @@ func TestPostsFormCompiles(t *testing.T) {
|
||||
token := env.admin(t, controllers.PermissionAccessPosts)
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminPostsPath+"/schema/form"), token, nil)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "mlmarkdown") {
|
||||
t.Fatalf("form schema missing mlmarkdown: %s", body)
|
||||
if !strings.Contains(body, `"type":"mlblogeditor"`) {
|
||||
t.Fatalf("form schema missing mlblogeditor: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "mltext") {
|
||||
t.Fatalf("form schema missing mltext: %s", body)
|
||||
@@ -40,9 +41,12 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), privileged, map[string]any{
|
||||
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||
"slug": map[string]any{"en": "hello-admin", "pl": "witaj-admin"},
|
||||
"content": map[string]any{"en": "Hello **world**", "pl": "Witaj **świecie**"},
|
||||
"content": map[string]any{"en": "<p>Hello <strong>world</strong></p>", "pl": "<p>Witaj <strong>świecie</strong></p>"},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
})
|
||||
if notices := adminNotices(t, rec); notices != nil {
|
||||
t.Fatalf("clean create carries meta.notices: %s", rec.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
@@ -60,14 +64,9 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
if stored.Slug != "hello-admin" {
|
||||
t.Fatalf("host slug = %q, want hello-admin", stored.Slug)
|
||||
}
|
||||
if stored.ContentHTML == "" {
|
||||
t.Fatal("content_html is empty")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(stored.ContentHTML), "<script") {
|
||||
t.Fatalf("content_html contains script: %s", stored.ContentHTML)
|
||||
}
|
||||
if !strings.Contains(stored.ContentHTML, "<strong>") && !strings.Contains(stored.ContentHTML, "<p>") {
|
||||
t.Fatalf("content_html missing markup: %s", stored.ContentHTML)
|
||||
// D-02: the admin path stores the same HTML in both columns.
|
||||
if stored.Content != "<p>Hello <strong>world</strong></p>" || stored.ContentHTML != stored.Content {
|
||||
t.Fatalf("content = %q, content_html = %q, want both the submitted HTML", stored.Content, stored.ContentHTML)
|
||||
}
|
||||
|
||||
var plCount int64
|
||||
@@ -139,8 +138,8 @@ func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
||||
if strings.Contains(stored.ContentHTML, "raw HTML omitted") {
|
||||
t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML)
|
||||
}
|
||||
if strings.TrimSpace(stored.ContentHTML) != content {
|
||||
t.Fatalf("%s: content_html = %q, want %q", stage, strings.TrimSpace(stored.ContentHTML), content)
|
||||
if stored.ContentHTML != content {
|
||||
t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, content)
|
||||
}
|
||||
if stored.Content != content {
|
||||
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
||||
@@ -152,10 +151,12 @@ func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
||||
assertHTML("re-save")
|
||||
}
|
||||
|
||||
// TestPostsAdminSaveSanitizesHTML pins CR-01 and WR-01 on the admin write
|
||||
// path: unsafe markup in content is stripped from content_html, as Winter's
|
||||
// Html::clean strips it, instead of refusing the save, and a "Data:" label is
|
||||
// ordinary text. content itself is stored as submitted.
|
||||
// TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and
|
||||
// D-10 on the admin write path: unsafe markup in content is stripped, as
|
||||
// Winter's Html::clean strips it, instead of refusing the save; content and
|
||||
// content_html both hold the sanitized HTML for the host locale (en) and the
|
||||
// translation (pl); a "Data:" label is ordinary text; and the response's
|
||||
// meta.notices names what was removed for each locale.
|
||||
func TestPostsAdminSaveSanitizesHTML(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
token := env.admin(t,
|
||||
@@ -184,11 +185,68 @@ func TestPostsAdminSaveSanitizesHTML(t *testing.T) {
|
||||
}
|
||||
const want = "<p>Data: 12 maja 2024</p>\n" +
|
||||
"<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"a\"></p>\n" +
|
||||
"<p>x</p>"
|
||||
if got := strings.TrimSpace(stored.ContentHTML); got != want {
|
||||
t.Fatalf("content_html = %q, want %q", got, want)
|
||||
"<p>x</p>\n"
|
||||
if stored.Content != want || stored.ContentHTML != want {
|
||||
t.Fatalf("host content = %q, content_html = %q, want both %q", stored.Content, stored.ContentHTML, want)
|
||||
}
|
||||
if stored.Content != content {
|
||||
t.Fatalf("content = %q, want it stored as submitted", stored.Content)
|
||||
var raw string
|
||||
if err := env.gdb.Table("golem15_translate_attributes").
|
||||
Select("attribute_data").
|
||||
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl").
|
||||
Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pl map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &pl); err != nil {
|
||||
t.Fatalf("attribute_data %q: %v", raw, err)
|
||||
}
|
||||
if pl["content"] != want || pl["content_html"] != want {
|
||||
t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], want)
|
||||
}
|
||||
|
||||
notices := adminNotices(t, rec)
|
||||
for _, locale := range []string{"en", "pl"} {
|
||||
var elements, attributes bool
|
||||
for _, n := range notices {
|
||||
if n.Field != "content" || n.Locale != locale {
|
||||
continue
|
||||
}
|
||||
elements = elements || strings.Contains(n.Message, "2 elements were removed: a, base")
|
||||
attributes = attributes || strings.Contains(n.Message, "1 attribute was removed: onerror")
|
||||
}
|
||||
if !elements || !attributes {
|
||||
t.Fatalf("%s notices = %#v, want removed elements a, base and attribute onerror", locale, notices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// adminNotice is one meta.notices entry of an admin save response.
|
||||
type adminNotice struct {
|
||||
Field string `json:"field"`
|
||||
Locale string `json:"locale"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// adminNotices returns a save response's meta.notices, nil when the key is
|
||||
// absent.
|
||||
func adminNotices(t *testing.T, rec *httptest.ResponseRecorder) []adminNotice {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Meta map[string]json.RawMessage `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
raw, ok := body.Meta["notices"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var notices []adminNotice
|
||||
if err := json.Unmarshal(raw, ¬ices); err != nil {
|
||||
t.Fatalf("meta.notices %s: %v", raw, err)
|
||||
}
|
||||
if notices == nil {
|
||||
notices = []adminNotice{}
|
||||
}
|
||||
return notices
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user