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:
@@ -1,9 +1,13 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -11,6 +15,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gocloud.dev/blob"
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
@@ -21,6 +26,7 @@ const BlogEditorElement = "golem15-journal-blogeditor"
|
||||
// Phrase keys of the Blog Editor field types.
|
||||
const (
|
||||
editorContentInvalid = "golem15.journal::lang.editor.content_invalid"
|
||||
editorImageInvalid = "golem15.journal::lang.editor.image_invalid"
|
||||
editorRemovedElements = "golem15.journal::lang.editor.removed_elements"
|
||||
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
|
||||
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
|
||||
@@ -30,9 +36,28 @@ const (
|
||||
// blogListValueRunes is how many runes of text a list column shows.
|
||||
const blogListValueRunes = 120
|
||||
|
||||
// blogUploadFolder is the upload folder of a field without uploadFolder.
|
||||
const blogUploadFolder = "editor"
|
||||
|
||||
var (
|
||||
blogHTMLColumnPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
blogUploadFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
||||
blogHTMLColumnPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
blogUploadExtPattern = regexp.MustCompile(`^\.[a-z0-9]+$`)
|
||||
// blogImageTypes maps an upload extension to the content type its first
|
||||
// bytes must sniff as.
|
||||
blogImageTypes = map[string]string{
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
}
|
||||
// blogImageExtensions is the extension given to a name without one.
|
||||
blogImageExtensions = map[string]string{
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
)
|
||||
|
||||
// BlogEditorTypes returns the Blog Editor admin field types: blogeditor for
|
||||
@@ -41,14 +66,17 @@ var (
|
||||
// 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.
|
||||
// rendered HTML of the htmlColumn option. Upload stores an image the editor
|
||||
// inserts through StoreMedia, in the media bucket the app publishes. app is
|
||||
// asked for the booted application at request time; it may be nil in tests,
|
||||
// where Upload then fails.
|
||||
//
|
||||
// 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.
|
||||
// folder under /journal/ that editor image uploads are stored in ("editor"
|
||||
// when unset).
|
||||
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
_ = app // used by the editor image upload
|
||||
upload := uploadBlogImage(app)
|
||||
return []pact.FormFieldType{
|
||||
{
|
||||
Name: "blogeditor",
|
||||
@@ -60,6 +88,7 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
Decode: decodeBlogHTML,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
Upload: upload,
|
||||
},
|
||||
{
|
||||
Name: "mlblogeditor",
|
||||
@@ -72,6 +101,7 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
Decode: decodeBlogHTML,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
Upload: upload,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -106,7 +136,7 @@ func checkBlogOptions(options map[string]any) error {
|
||||
|
||||
// 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, "..") {
|
||||
if !validMediaFolder(folder) {
|
||||
return errors.New("uploadFolder must be at most 100 letters, digits, '_', '-' or '/' without '..'")
|
||||
}
|
||||
return nil
|
||||
@@ -259,3 +289,96 @@ func blogListValue(_ context.Context, value any) string {
|
||||
runes := []rune(text)
|
||||
return strings.TrimRightFunc(string(runes[:blogListValueRunes]), unicode.IsSpace) + "…"
|
||||
}
|
||||
|
||||
// uploadBlogImage is the Blog Editor's Upload: it stores an image the
|
||||
// editor inserts through StoreMedia, in the field's uploadFolder, and
|
||||
// returns its URL. The file's first bytes must sniff as a JPEG, PNG, GIF or
|
||||
// WebP image of the type its extension names (a name without an extension
|
||||
// gets the sniffed one); anything else is refused with
|
||||
// golem15.journal::lang.editor.image_invalid. The stored name is
|
||||
// safeUploadName's. A MediaError is refused with its message; a missing
|
||||
// application or bucket is an opaque error.
|
||||
func uploadBlogImage(app func() *backpack.App) func(context.Context, pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
|
||||
return func(ctx context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
|
||||
var a *backpack.App
|
||||
if app != nil {
|
||||
a = app()
|
||||
}
|
||||
if a == nil {
|
||||
return pact.FormFieldUploadResult{}, errors.New("journal: editor upload: application is not configured")
|
||||
}
|
||||
bucket, ok := a.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil {
|
||||
return pact.FormFieldUploadResult{}, errors.New("journal: editor upload: media bucket is not configured")
|
||||
}
|
||||
if in.Body == nil {
|
||||
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{editorImageInvalid}}
|
||||
}
|
||||
head := make([]byte, 512)
|
||||
n, err := io.ReadFull(in.Body, head)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return pact.FormFieldUploadResult{}, fmt.Errorf("journal: editor upload: read: %w", err)
|
||||
}
|
||||
head = head[:n]
|
||||
sniffed := http.DetectContentType(head)
|
||||
ext, known := blogImageExtensions[sniffed]
|
||||
if !known {
|
||||
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{editorImageInvalid}}
|
||||
}
|
||||
name := safeUploadName(in.Filename)
|
||||
if named := strings.TrimPrefix(path.Ext(name), "."); named == "" {
|
||||
name += "." + ext
|
||||
} else if want, allowed := blogImageTypes[named]; allowed && want != sniffed {
|
||||
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{editorImageInvalid}}
|
||||
}
|
||||
folder, _ := in.Options["uploadFolder"].(string)
|
||||
if folder == "" {
|
||||
folder = blogUploadFolder
|
||||
}
|
||||
stored, err := StoreMedia(ctx, bucket, folder, name, io.MultiReader(bytes.NewReader(head), in.Body))
|
||||
if err != nil {
|
||||
var refused *MediaError
|
||||
if errors.As(err, &refused) && refused != nil {
|
||||
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{refused.Message}}
|
||||
}
|
||||
return pact.FormFieldUploadResult{}, err
|
||||
}
|
||||
return pact.FormFieldUploadResult{URL: stored.URL}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// safeUploadName is the stored name of an editor upload: the base name,
|
||||
// lowercased, every character outside a-z, 0-9, '.', '_' and '-' turned
|
||||
// into '-' with runs collapsed and no leading or trailing '-' or '.', and
|
||||
// the extension kept. An empty name becomes "image". The result needs no
|
||||
// escaping in an src attribute.
|
||||
func safeUploadName(filename string) string {
|
||||
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
||||
ext := strings.ToLower(path.Ext(base))
|
||||
if !blogUploadExtPattern.MatchString(ext) {
|
||||
ext = ""
|
||||
}
|
||||
stem := strings.ToLower(strings.TrimSuffix(base, path.Ext(base)))
|
||||
if ext == "" {
|
||||
stem = strings.ToLower(base)
|
||||
}
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range stem {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if !ok || r == '-' {
|
||||
if !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
}
|
||||
stem = strings.Trim(b.String(), "-.")
|
||||
if stem == "" {
|
||||
stem = "image"
|
||||
}
|
||||
return stem + ext
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user