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 } }