- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
178 lines
4.9 KiB
Go
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
|
|
}
|