All checks were successful
check / check (push) Successful in 4s
closes #31 ## Problem `ImageProcessor.Process` used `io.ReadAll(input)` without any size limit, allowing arbitrarily large inputs to exhaust all available memory. This is a DoS vector — even though the upstream fetcher has a `MaxResponseSize` limit (50 MiB), the processor interface accepts any `io.Reader` and should defend itself independently. Additionally, the service layer's `processFromSourceOrFetch` read cached source content with `io.ReadAll` without a bound, so an unexpectedly large cached file could also cause unbounded memory consumption. ## Changes ### Processor (`processor.go`) - Added `maxInputBytes` field to `ImageProcessor` (configurable, defaults to 50 MiB via `DefaultMaxInputBytes`) - `NewImageProcessor` now accepts a `maxInputBytes` parameter (0 or negative uses the default) - `Process` now wraps the input reader with `io.LimitReader` and rejects inputs exceeding the limit with `ErrInputDataTooLarge` - Added `DefaultMaxInputBytes` and `ErrInputDataTooLarge` exported constants/errors ### Service (`service.go`) - `NewService` now wires the fetcher's `MaxResponseSize` through to the processor - Extracted `loadCachedSource` helper method to flatten nesting in `processFromSourceOrFetch` - Cached source reads are now bounded by `maxResponseSize` — oversized cached files are discarded and re-fetched ### Tests (`processor_test.go`) - `TestImageProcessor_RejectsOversizedInputData` — verifies that inputs exceeding `maxInputBytes` are rejected with `ErrInputDataTooLarge` - `TestImageProcessor_AcceptsInputWithinLimit` — verifies that inputs within the limit are processed normally - `TestImageProcessor_DefaultMaxInputBytes` — verifies that 0 and negative values use the default - All existing tests updated to use `NewImageProcessor(0)` (default limit) Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@eeqj.de> Reviewed-on: #37 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
399 lines
10 KiB
Go
399 lines
10 KiB
Go
// Package imageprocessor provides image format conversion and resizing using libvips.
|
|
package imageprocessor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
|
|
"github.com/davidbyttow/govips/v2/vips"
|
|
)
|
|
|
|
// vipsOnce ensures vips is initialized exactly once.
|
|
var vipsOnce sync.Once //nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
|
|
|
// initVips initializes libvips with quiet logging.
|
|
func initVips() {
|
|
vipsOnce.Do(func() {
|
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
|
vips.Startup(nil)
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
|
|
// ImageProcessor implements image transformation using libvips via govips.
|
|
type ImageProcessor struct {
|
|
maxInputBytes int64
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
return &ImageProcessor{
|
|
maxInputBytes: maxInputBytes,
|
|
}
|
|
}
|
|
|
|
// Process transforms an image according to the request.
|
|
func (p *ImageProcessor) Process(
|
|
_ context.Context,
|
|
input io.Reader,
|
|
req *Request,
|
|
) (*Result, error) {
|
|
// 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()
|
|
|
|
// 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 &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
|
|
}
|
|
|
|
// SupportedInputFormats returns MIME types this processor can read.
|
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
|
return []string{
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/avif",
|
|
}
|
|
}
|
|
|
|
// 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 "image/jpeg"
|
|
case FormatPNG:
|
|
return "image/png"
|
|
case FormatWebP:
|
|
return "image/webp"
|
|
case FormatGIF:
|
|
return "image/gif"
|
|
case FormatAVIF:
|
|
return "image/avif"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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,
|
|
}
|
|
|
|
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 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 "avif":
|
|
return FormatAVIF
|
|
default:
|
|
return FormatJPEG
|
|
}
|
|
}
|