feat(admin): category content on the Blog Editor and editor image uploads

- category content is the blogeditor type (uploadFolder categories): it is
  stripped and reported like post content; the 422 CheckHTML gate is
  retired and blank content still becomes NULL
- blogeditor and mlblogeditor Upload: magic-byte check for JPEG, PNG, GIF
  and WebP matching the extension, safe lowercase names, storage through
  classes.StoreMedia in the field's uploadFolder
- TestBlogEditorUploadSmoke, TestCategoriesAdminContent asserts the strip,
  README describes the field types, admin saves and editor uploads
This commit is contained in:
2026-10-10 12:08:51 +02:00
parent 15fcad7a93
commit cb1c688213
8 changed files with 417 additions and 53 deletions

View File

@@ -5,7 +5,6 @@ import (
"errors"
"strings"
"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/cabana"
@@ -61,21 +60,20 @@ func (categoriesAdminController) FormBeforeUpdate(ctx context.Context, model any
return prepareCategoryWrite(cat)
}
// prepareCategoryWrite gates the category HTML body (D-21): blank content is
// stored as NULL, and content that fails classes.CheckHTML is a 422 on
// content. Any other content is stored exactly as submitted.
// prepareCategoryWrite stores blank category content (white space only) as
// NULL and accepts any other content it receives. Since Phase 16.2 the
// content field is the blogeditor type, whose Decode already stored the HTML
// policy's output (classes.SanitizeHTMLReport) and told the administrator
// what it removed, so an admin save is never refused because of markup
// (D-10 amended, see the bluemonday decision note). Rows written by SQL or
// by the import commands do not pass through here and are not sanitized by
// it.
func prepareCategoryWrite(cat *models.Category) error {
if cat.Content == nil {
return nil
}
if strings.TrimSpace(*cat.Content) == "" {
cat.Content = nil
return nil
}
if err := classes.CheckHTML(*cat.Content); err != nil {
return &cabana.ValidationError{Details: map[string]any{
"content": []string{err.Error()},
}}
}
return nil
}

View File

@@ -1,6 +1,7 @@
package controllers
import (
"bytes"
"context"
"errors"
"os"
@@ -13,8 +14,10 @@ import (
"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/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
@@ -334,3 +337,206 @@ func TestBlogEditorTracerSmoke(t *testing.T) {
}
})
}
// pngBytes, jpegBytes and htmlBytes are file heads http.DetectContentType
// recognizes.
var (
pngBytes = append([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), make([]byte, 64)...)
jpegBytes = append([]byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00"), make([]byte, 64)...)
gifBytes = append([]byte("GIF89a"), make([]byte, 64)...)
webpBytes = append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 64)...)
htmlBytes = []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
svgBytes = []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
)
// TestBlogEditorUploadSmoke covers plan 03 Task 2: the media storage shared
// by the public upload route and the editor Upload, the editor Upload's
// magic-byte check and safe names, and category content on blogeditor.
func TestBlogEditorUploadSmoke(t *testing.T) {
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
app := backpack.New(nil)
if err := app.Publish(bucket); err != nil {
t.Fatal(err)
}
ctx := context.Background()
stored := func(t *testing.T, key string) []byte {
t.Helper()
data, err := bucket.ReadAll(ctx, key)
if err != nil {
t.Fatalf("read %s: %v", key, err)
}
return data
}
mediaError := func(t *testing.T, err error, message string) {
t.Helper()
var refused *classes.MediaError
if !errors.As(err, &refused) || refused.Field != "file" || refused.Message != message {
t.Fatalf("error = %v, want MediaError on file %q", err, message)
}
}
t.Run("StoreMedia keeps the public route's layout", func(t *testing.T) {
got, err := classes.StoreMedia(ctx, bucket, "photos", "cover.png", bytes.NewReader(pngBytes))
if err != nil {
t.Fatal(err)
}
if got.Path != "/journal/photos/cover.png" || got.URL != attach.PublicURL("journal/photos/cover.png") {
t.Fatalf("StoreMedia = %+v", got)
}
if !bytes.Equal(stored(t, "journal/photos/cover.png"), pngBytes) {
t.Fatal("stored bytes differ")
}
again, err := classes.StoreMedia(ctx, bucket, "/journal/photos/", `C:\up\cover.png`, bytes.NewReader(pngBytes))
if err != nil {
t.Fatal(err)
}
if again.Path != "/journal/photos/cover-1.png" {
t.Fatalf("second StoreMedia path = %q, want the unique suffix", again.Path)
}
})
t.Run("StoreMedia refuses other extensions and large files", func(t *testing.T) {
_, err := classes.StoreMedia(ctx, bucket, "", "logo.svg", bytes.NewReader(svgBytes))
mediaError(t, err, "The file must be an image.")
big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...)
_, err = classes.StoreMedia(ctx, bucket, "", "big.png", bytes.NewReader(big))
mediaError(t, err, "The file may not be greater than 10240 kilobytes.")
if exists, _ := bucket.Exists(ctx, "journal/big.png"); exists {
t.Fatal("a refused file was stored")
}
if _, err := classes.StoreMedia(ctx, nil, "", "a.png", bytes.NewReader(pngBytes)); err == nil {
t.Fatal("StoreMedia without a bucket = nil error")
} else if errors.As(err, new(*classes.MediaError)) {
t.Fatalf("StoreMedia without a bucket = %v, want an opaque error", err)
}
})
types := classes.BlogEditorTypes(func() *backpack.App { return app })
for _, ft := range types {
if ft.Upload == nil {
t.Fatalf("%s has no Upload", ft.Name)
}
}
upload := func(ft pact.FormFieldType, options map[string]any, name string, body []byte) (pact.FormFieldUploadResult, error) {
return ft.Upload(ctx, pact.FormFieldUpload{Field: "content", Options: options, Filename: name, Body: bytes.NewReader(body)})
}
refused := func(t *testing.T, err error, message string) {
t.Helper()
var fe *pact.FormFieldError
if !errors.As(err, &fe) || len(fe.Messages) != 1 || fe.Messages[0] != message {
t.Fatalf("error = %v, want FormFieldError %q", err, message)
}
}
posts := map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}
t.Run("editor upload stores a sniffed image under a safe name", func(t *testing.T) {
got, err := upload(types[1], posts, "Zdjęcie 1.PNG", pngBytes)
if err != nil {
t.Fatal(err)
}
if got.URL != attach.PublicURL("journal/posts/zdj-cie-1.png") {
t.Fatalf("URL = %q", got.URL)
}
if !bytes.Equal(stored(t, "journal/posts/zdj-cie-1.png"), pngBytes) {
t.Fatal("stored bytes differ from the upload")
}
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {
if _, err := upload(types[0], map[string]any{"uploadFolder": "categories"}, name, body); err != nil {
t.Fatalf("upload %s: %v", name, err)
}
}
got, err = upload(types[0], nil, "...", pngBytes)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got.URL, attach.PublicURL("journal/editor/")) {
t.Fatalf("default folder URL = %q, want journal/editor/", got.URL)
}
})
t.Run("editor upload refuses what does not sniff as its image type", func(t *testing.T) {
const invalid = "golem15.journal::lang.editor.image_invalid"
_, err := upload(types[1], posts, "x.png", htmlBytes)
refused(t, err, invalid)
_, err = upload(types[1], posts, "logo.svg", svgBytes)
refused(t, err, invalid)
_, err = upload(types[1], posts, "photo.jpg", pngBytes)
refused(t, err, invalid)
_, err = upload(types[1], posts, "photo.svg", pngBytes)
refused(t, err, "The file must be an image.")
big := append(append([]byte{}, pngBytes...), make([]byte, classes.MediaMaxBytes)...)
_, err = upload(types[1], posts, "big.png", big)
refused(t, err, "The file may not be greater than 10240 kilobytes.")
})
t.Run("editor upload without a bucket is an opaque error", func(t *testing.T) {
bare := classes.BlogEditorTypes(func() *backpack.App { return backpack.New(nil) })
_, err := bare[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Options: posts, Filename: "a.png", Body: bytes.NewReader(pngBytes)})
if err == nil || errors.As(err, new(*pact.FormFieldError)) {
t.Fatalf("error = %v, want an opaque error", err)
}
nilApp := classes.BlogEditorTypes(nil)
if _, err := nilApp[1].Upload(ctx, pact.FormFieldUpload{Field: "content", Filename: "a.png", Body: bytes.NewReader(pngBytes)}); err == nil {
t.Fatal("upload without an app = nil error")
}
})
t.Run("category content compiles as blogeditor", func(t *testing.T) {
schema, err := cabana.CompileFormWithTypes("golem15.journal", categoriesAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
if err != nil {
t.Fatal(err)
}
content := formField(t, schema, "content")
if content.Type != "blogeditor" || content.Element != classes.BlogEditorElement || content.Translatable {
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
}
if content.TypeOptions["uploadFolder"] != "categories" || content.Size != "huge" {
t.Fatalf("content typeOptions = %#v size %q", content.TypeOptions, content.Size)
}
})
plain := types[0]
t.Run("category load serves the stored HTML", func(t *testing.T) {
html := "<p>Pillar</p>"
for _, tc := range []struct {
value any
want string
}{{&html, html}, {(*string)(nil), ""}, {nil, ""}, {"plain text", "plain text"}} {
got, err := plain.Load(ctx, pact.FormFieldValue{Field: "content", Options: map[string]any{"uploadFolder": "categories"}, Default: true, Value: tc.value})
if err != nil || got != tc.want {
t.Fatalf("Load(%#v) = %#v, %v, want %q", tc.value, got, err, tc.want)
}
}
})
t.Run("category decode strips with a notice", func(t *testing.T) {
got, err := plain.Decode(ctx, pact.FormFieldValue{Field: "content", Default: true, Value: `<p onclick="steal()">x</p>`})
if err != nil {
t.Fatal(err)
}
if got.Value != "<p>x</p>" || len(got.Notices) != 1 {
t.Fatalf("Decode = %#v", got)
}
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onclick" {
t.Fatalf("notice = %#v", n)
}
})
t.Run("category hooks map blank to NULL and accept markup", func(t *testing.T) {
ctl := categoriesAdminController{}
blank := " \n\t"
cat := &models.Category{Content: &blank}
if err := ctl.FormBeforeUpdate(ctx, cat); err != nil || cat.Content != nil {
t.Fatalf("blank: content = %v, err = %v, want NULL", cat.Content, err)
}
body := `<p onclick="steal()">x</p>`
cat = &models.Category{Content: &body}
if err := ctl.FormBeforeCreate(ctx, cat); err != nil {
t.Fatalf("FormBeforeCreate refused markup: %v", err)
}
if cat.Content == nil || *cat.Content != body {
t.Fatalf("content = %v, want it as received", cat.Content)
}
})
}