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______.. // 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 }