- deferred_bindings migration set under summercms.deferred with backend_user_id - lagoon.DeferredBind/Unbind/Bindings/Forget/Slaves scoped by DeferredKey - lagoon.PurgeDeferred with SKIP LOCKED batches and after-commit blob deletes - attach.Store with the ported image guard, extension and MIME limits - attach.Relation, attach.HasRelations, attach.BlobKeys, File.ThumbKey - lagoon README and attachments docs
264 lines
15 KiB
Go
264 lines
15 KiB
Go
package attach
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"log/slog"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"gocloud.dev/blob"
|
|
"gocloud.dev/gcerrors"
|
|
// The webp decoder lets image.DecodeConfig and File.Thumb read .webp
|
|
// originals. imaging cannot encode webp, so a webp thumbnail holds JPEG
|
|
// bytes under the original's .webp name (see defaultEncodeImage).
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
const (
|
|
maxThumbEdge = 4096
|
|
maxThumbSourceBytes = 32 << 20
|
|
maxThumbSourcePixels = 4096 * 4096
|
|
)
|
|
|
|
// thumbToken is the alphabet allowed for the mode and extension segments of a
|
|
// thumb filename. Both are interpolated into a blob key, which fileblob maps
|
|
// to a filesystem path, so separators and dots must never reach it.
|
|
var thumbToken = regexp.MustCompile(`^[a-z0-9]+$`)
|
|
|
|
// ThumbFilename is Winter File::getThumbFilename: thumb_<id>_<w>_<h>_<ox>_<oy>_<mode>.<ext>.
|
|
// A mode or ext outside [a-z0-9]+ is coerced to "auto" / "jpg" so the result
|
|
// is always a single safe path element; File.Thumb rejects such input instead.
|
|
func ThumbFilename(id uint, w, h int, offsetX, offsetY int, mode, ext string) string {
|
|
if !thumbToken.MatchString(mode) {
|
|
mode = "auto"
|
|
}
|
|
if !thumbToken.MatchString(ext) {
|
|
ext = "jpg"
|
|
}
|
|
return fmt.Sprintf("thumb_%d_%d_%d_%d_%d_%s.%s", id, w, h, offsetX, offsetY, mode, ext)
|
|
}
|
|
|
|
// PartitionDirectory is Winter File::getPartitionDirectory: first 9 chars of
|
|
// disk_name split into 3 groups of 3, joined by '/', with a trailing slash.
|
|
func PartitionDirectory(diskName string) string {
|
|
var groups []string
|
|
for i := 0; i < len(diskName) && len(groups) < 3; i += 3 {
|
|
end := i + 3
|
|
if end > len(diskName) {
|
|
end = len(diskName)
|
|
}
|
|
groups = append(groups, diskName[i:end])
|
|
}
|
|
return strings.Join(groups, "/") + "/"
|
|
}
|
|
|
|
// BlobKey is the Winter on-disk key for an original file: partition + disk_name.
|
|
func BlobKey(diskName string) string {
|
|
return PartitionDirectory(diskName) + diskName
|
|
}
|
|
|
|
func fileExt(diskName string) string {
|
|
ext := strings.TrimPrefix(path.Ext(diskName), ".")
|
|
if ext == "" {
|
|
return "jpg"
|
|
}
|
|
return strings.ToLower(ext)
|
|
}
|
|
|
|
// PublicURL returns the public URL of a blob key: storage.uploads.
|
|
// public_path_prefix and the key joined by exactly one slash. With the
|
|
// WinterCMS layout (bucket rooted at storage/app/uploads/public, prefix
|
|
// /storage/app/uploads/public) it is Winter's File::getPath() path.
|
|
func PublicURL(key string) string {
|
|
prefix := strings.TrimRight(PublicPathPrefix(), "/")
|
|
key = strings.TrimLeft(key, "/")
|
|
if prefix == "" {
|
|
return "/" + key
|
|
}
|
|
return prefix + "/" + key
|
|
}
|
|
|
|
// URL returns the public URL of the original file, Winter's File::getPath():
|
|
// PublicURL of BlobKey(DiskName).
|
|
func (f *File) URL() string {
|
|
if f == nil {
|
|
return ""
|
|
}
|
|
return PublicURL(BlobKey(f.DiskName))
|
|
}
|
|
|
|
func defaultResizeImage(src image.Image, w, h int, mode string) image.Image {
|
|
switch strings.ToLower(mode) {
|
|
case "crop":
|
|
return imaging.Fill(src, w, h, imaging.Center, imaging.Lanczos)
|
|
case "exact":
|
|
return imaging.Resize(src, w, h, imaging.Lanczos)
|
|
default:
|
|
return imaging.Fit(src, w, h, imaging.Lanczos)
|
|
}
|
|
}
|
|
|
|
var resizeImage = defaultResizeImage
|
|
|
|
func defaultEncodeImage(w io.Writer, img image.Image, ext string) error {
|
|
format := imaging.JPEG
|
|
switch strings.ToLower(ext) {
|
|
case "png":
|
|
format = imaging.PNG
|
|
case "gif":
|
|
format = imaging.GIF
|
|
}
|
|
return imaging.Encode(w, img, format)
|
|
}
|
|
|
|
var encodeImage = defaultEncodeImage
|
|
|
|
// Thumb returns the public URL of a lazily generated thumbnail. The second
|
|
// call for the same dimensions hits the existing blob and does not resize.
|
|
// It is PublicURL of ThumbKey; see ThumbKey for the generation rules.
|
|
func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) {
|
|
key, err := f.ThumbKey(ctx, bucket, w, h, mode)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return PublicURL(key), nil
|
|
}
|
|
|
|
// ThumbKey returns the blob key of a lazily generated thumbnail, generating
|
|
// it on first use, so a caller that must not emit a public URL (a protected
|
|
// file) can stream the thumbnail itself. The second call for the same
|
|
// dimensions hits the existing blob and does not resize.
|
|
//
|
|
// As WinterCMS's File::makeThumb does, an original that is missing from
|
|
// the bucket, does not decode, or declares more than 4096 by 4096 pixels
|
|
// gets WinterCMS's broken-image picture (BrokenImagePNG) stored as its
|
|
// thumbnail, and the failure is logged at warn level instead of returned:
|
|
// one unusable upload never fails the listings that show it. An invalid
|
|
// mode or size, a storage error and an encode failure are still errors.
|
|
func (f *File) ThumbKey(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) {
|
|
if f == nil {
|
|
return "", fmt.Errorf("attach: file is nil")
|
|
}
|
|
if bucket == nil {
|
|
return "", fmt.Errorf("attach: bucket is nil")
|
|
}
|
|
if mode == "" {
|
|
mode = "auto"
|
|
}
|
|
mode = strings.ToLower(mode)
|
|
if !thumbToken.MatchString(mode) {
|
|
return "", fmt.Errorf("attach: invalid thumb mode %q", mode)
|
|
}
|
|
if w <= 0 || h <= 0 || w > maxThumbEdge || h > maxThumbEdge {
|
|
return "", fmt.Errorf("attach: thumb size %dx%d is out of range", w, h)
|
|
}
|
|
ext := fileExt(f.DiskName)
|
|
if !thumbToken.MatchString(ext) {
|
|
return "", fmt.Errorf("attach: invalid thumb extension %q", ext)
|
|
}
|
|
thumbName := ThumbFilename(f.ID, w, h, 0, 0, mode, ext)
|
|
part := PartitionDirectory(f.DiskName)
|
|
thumbKey := part + thumbName
|
|
exists, err := bucket.Exists(ctx, thumbKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("attach: thumb exists: %w", err)
|
|
}
|
|
if exists {
|
|
return thumbKey, nil
|
|
}
|
|
origKey := part + f.DiskName
|
|
r, err := bucket.NewReader(ctx, origKey, nil)
|
|
if gcerrors.Code(err) == gcerrors.NotFound {
|
|
return brokenThumb(ctx, bucket, f, thumbKey, err)
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("attach: read original: %w", err)
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(r, maxThumbSourceBytes))
|
|
closeErr := r.Close()
|
|
if err != nil {
|
|
return "", fmt.Errorf("attach: read original: %w", err)
|
|
}
|
|
if closeErr != nil {
|
|
return "", closeErr
|
|
}
|
|
// Check the dimensions from the header before decoding the pixels, so
|
|
// a small file that declares a huge image is refused without
|
|
// allocating it.
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return brokenThumb(ctx, bucket, f, thumbKey, fmt.Errorf("decode original: %w", err))
|
|
}
|
|
if int64(cfg.Width)*int64(cfg.Height) > maxThumbSourcePixels {
|
|
return brokenThumb(ctx, bucket, f, thumbKey, errOriginalTooLarge)
|
|
}
|
|
src, _, err := image.Decode(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return brokenThumb(ctx, bucket, f, thumbKey, fmt.Errorf("decode original: %w", err))
|
|
}
|
|
bounds := src.Bounds()
|
|
if int64(bounds.Dx())*int64(bounds.Dy()) > maxThumbSourcePixels {
|
|
return brokenThumb(ctx, bucket, f, thumbKey, errOriginalTooLarge)
|
|
}
|
|
resized := resizeImage(src, w, h, mode)
|
|
contentType := "image/jpeg"
|
|
switch ext {
|
|
case "png":
|
|
contentType = "image/png"
|
|
case "gif":
|
|
contentType = "image/gif"
|
|
}
|
|
wr, err := bucket.NewWriter(ctx, thumbKey, &blob.WriterOptions{ContentType: contentType})
|
|
if err != nil {
|
|
return "", fmt.Errorf("attach: thumb writer: %w", err)
|
|
}
|
|
encErr := encodeImage(wr, resized, ext)
|
|
closeErr = wr.Close()
|
|
if encErr != nil || closeErr != nil {
|
|
_ = deleteKey(ctx, bucket, thumbKey)
|
|
if encErr != nil {
|
|
return "", encErr
|
|
}
|
|
return "", closeErr
|
|
}
|
|
return thumbKey, nil
|
|
}
|
|
|
|
var errOriginalTooLarge = errors.New("original image is too large")
|
|
|
|
// brokenImageBase64 is WinterCMS's Database\Attach\BrokenImage picture, a
|
|
// 200x200 PNG, byte for byte.
|
|
const brokenImageBase64 = "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"
|
|
|
|
// BrokenImagePNG is the PNG Thumb stores as the thumbnail of an unusable
|
|
// original: WinterCMS's BrokenImage picture.
|
|
var BrokenImagePNG = func() []byte {
|
|
b, err := base64.StdEncoding.DecodeString(brokenImageBase64)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}()
|
|
|
|
// brokenThumb is the catch branch of WinterCMS's File::makeThumb: log the
|
|
// reason, store BrokenImagePNG under the thumbnail key and return that key.
|
|
func brokenThumb(ctx context.Context, bucket *blob.Bucket, f *File, thumbKey string, reason error) (string, error) {
|
|
slog.Default().WarnContext(ctx, "attach: thumbnail original is unusable, storing the broken-image picture",
|
|
slog.Uint64("file_id", uint64(f.ID)), slog.String("error", reason.Error()))
|
|
if err := bucket.WriteAll(ctx, thumbKey, BrokenImagePNG, &blob.WriterOptions{ContentType: "image/png"}); err != nil {
|
|
return "", fmt.Errorf("attach: broken-image thumb: %w", err)
|
|
}
|
|
return thumbKey, nil
|
|
}
|