Bound imageprocessor.Process input read to prevent unbounded memory use #37
@@ -1,4 +1,5 @@
|
||||
package imgcache
|
||||
// Package imageprocessor provides image format conversion and resizing using libvips.
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -22,6 +23,68 @@ func initVips() {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -39,7 +102,7 @@ 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 the Processor interface using libvips via govips.
|
||||
// ImageProcessor implements image transformation using libvips via govips.
|
||||
type ImageProcessor struct {
|
||||
maxInputBytes int64
|
||||
}
|
||||
@@ -71,8 +134,8 @@ func New(params Params) *ImageProcessor {
|
||||
func (p *ImageProcessor) Process(
|
||||
_ context.Context,
|
||||
input io.Reader,
|
||||
req *ImageRequest,
|
||||
) (*ProcessResult, error) {
|
||||
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.
|
||||
@@ -142,10 +205,10 @@ func (p *ImageProcessor) Process(
|
||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||
}
|
||||
|
||||
return &ProcessResult{
|
||||
return &Result{
|
||||
Content: io.NopCloser(bytes.NewReader(output)),
|
||||
ContentLength: int64(len(output)),
|
||||
ContentType: ImageFormatToMIME(outputFormat),
|
||||
ContentType: FormatToMIME(outputFormat),
|
||||
Width: img.Width(),
|
||||
Height: img.Height(),
|
||||
InputWidth: origWidth,
|
||||
@@ -157,17 +220,17 @@ func (p *ImageProcessor) Process(
|
||||
// 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),
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
}
|
||||
}
|
||||
|
||||
// SupportedOutputFormats returns formats this processor can write.
|
||||
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
||||
return []ImageFormat{
|
||||
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
||||
return []Format{
|
||||
FormatJPEG,
|
||||
FormatPNG,
|
||||
FormatGIF,
|
||||
@@ -176,6 +239,24 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -204,7 +285,6 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
||||
|
||||
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)
|
||||
@@ -215,7 +295,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||
|
||||
case FitFill:
|
||||
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force
|
||||
// Resize to exact dimensions (may distort)
|
||||
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||
|
||||
case FitInside:
|
||||
@@ -251,7 +331,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
||||
const defaultQuality = 85
|
||||
|
||||
// encode encodes an image to the specified format.
|
||||
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
|
||||
func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) {
|
||||
if quality <= 0 {
|
||||
quality = defaultQuality
|
||||
}
|
||||
@@ -299,8 +379,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// formatFromString converts a format string to ImageFormat.
|
||||
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
||||
// formatFromString converts a format string to Format.
|
||||
func (p *ImageProcessor) formatFromString(format string) Format {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return FormatJPEG
|
||||
@@ -1,4 +1,4 @@
|
||||
package imgcache
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -70,13 +70,36 @@ func createTestPNG(t *testing.T, width, height int) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||
func detectMIME(data []byte) string {
|
||||
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||
return "image/png"
|
||||
}
|
||||
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||
return "image/gif"
|
||||
}
|
||||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||
return "image/webp"
|
||||
}
|
||||
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
|
||||
brand := string(data[8:12])
|
||||
if brand == "avif" || brand == "avis" {
|
||||
return "image/avif"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||
proc := New(Params{})
|
||||
ctx := context.Background()
|
||||
|
||||
input := createTestJPEG(t, 800, 600)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 400, Height: 300},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -107,13 +130,9 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
mime, err := DetectFormat(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectFormat() error = %v", err)
|
||||
}
|
||||
|
||||
if mime != MIMETypeJPEG {
|
||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
||||
mime := detectMIME(data)
|
||||
if mime != "image/jpeg" {
|
||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +142,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||
|
||||
input := createTestJPEG(t, 200, 150)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 200, Height: 150},
|
||||
Format: FormatPNG,
|
||||
FitMode: FitCover,
|
||||
@@ -140,13 +159,9 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
mime, err := DetectFormat(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectFormat() error = %v", err)
|
||||
}
|
||||
|
||||
if mime != MIMETypePNG {
|
||||
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
|
||||
mime := detectMIME(data)
|
||||
if mime != "image/png" {
|
||||
t.Errorf("Output format = %v, want image/png", mime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +171,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
||||
|
||||
input := createTestJPEG(t, 640, 480)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 0, Height: 0}, // Original size
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -186,7 +201,7 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
||||
// Should result in 400x200 (maintaining aspect ratio)
|
||||
input := createTestJPEG(t, 800, 400)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 400, Height: 400},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -213,7 +228,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
||||
// Should scale proportionally to 400x300
|
||||
input := createTestJPEG(t, 800, 600)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 400, Height: 0},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -243,7 +258,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||
// Should scale proportionally to 400x300
|
||||
input := createTestJPEG(t, 800, 600)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 0, Height: 300},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -271,7 +286,7 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||
|
||||
input := createTestPNG(t, 400, 300)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 200, Height: 150},
|
||||
Format: FormatPNG,
|
||||
FitMode: FitCover,
|
||||
@@ -292,11 +307,6 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProcessor_ImplementsInterface(t *testing.T) {
|
||||
// Verify ImageProcessor implements Processor interface
|
||||
var _ Processor = (*ImageProcessor)(nil)
|
||||
}
|
||||
|
||||
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||||
proc := New(Params{})
|
||||
|
||||
@@ -319,7 +329,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||
// This should be rejected before processing to prevent DoS
|
||||
input := createTestJPEG(t, 10000, 100)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 100, Height: 100},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -343,7 +353,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
||||
// Create an image with oversized height
|
||||
input := createTestJPEG(t, 100, 10000)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 100, Height: 100},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -365,10 +375,9 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an image at exactly MaxInputDimension - should be accepted
|
||||
// Using smaller dimensions to keep test fast
|
||||
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 100, Height: 100},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -388,7 +397,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||
|
||||
input := createTestJPEG(t, 200, 150)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 100, Height: 75},
|
||||
Format: FormatWebP,
|
||||
Quality: 80,
|
||||
@@ -407,13 +416,9 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
mime, err := DetectFormat(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectFormat() error = %v", err)
|
||||
}
|
||||
|
||||
if mime != MIMETypeWebP {
|
||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
|
||||
mime := detectMIME(data)
|
||||
if mime != "image/webp" {
|
||||
t.Errorf("Output format = %v, want image/webp", mime)
|
||||
}
|
||||
|
||||
// Verify dimensions
|
||||
@@ -436,7 +441,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||
}
|
||||
|
||||
// Request resize and convert to JPEG
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 2, Height: 2},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -455,13 +460,9 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
mime, err := DetectFormat(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectFormat() error = %v", err)
|
||||
}
|
||||
|
||||
if mime != MIMETypeJPEG {
|
||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
||||
mime := detectMIME(data)
|
||||
if mime != "image/jpeg" {
|
||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +478,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
||||
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||
}
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 100, Height: 75},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -502,7 +503,7 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
||||
proc := New(Params{MaxInputBytes: limit})
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 10, Height: 10},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -536,7 +537,7 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||
|
||||
input := createTestJPEG(t, 200, 150)
|
||||
|
||||
req := &ImageRequest{
|
||||
req := &Request{
|
||||
Size: Size{Width: 100, Height: 75},
|
||||
Format: FormatAVIF,
|
||||
Quality: 85,
|
||||
@@ -555,13 +556,9 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
mime, err := DetectFormat(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectFormat() error = %v", err)
|
||||
}
|
||||
|
||||
if mime != MIMETypeAVIF {
|
||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
|
||||
mime := detectMIME(data)
|
||||
if mime != "image/avif" {
|
||||
t.Errorf("Output format = %v, want image/avif", mime)
|
||||
}
|
||||
|
||||
// Verify dimensions
|
||||
BIN
internal/imageprocessor/testdata/red.avif
vendored
Normal file
BIN
internal/imageprocessor/testdata/red.avif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 B |
@@ -199,36 +199,6 @@ type FetchResult struct {
|
||||
TLSCipherSuite string
|
||||
}
|
||||
|
||||
// Processor handles image transformation (resize, format conversion)
|
||||
type Processor interface {
|
||||
// Process transforms an image according to the request
|
||||
Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error)
|
||||
// SupportedInputFormats returns MIME types this processor can read
|
||||
SupportedInputFormats() []string
|
||||
// SupportedOutputFormats returns formats this processor can write
|
||||
SupportedOutputFormats() []ImageFormat
|
||||
}
|
||||
|
||||
// ProcessResult contains the result of image processing
|
||||
type ProcessResult 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
|
||||
}
|
||||
|
||||
// Storage handles persistent storage of cached content
|
||||
type Storage interface {
|
||||
// Store saves content and returns its hash
|
||||
|
||||
@@ -11,13 +11,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
)
|
||||
|
||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
||||
type Service struct {
|
||||
cache *Cache
|
||||
fetcher Fetcher
|
||||
processor Processor
|
||||
processor *imageprocessor.ImageProcessor
|
||||
signer *Signer
|
||||
whitelist *HostWhitelist
|
||||
log *slog.Logger
|
||||
@@ -82,7 +83,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
return &Service{
|
||||
cache: cfg.Cache,
|
||||
fetcher: fetcher,
|
||||
processor: New(Params{MaxInputBytes: maxResponseSize}),
|
||||
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
|
||||
signer: signer,
|
||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
||||
log: log,
|
||||
@@ -300,7 +301,14 @@ func (s *Service) processAndStore(
|
||||
// Process the image
|
||||
processStart := time.Now()
|
||||
|
||||
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req)
|
||||
processReq := &imageprocessor.Request{
|
||||
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
|
||||
Format: imageprocessor.Format(req.Format),
|
||||
Quality: req.Quality,
|
||||
FitMode: imageprocessor.FitMode(req.FitMode),
|
||||
}
|
||||
|
||||
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user