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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user