A translation that carries only content_html (an API write through FormatTranslation) opened as an empty editor, and the first edit in that locale silently replaced the translated body. Load now serves the htmlColumn attribute whenever the stored content is blank and that attribute is not empty; an untouched save echoes it and is skipped, an edit writes the edited HTML to both columns.
394 lines
13 KiB
Go
394 lines
13 KiB
Go
package classes
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gocloud.dev/blob"
|
|
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"
|
|
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"
|
|
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
|
|
)
|
|
|
|
// 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_]*$`)
|
|
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
|
|
// 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, and a blank value as the
|
|
// htmlColumn attribute when that holds a body. 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 ("editor"
|
|
// when unset).
|
|
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
|
upload := uploadBlogImage(app)
|
|
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,
|
|
Upload: upload,
|
|
},
|
|
{
|
|
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,
|
|
Upload: upload,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 !validMediaFolder(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).
|
|
// A blank value is served as the htmlColumn attribute in the same locale
|
|
// when that is not empty, so a locale that carries only content_html (an
|
|
// API-written translation, FormatTranslation) opens with the body the site
|
|
// renders instead of an empty editor whose first edit would replace it; it
|
|
// is served as stored otherwise.
|
|
func loadBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) {
|
|
text := storedText(in.Value)
|
|
column, _ := in.Options["htmlColumn"].(string)
|
|
if column == "" || LooksLikeHTML(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
|
|
}
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return text, 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) + "…"
|
|
}
|
|
|
|
// 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
|
|
}
|