Files
summercms/modules/lagoon/attach/thumb.go
Jakub Zych 1307060e15 fix(12-05): store WinterCMS's broken-image thumbnail for an unusable original (T-12-16)
A photo whose original is missing, does not decode or declares more than
4096x4096 pixels made attach.File.Thumb return an error, and every listing
that shows the photo answered 500 from then on: one 100-byte PNG uploaded
by any household member broke GET collections and the album for everyone.

Thumb now follows WinterCMS's File::makeThumb catch branch: it logs the
reason at warn level, stores WinterCMS's BrokenImage picture (exported as
attach.BrokenImagePNG) under the thumbnail key and returns its URL. Invalid
arguments, storage errors and encode failures are still errors.
2026-10-02 15:15:26 +02:00

251 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.
//
// 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) Thumb(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 PublicURL(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 PublicURL(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 its URL.
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 PublicURL(thumbKey), nil
}