// Package imageprocessor provides image format conversion and resizing using libvips. package imageprocessor import ( "bytes" "context" "errors" "fmt" "io" "runtime" "sync" "time" "github.com/davidbyttow/govips/v2/vips" ) // vipsOnce ensures vips is initialized exactly once. // //nolint:gochecknoglobals // package-level sync.Once for one-time vips init var vipsOnce sync.Once // initVips initializes libvips with quiet logging, one worker thread per // image and no operation cache. Process already works on one image per CPU // by default, so more threads per image would only compete for the CPUs. // Each request decodes different source bytes, so the operation cache // would rarely be hit and would hold memory outside MaxConcurrentProcessing; // repeated requests are served from pixa's disk cache instead. func initVips() { vipsOnce.Do(func() { vips.LoggingSettings(nil, vips.LogLevelError) vips.Startup(&vips.Config{ ConcurrencyLevel: 1, MaxCacheSize: 0, MaxCacheMem: 0, MaxCacheFiles: 0, }) }) } // Format represents supported output image formats. type Format string // Supported image output formats. const ( FormatOriginal Format = "orig" FormatJPEG Format = "jpeg" FormatPNG Format = "png" FormatWebP Format = "webp" FormatAVIF Format = "avif" FormatGIF Format = "gif" ) // FitMode represents how to fit an image into requested dimensions. type FitMode string // Supported image fit modes. const ( FitCover FitMode = "cover" FitContain FitMode = "contain" FitFill FitMode = "fill" FitInside FitMode = "inside" FitOutside FitMode = "outside" ) // ErrInvalidFitMode is returned when an invalid fit mode is provided. var ErrInvalidFitMode = errors.New("invalid fit mode") // Size represents requested image dimensions. type Size struct { Width int Height int } // Request holds the parameters for image processing. type Request struct { Size Size Format Format Quality int FitMode FitMode } // Result contains the output of image processing. type Result struct { // Content is the processed image data. Content io.ReadCloser // ContentLength is the size in bytes. ContentLength int64 // ContentType is the MIME type of the output. ContentType string // Width is the output image width. Width int // Height is the output image height. Height int // InputWidth is the original image width before processing. InputWidth int // InputHeight is the original image height before processing. InputHeight int // InputFormat is the detected input format (e.g., "jpeg", "png"). InputFormat string } // 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 // DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB). // This matches the default upstream fetcher limit. const DefaultMaxInputBytes = 50 << 20 // ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension. var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") // ErrInputDataTooLarge is returned when the raw input data exceeds the // configured byte limit. var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size") // ErrUnsupportedOutputFormat is returned when the requested output format is // not supported. var ErrUnsupportedOutputFormat = errors.New("unsupported output format") // ErrTooManyImages is returned when MaxConcurrentProcessing images are being // processed and none finishes within ProcessingWaitTimeout. var ErrTooManyImages = errors.New("too many images being processed at once") // ProcessingWaitTimeout is how long Process waits for a free slot when // MaxConcurrentProcessing images are already being processed. const ProcessingWaitTimeout = 10 * time.Second // ImageProcessor implements image transformation using libvips via govips. type ImageProcessor struct { maxInputBytes int64 // processingSemaphore has one slot per image that may be processed at // once. Process holds a slot from before it reads its input until it // returns, so the input, the decoded image and the output all count. processingSemaphore chan struct{} // processingWaitTimeout is ProcessingWaitTimeout; tests shorten it. processingWaitTimeout time.Duration } // Params holds configuration for creating an ImageProcessor. // Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes). type Params struct { // MaxInputBytes is the maximum allowed input size in bytes. // If <= 0, DefaultMaxInputBytes is used. MaxInputBytes int64 // MaxConcurrentProcessing is the most images processed at once. // If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used. MaxConcurrentProcessing int } // New creates a new image processor with the given parameters. // A zero-value Params{} uses sensible defaults. func New(params Params) *ImageProcessor { initVips() maxInputBytes := params.MaxInputBytes if maxInputBytes <= 0 { maxInputBytes = DefaultMaxInputBytes } maxConcurrentProcessing := params.MaxConcurrentProcessing if maxConcurrentProcessing <= 0 { maxConcurrentProcessing = runtime.GOMAXPROCS(0) } return &ImageProcessor{ maxInputBytes: maxInputBytes, processingSemaphore: make(chan struct{}, maxConcurrentProcessing), processingWaitTimeout: ProcessingWaitTimeout, } } // Process transforms an image according to the request. When // MaxConcurrentProcessing images are already being processed, it waits up // to ProcessingWaitTimeout for one to finish, then fails with // ErrTooManyImages. func (p *ImageProcessor) Process( ctx context.Context, input io.Reader, req *Request, ) (*Result, error) { release, err := p.acquireSlot(ctx) if err != nil { return nil, err } defer release() // Read input with a size limit to prevent unbounded memory consumption. // We read at most maxInputBytes+1 so we can detect if the input exceeds // the limit without consuming additional memory. limited := io.LimitReader(input, p.maxInputBytes+1) data, err := io.ReadAll(limited) if err != nil { return nil, fmt.Errorf("failed to read input: %w", err) } if int64(len(data)) > p.maxInputBytes { return nil, ErrInputDataTooLarge } // Decode image img, err := vips.NewImageFromBuffer(data) if err != nil { return nil, fmt.Errorf("failed to decode image: %w", err) } defer img.Close() // Turn the image upright now: encode strips the EXIF orientation tag, // and sizes below must be worked out on the upright image. err = img.AutoRotate() if err != nil { return nil, fmt.Errorf("failed to auto-rotate: %w", err) } // 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, targetHeight := targetDimensions(req.Size, origWidth, origHeight) // Resize if needed if targetWidth != origWidth || targetHeight != origHeight { err := p.resize(img, targetWidth, targetHeight, req.FitMode) if 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 &Result{ Content: io.NopCloser(bytes.NewReader(output)), ContentLength: int64(len(output)), ContentType: FormatToMIME(outputFormat), Width: img.Width(), Height: img.Height(), InputWidth: origWidth, InputHeight: origHeight, InputFormat: inputFormat, }, nil } // targetDimensions calculates the output dimensions for a requested size, // scaling proportionally when only one dimension is given and keeping the // original dimensions when both are zero. func targetDimensions(size Size, origWidth, origHeight int) (int, int) { switch { case size.Width == 0 && size.Height == 0: // Both are 0: keep original size return origWidth, origHeight case size.Width == 0: // Only height specified: calculate width proportionally return origWidth * size.Height / origHeight, size.Height case size.Height == 0: // Only width specified: calculate height proportionally return size.Width, origHeight * size.Width / origWidth default: return size.Width, size.Height } } // MIME types for the supported image formats. const ( mimeJPEG = "image/jpeg" mimePNG = "image/png" mimeGIF = "image/gif" mimeWebP = "image/webp" mimeAVIF = "image/avif" ) // SupportedInputFormats returns MIME types this processor can read. func (p *ImageProcessor) SupportedInputFormats() []string { return []string{ mimeJPEG, mimePNG, mimeGIF, mimeWebP, mimeAVIF, } } // SupportedOutputFormats returns formats this processor can write. func (p *ImageProcessor) SupportedOutputFormats() []Format { return []Format{ FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, } } // FormatToMIME converts a Format to its MIME type string. func FormatToMIME(format Format) string { switch format { case FormatJPEG: return mimeJPEG case FormatPNG: return mimePNG case FormatWebP: return mimeWebP case FormatGIF: return mimeGIF case FormatAVIF: return mimeAVIF case FormatOriginal: return "application/octet-stream" default: return "application/octet-stream" } } // acquireSlot takes a slot in processingSemaphore, waiting at most // processingWaitTimeout for one to free up, and returns the func that gives // it back. A free slot is taken even when ctx has ended; only the wait for // one stops when ctx ends, as the rest of Process does not check ctx. func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) { release := func() { <-p.processingSemaphore } select { case p.processingSemaphore <- struct{}{}: return release, nil default: } select { case p.processingSemaphore <- struct{}{}: return release, nil case <-time.After(p.processingWaitTimeout): return nil, ErrTooManyImages case <-ctx.Done(): return nil, ctx.Err() } } // 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 string(FormatAVIF) case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF, vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP, vips.ImageTypeJP2K, vips.ImageTypeJXL: return "unknown" 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 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) 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 Format, 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, } case FormatOriginal: return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) default: return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) } // Stripping drops the ICC profile as well, and clients show an image // with no profile as sRGB, so convert to sRGB first. "srgb" names // libvips' built-in profile; govips' own sRGB path variable is set on // first use but read without a lock, so concurrent requests race on it. if img.HasICCProfile() { err := img.TransformICCProfileWithFallback("srgb", "srgb") if err != nil { return nil, fmt.Errorf("failed to convert to sRGB: %w", err) } } // Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for // GIF, which carries none of them. params.StripMetadata = true output, _, err := img.Export(¶ms) if err != nil { return nil, err } return output, nil } // formatFromString converts a format string to Format. func (p *ImageProcessor) formatFromString(format string) Format { switch format { case "jpeg": return FormatJPEG case "png": return FormatPNG case "gif": return FormatGIF case "webp": return FormatWebP case string(FormatAVIF): return FormatAVIF default: return FormatJPEG } }