Files
summercms/modules/lagoon/attach/thumb.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

178 lines
4.9 KiB
Go

package attach
import (
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"path"
"regexp"
"strings"
"github.com/disintegration/imaging"
"gocloud.dev/blob"
)
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)
}
func publicURL(key string) string {
prefix := strings.TrimRight(PublicPathPrefix(), "/")
key = strings.TrimLeft(key, "/")
if prefix == "" {
return "/" + key
}
return prefix + "/" + key
}
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.
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 err != nil {
return "", fmt.Errorf("attach: read original: %w", err)
}
src, _, err := image.Decode(io.LimitReader(r, maxThumbSourceBytes))
closeErr := r.Close()
if err != nil {
return "", fmt.Errorf("attach: decode original: %w", err)
}
if closeErr != nil {
return "", closeErr
}
bounds := src.Bounds()
if int64(bounds.Dx())*int64(bounds.Dy()) > maxThumbSourcePixels {
return "", fmt.Errorf("attach: original image is too large")
}
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
}