feat: SEO field types and the complete SEO tab

This commit is contained in:
2026-10-27 11:04:03 +01:00
parent deebd9aa65
commit 93ddbcc9ef
26 changed files with 1827 additions and 50 deletions

123
classes/image.go Normal file
View File

@@ -0,0 +1,123 @@
package classes
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"path"
"regexp"
"strings"
"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"
"gocloud.dev/blob"
)
// ImageFolder is the media folder social images are stored in.
const ImageFolder = "media/seo"
// ImageMaxBytes caps a social image upload (10 MiB, as the media library).
const ImageMaxBytes = 10 << 20
// The phrase keys of the image refusals.
const (
errImageInvalid = "golem15.seo::lang.image.invalid"
errImageTooBig = "golem15.seo::lang.image.too_big"
errImageURL = "golem15.seo::lang.image.url_invalid"
errImageForbidden = "golem15.seo::lang.image.forbidden"
)
// imageExtensions maps the sniffed image types to their file extensions.
var imageExtensions = map[string]string{
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
var unsafeNameChars = regexp.MustCompile(`[^a-z0-9._-]+`)
// safeImageName is the stored base name: lowercased, characters outside
// a-z, 0-9, '.', '_' and '-' turned into '-', at most 80 characters, with
// the sniffed extension.
func safeImageName(filename, ext string) string {
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
base = strings.TrimSuffix(base, path.Ext(base))
base = strings.Trim(unsafeNameChars.ReplaceAllString(strings.ToLower(base), "-"), "-.")
if base == "" {
base = "image"
}
if len(base) > 80 {
base = base[:80]
}
return base + "." + ext
}
// UploadImage is the seoimage field type's Upload (D-100, P-16): the
// administrator needs golem15.seo.manage; the first bytes must sniff as a
// JPEG, PNG, GIF or WebP image; the file is stored in the media bucket
// under media/seo/<random>-<safe name> (a name that is safe to leave behind
// when the save never happens) and its public URL is returned.
func UploadImage(app func() *backpack.App, permission string) func(context.Context, pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
return func(ctx context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
if p, ok := bouncer.User(ctx); !ok || !cabana.Allows(p, []string{permission}) {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageForbidden}}
}
var a *backpack.App
if app != nil {
a = app()
}
if a == nil {
return pact.FormFieldUploadResult{}, errors.New("golem15.seo: image upload: application is not configured")
}
bucket, ok := a.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
return pact.FormFieldUploadResult{}, errors.New("golem15.seo: image upload: media bucket is not configured")
}
if in.Body == nil {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageInvalid}}
}
data, err := io.ReadAll(io.LimitReader(in.Body, ImageMaxBytes+1))
if err != nil {
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: image upload: read: %w", err)
}
if len(data) > ImageMaxBytes {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageTooBig}}
}
ext, known := imageExtensions[http.DetectContentType(data)]
if !known {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{errImageInvalid}}
}
random := make([]byte, 6)
if _, err := rand.Read(random); err != nil {
return pact.FormFieldUploadResult{}, err
}
key := ImageFolder + "/" + hex.EncodeToString(random) + "-" + safeImageName(in.Filename, ext)
w, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{IfNotExist: true, ContentType: "image/" + strings.Replace(ext, "jpg", "jpeg", 1)})
if err != nil {
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: image writer: %w", err)
}
if _, err := io.Copy(w, bytes.NewReader(data)); err != nil {
_ = w.Close()
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: write image: %w", err)
}
if err := w.Close(); err != nil {
return pact.FormFieldUploadResult{}, fmt.Errorf("golem15.seo: close image: %w", err)
}
return pact.FormFieldUploadResult{URL: attach.PublicURL(key)}, nil
}
}
// ValidImageURL reports whether a stored social image value is empty, a
// path or an absolute http(s) URL.
func ValidImageURL(v string) bool {
return v == "" || ValidURLRef(v)
}