Tests for: - ErrInputTooLarge when input image exceeds MaxInputDimension - ErrPathTraversal for ../, encoded traversal, backslashes, null bytes
253 lines
5.9 KiB
Go
253 lines
5.9 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"golang.org/x/image/webp"
|
|
)
|
|
|
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
|
const MaxInputDimension = 8192
|
|
|
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
|
|
|
// ImageProcessor implements the Processor interface using pure Go libraries.
|
|
type ImageProcessor struct{}
|
|
|
|
// NewImageProcessor creates a new image processor.
|
|
func NewImageProcessor() *ImageProcessor {
|
|
return &ImageProcessor{}
|
|
}
|
|
|
|
// Process transforms an image according to the request.
|
|
func (p *ImageProcessor) Process(
|
|
_ context.Context,
|
|
input io.Reader,
|
|
req *ImageRequest,
|
|
) (*ProcessResult, error) {
|
|
// Read input
|
|
data, err := io.ReadAll(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
|
|
// Decode image
|
|
img, format, err := p.decode(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
|
}
|
|
|
|
// Get original dimensions
|
|
bounds := img.Bounds()
|
|
origWidth := bounds.Dx()
|
|
origHeight := bounds.Dy()
|
|
|
|
// Determine target dimensions
|
|
targetWidth := req.Size.Width
|
|
targetHeight := req.Size.Height
|
|
|
|
// If both are 0, keep original size
|
|
if targetWidth == 0 && targetHeight == 0 {
|
|
targetWidth = origWidth
|
|
targetHeight = origHeight
|
|
}
|
|
|
|
// Resize if needed
|
|
if targetWidth != origWidth || targetHeight != origHeight {
|
|
img = p.resize(img, targetWidth, targetHeight, req.FitMode)
|
|
}
|
|
|
|
// Determine output format
|
|
outputFormat := req.Format
|
|
if outputFormat == FormatOriginal || outputFormat == "" {
|
|
outputFormat = p.formatFromString(format)
|
|
}
|
|
|
|
// Encode to target format
|
|
output, err := p.encode(img, outputFormat, req.Quality)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
|
}
|
|
|
|
finalBounds := img.Bounds()
|
|
|
|
return &ProcessResult{
|
|
Content: io.NopCloser(bytes.NewReader(output)),
|
|
ContentLength: int64(len(output)),
|
|
ContentType: ImageFormatToMIME(outputFormat),
|
|
Width: finalBounds.Dx(),
|
|
Height: finalBounds.Dy(),
|
|
}, nil
|
|
}
|
|
|
|
// SupportedInputFormats returns MIME types this processor can read.
|
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
|
return []string{
|
|
string(MIMETypeJPEG),
|
|
string(MIMETypePNG),
|
|
string(MIMETypeGIF),
|
|
string(MIMETypeWebP),
|
|
}
|
|
}
|
|
|
|
// SupportedOutputFormats returns formats this processor can write.
|
|
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
|
return []ImageFormat{
|
|
FormatJPEG,
|
|
FormatPNG,
|
|
FormatGIF,
|
|
// WebP encoding not supported in pure Go, will fall back to PNG
|
|
}
|
|
}
|
|
|
|
// decode decodes image data into an image.Image.
|
|
func (p *ImageProcessor) decode(data []byte) (image.Image, string, error) {
|
|
r := bytes.NewReader(data)
|
|
|
|
// Try standard formats first
|
|
img, format, err := image.Decode(r)
|
|
if err == nil {
|
|
return img, format, nil
|
|
}
|
|
|
|
// Try WebP
|
|
r.Reset(data)
|
|
|
|
img, err = webp.Decode(r)
|
|
if err == nil {
|
|
return img, "webp", nil
|
|
}
|
|
|
|
return nil, "", fmt.Errorf("unsupported image format")
|
|
}
|
|
|
|
// resize resizes the image according to the fit mode.
|
|
func (p *ImageProcessor) resize(img image.Image, width, height int, fit FitMode) image.Image {
|
|
switch fit {
|
|
case FitCover:
|
|
// Resize and crop to fill exact dimensions
|
|
return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
|
|
|
|
case FitContain:
|
|
// Resize to fit within dimensions, maintaining aspect ratio
|
|
return imaging.Fit(img, width, height, imaging.Lanczos)
|
|
|
|
case FitFill:
|
|
// Resize to exact dimensions (may distort)
|
|
return imaging.Resize(img, width, height, imaging.Lanczos)
|
|
|
|
case FitInside:
|
|
// Same as contain, but only shrink
|
|
bounds := img.Bounds()
|
|
origW := bounds.Dx()
|
|
origH := bounds.Dy()
|
|
|
|
if origW <= width && origH <= height {
|
|
return img // Already fits
|
|
}
|
|
|
|
return imaging.Fit(img, width, height, imaging.Lanczos)
|
|
|
|
case FitOutside:
|
|
// Resize so smallest dimension fits, may exceed target on other dimension
|
|
bounds := img.Bounds()
|
|
origW := bounds.Dx()
|
|
origH := bounds.Dy()
|
|
|
|
scaleW := float64(width) / float64(origW)
|
|
scaleH := float64(height) / float64(origH)
|
|
|
|
scale := scaleW
|
|
if scaleH > scaleW {
|
|
scale = scaleH
|
|
}
|
|
|
|
newW := int(float64(origW) * scale)
|
|
newH := int(float64(origH) * scale)
|
|
|
|
return imaging.Resize(img, newW, newH, imaging.Lanczos)
|
|
|
|
default:
|
|
// Default to cover
|
|
return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
|
|
}
|
|
}
|
|
|
|
const defaultQuality = 85
|
|
|
|
// encode encodes an image to the specified format.
|
|
func (p *ImageProcessor) encode(img image.Image, format ImageFormat, quality int) ([]byte, error) {
|
|
if quality <= 0 {
|
|
quality = defaultQuality
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
switch format {
|
|
case FormatJPEG:
|
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case FormatPNG:
|
|
err := png.Encode(&buf, img)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case FormatGIF:
|
|
err := gif.Encode(&buf, img, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case FormatWebP:
|
|
// Pure Go doesn't have WebP encoder, fall back to PNG
|
|
// TODO: Add WebP encoding support via external library
|
|
err := png.Encode(&buf, img)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case FormatAVIF:
|
|
// AVIF not supported in pure Go, fall back to JPEG
|
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported output format: %s", format)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// formatFromString converts a format string to ImageFormat.
|
|
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
|
switch format {
|
|
case "jpeg":
|
|
return FormatJPEG
|
|
case "png":
|
|
return FormatPNG
|
|
case "gif":
|
|
return FormatGIF
|
|
case "webp":
|
|
return FormatWebP
|
|
default:
|
|
return FormatJPEG
|
|
}
|
|
}
|