- attach.PublicURL and (*File).URL build Winter File::getPath() URLs; the thumbnailer decodes webp via golang.org/x/image v0.46.0 and checks the image size from the header before decoding - tide requests carry multipart parts (files beside the fixture pinned by sha256) encoded with the fixed MultipartBoundary, so PHP and Go receive byte-identical bodies - tide masks the random partition, disk name and file id of url/thumb_url upload URLs while still diffing prefix, size, mode and extension, and NormalizePublications masks Carbon dates in the published album
210 lines
6.1 KiB
Go
210 lines
6.1 KiB
Go
package attach
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"gocloud.dev/blob"
|
|
// 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.
|
|
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)
|
|
}
|
|
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 "", fmt.Errorf("attach: decode original: %w", err)
|
|
}
|
|
if int64(cfg.Width)*int64(cfg.Height) > maxThumbSourcePixels {
|
|
return "", fmt.Errorf("attach: original image is too large")
|
|
}
|
|
src, _, err := image.Decode(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return "", fmt.Errorf("attach: decode original: %w", err)
|
|
}
|
|
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
|
|
}
|