124 lines
4.4 KiB
Go
124 lines
4.4 KiB
Go
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)
|
|
}
|