All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config (default: all minus six disabled linters, lll 88, tests included) and bump every golangci-lint pin to v2.12.2: - Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned) - script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new linux-amd64/arm64 release-archive sha256 pins Fix all 747 findings the stricter config surfaces, with no behavior changes: t.Parallel() throughout the test suite, static sentinel errors and errors.Is comparisons, checked error returns, context propagation (contextcheck/noctx), 88-column wrapping, extracted constants and helpers for goconst/dupl/funlen/cyclop, exhaustive switch cases replicating existing defaults, and white-box test files renamed to *_internal_test.go for testpackage. Three nolint:tagliatelle directives preserve the existing snake_case JSON wire and on-disk metadata formats.
432 lines
11 KiB
Go
432 lines
11 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.
|
|
//
|
|
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
|
var vipsOnce sync.Once
|
|
|
|
// 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, 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"
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|