package imgcache import ( "bytes" "context" "errors" "fmt" "io" "sync" "github.com/davidbyttow/govips/v2/vips" ) // vipsOnce ensures vips is initialized exactly once. var vipsOnce sync.Once // initVips initializes libvips with quiet logging. func initVips() { vipsOnce.Do(func() { vips.LoggingSettings(nil, vips.LogLevelError) vips.Startup(nil) }) } // 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") // ErrUnsupportedOutputFormat is returned when the requested output format is not supported. var ErrUnsupportedOutputFormat = errors.New("unsupported output format") // ImageProcessor implements the Processor interface using libvips via govips. type ImageProcessor struct{} // NewImageProcessor creates a new image processor. func NewImageProcessor() *ImageProcessor { initVips() 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, err := vips.NewImageFromBuffer(data) if err != nil { return nil, fmt.Errorf("failed to decode image: %w", err) } defer img.Close() // Get original dimensions origWidth := img.Width() origHeight := img.Height() // Detect input format inputFormat := p.detectFormat(img) // Validate input dimensions to prevent DoS via decompression bombs if origWidth > MaxInputDimension || origHeight > MaxInputDimension { return nil, ErrInputTooLarge } // Determine target dimensions targetWidth := req.Size.Width targetHeight := req.Size.Height // Handle dimension calculation if targetWidth == 0 && targetHeight == 0 { // Both are 0: keep original size targetWidth = origWidth targetHeight = origHeight } else if targetWidth == 0 { // Only height specified: calculate width proportionally targetWidth = origWidth * targetHeight / origHeight } else if targetHeight == 0 { // Only width specified: calculate height proportionally targetHeight = origHeight * targetWidth / origWidth } // Resize if needed if targetWidth != origWidth || targetHeight != origHeight { if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil { return nil, fmt.Errorf("failed to resize: %w", err) } } // Determine output format outputFormat := req.Format if outputFormat == FormatOriginal || outputFormat == "" { outputFormat = p.formatFromString(inputFormat) } // Encode to target format output, err := p.encode(img, outputFormat, req.Quality) if err != nil { return nil, fmt.Errorf("failed to encode: %w", err) } return &ProcessResult{ Content: io.NopCloser(bytes.NewReader(output)), ContentLength: int64(len(output)), ContentType: ImageFormatToMIME(outputFormat), Width: img.Width(), Height: img.Height(), InputWidth: origWidth, InputHeight: origHeight, InputFormat: inputFormat, }, nil } // SupportedInputFormats returns MIME types this processor can read. func (p *ImageProcessor) SupportedInputFormats() []string { return []string{ string(MIMETypeJPEG), string(MIMETypePNG), string(MIMETypeGIF), string(MIMETypeWebP), string(MIMETypeAVIF), } } // SupportedOutputFormats returns formats this processor can write. func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat { return []ImageFormat{ FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, } } // detectFormat returns the format string from a vips image. func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string { format := img.Format() switch format { case vips.ImageTypeJPEG: return "jpeg" case vips.ImageTypePNG: return "png" case vips.ImageTypeGIF: return "gif" case vips.ImageTypeWEBP: return "webp" case vips.ImageTypeAVIF, vips.ImageTypeHEIF: return "avif" default: return "unknown" } } // resize resizes the image according to the fit mode. func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error { switch fit { case FitCover, "": // Resize and crop to fill exact dimensions (default) return img.Thumbnail(width, height, vips.InterestingCentre) case FitContain: // Resize to fit within dimensions, maintaining aspect ratio // Calculate target dimensions maintaining aspect ratio imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) scale := min(scaleW, scaleH) newW := int(float64(imgW) * scale) newH := int(float64(imgH) * scale) return img.Thumbnail(newW, newH, vips.InterestingNone) case FitFill: // Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) case FitInside: // Same as contain, but only shrink if img.Width() <= width && img.Height() <= height { return nil // Already fits } imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) scale := min(scaleW, scaleH) newW := int(float64(imgW) * scale) newH := int(float64(imgH) * scale) return img.Thumbnail(newW, newH, vips.InterestingNone) case FitOutside: // Resize so smallest dimension fits, may exceed target on other dimension imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) scale := max(scaleW, scaleH) newW := int(float64(imgW) * scale) newH := int(float64(imgH) * scale) return img.Thumbnail(newW, newH, vips.InterestingNone) default: return fmt.Errorf("%w: %s", ErrInvalidFitMode, fit) } } const defaultQuality = 85 // encode encodes an image to the specified format. func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) { if quality <= 0 { quality = defaultQuality } var params vips.ExportParams switch format { case FormatJPEG: params = vips.ExportParams{ Format: vips.ImageTypeJPEG, Quality: quality, } case FormatPNG: params = vips.ExportParams{ Format: vips.ImageTypePNG, } case FormatGIF: params = vips.ExportParams{ Format: vips.ImageTypeGIF, } case FormatWebP: params = vips.ExportParams{ Format: vips.ImageTypeWEBP, Quality: quality, } case FormatAVIF: params = vips.ExportParams{ Format: vips.ImageTypeAVIF, Quality: quality, } default: return nil, fmt.Errorf("unsupported output format: %s", format) } output, _, err := img.Export(¶ms) if err != nil { return nil, err } return output, 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 case "avif": return FormatAVIF default: return FormatJPEG } }