From 18f218e0394f068853f1937cd36af52f64af19dd Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Mar 2026 01:59:15 -0700 Subject: [PATCH 1/3] bound imageprocessor.Process input read to prevent unbounded memory use ImageProcessor.Process used io.ReadAll without a size limit, allowing arbitrarily large inputs to exhaust memory. Add a configurable maxInputBytes limit (default 50 MiB, matching the fetcher limit) and reject inputs that exceed it with ErrInputDataTooLarge. Also bound the cached source content read in the service layer to prevent unexpectedly large cached files from consuming unbounded memory. Extracted loadCachedSource helper to reduce nesting complexity. --- internal/imgcache/processor.go | 36 +++++++++-- internal/imgcache/processor_test.go | 93 ++++++++++++++++++++++++----- internal/imgcache/service.go | 92 ++++++++++++++++++---------- 3 files changed, 168 insertions(+), 53 deletions(-) diff --git a/internal/imgcache/processor.go b/internal/imgcache/processor.go index 846983e..16e9de0 100644 --- a/internal/imgcache/processor.go +++ b/internal/imgcache/processor.go @@ -26,20 +26,36 @@ func initVips() { // 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 the Processor interface using libvips via govips. -type ImageProcessor struct{} +type ImageProcessor struct { + maxInputBytes int64 +} -// NewImageProcessor creates a new image processor. -func NewImageProcessor() *ImageProcessor { +// NewImageProcessor creates a new image processor with the given maximum input +// size in bytes. If maxInputBytes is <= 0, DefaultMaxInputBytes is used. +func NewImageProcessor(maxInputBytes int64) *ImageProcessor { initVips() - return &ImageProcessor{} + if maxInputBytes <= 0 { + maxInputBytes = DefaultMaxInputBytes + } + + return &ImageProcessor{ + maxInputBytes: maxInputBytes, + } } // Process transforms an image according to the request. @@ -48,12 +64,20 @@ func (p *ImageProcessor) Process( input io.Reader, req *ImageRequest, ) (*ProcessResult, error) { - // Read input - data, err := io.ReadAll(input) + // 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 { diff --git a/internal/imgcache/processor_test.go b/internal/imgcache/processor_test.go index 374826d..d27bd74 100644 --- a/internal/imgcache/processor_test.go +++ b/internal/imgcache/processor_test.go @@ -71,7 +71,7 @@ func createTestPNG(t *testing.T, width, height int) []byte { } func TestImageProcessor_ResizeJPEG(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() input := createTestJPEG(t, 800, 600) @@ -118,7 +118,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) { } func TestImageProcessor_ConvertToPNG(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() input := createTestJPEG(t, 200, 150) @@ -151,7 +151,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) { } func TestImageProcessor_OriginalSize(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() input := createTestJPEG(t, 640, 480) @@ -179,7 +179,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) { } func TestImageProcessor_FitContain(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // 800x400 image (2:1 aspect) into 400x400 box with contain @@ -206,7 +206,7 @@ func TestImageProcessor_FitContain(t *testing.T) { } func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // 800x600 image, request width=400 height=0 @@ -236,7 +236,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { } func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // 800x600 image, request width=0 height=300 @@ -266,7 +266,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { } func TestImageProcessor_ProcessPNG(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() input := createTestPNG(t, 400, 300) @@ -298,7 +298,7 @@ func TestImageProcessor_ImplementsInterface(t *testing.T) { } func TestImageProcessor_SupportedFormats(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) inputFormats := proc.SupportedInputFormats() if len(inputFormats) == 0 { @@ -312,7 +312,7 @@ func TestImageProcessor_SupportedFormats(t *testing.T) { } func TestImageProcessor_RejectsOversizedInput(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // Create an image that exceeds MaxInputDimension (e.g., 10000x100) @@ -337,7 +337,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) { } func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // Create an image with oversized height @@ -361,7 +361,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { } func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // Create an image at exactly MaxInputDimension - should be accepted @@ -383,7 +383,7 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { } func TestImageProcessor_EncodeWebP(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() input := createTestJPEG(t, 200, 150) @@ -426,7 +426,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) { } func TestImageProcessor_DecodeAVIF(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() // Load test AVIF file @@ -465,8 +465,73 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) { } } +func TestImageProcessor_RejectsOversizedInputData(t *testing.T) { + // Create a processor with a very small byte limit + const limit = 1024 + proc := NewImageProcessor(limit) + ctx := context.Background() + + // Create a valid JPEG that exceeds the byte limit + input := createTestJPEG(t, 800, 600) // will be well over 1 KiB + if int64(len(input)) <= limit { + t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input)) + } + + req := &ImageRequest{ + Size: Size{Width: 100, Height: 75}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + _, err := proc.Process(ctx, bytes.NewReader(input), req) + if err == nil { + t.Fatal("Process() should reject input exceeding maxInputBytes") + } + + if err != ErrInputDataTooLarge { + t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err) + } +} + +func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) { + // Create a small image and set limit well above its size + input := createTestJPEG(t, 10, 10) + limit := int64(len(input)) * 10 // 10× headroom + + proc := NewImageProcessor(limit) + ctx := context.Background() + + req := &ImageRequest{ + Size: Size{Width: 10, Height: 10}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + result, err := proc.Process(ctx, bytes.NewReader(input), req) + if err != nil { + t.Fatalf("Process() error = %v, want nil", err) + } + defer result.Content.Close() +} + +func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) { + // Passing 0 should use the default + proc := NewImageProcessor(0) + if proc.maxInputBytes != DefaultMaxInputBytes { + t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) + } + + // Passing negative should also use the default + proc = NewImageProcessor(-1) + if proc.maxInputBytes != DefaultMaxInputBytes { + t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) + } +} + func TestImageProcessor_EncodeAVIF(t *testing.T) { - proc := NewImageProcessor() + proc := NewImageProcessor(0) ctx := context.Background() input := createTestJPEG(t, 200, 150) diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index 53b99d2..2000f6c 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -15,13 +15,14 @@ import ( // Service implements the ImageCache interface, orchestrating cache, fetcher, and processor. type Service struct { - cache *Cache - fetcher Fetcher - processor Processor - signer *Signer - whitelist *HostWhitelist - log *slog.Logger - allowHTTP bool + cache *Cache + fetcher Fetcher + processor Processor + signer *Signer + whitelist *HostWhitelist + log *slog.Logger + allowHTTP bool + maxResponseSize int64 } // ServiceConfig holds configuration for the image service. @@ -50,15 +51,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) { return nil, errors.New("signing key is required") } + // Resolve fetcher config for defaults + fetcherCfg := cfg.FetcherConfig + if fetcherCfg == nil { + fetcherCfg = DefaultFetcherConfig() + } + // Use custom fetcher if provided, otherwise create HTTP fetcher var fetcher Fetcher if cfg.Fetcher != nil { fetcher = cfg.Fetcher } else { - fetcherCfg := cfg.FetcherConfig - if fetcherCfg == nil { - fetcherCfg = DefaultFetcherConfig() - } fetcher = NewHTTPFetcher(fetcherCfg) } @@ -74,14 +77,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) { allowHTTP = cfg.FetcherConfig.AllowHTTP } + maxResponseSize := fetcherCfg.MaxResponseSize + return &Service{ - cache: cfg.Cache, - fetcher: fetcher, - processor: NewImageProcessor(), - signer: signer, - whitelist: NewHostWhitelist(cfg.Whitelist), - log: log, - allowHTTP: allowHTTP, + cache: cfg.Cache, + fetcher: fetcher, + processor: NewImageProcessor(maxResponseSize), + signer: signer, + whitelist: NewHostWhitelist(cfg.Whitelist), + log: log, + allowHTTP: allowHTTP, + maxResponseSize: maxResponseSize, }, nil } @@ -146,6 +152,40 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e return response, nil } +// loadCachedSource attempts to load source content from cache, returning nil +// if the cached data is unavailable or exceeds maxResponseSize. +func (s *Service) loadCachedSource(contentHash ContentHash) []byte { + reader, err := s.cache.GetSourceContent(contentHash) + if err != nil { + s.log.Warn("failed to load cached source, fetching", "error", err) + + return nil + } + + // Bound the read to maxResponseSize to prevent unbounded memory use + // from unexpectedly large cached files. + limited := io.LimitReader(reader, s.maxResponseSize+1) + data, err := io.ReadAll(limited) + _ = reader.Close() + + if err != nil { + s.log.Warn("failed to read cached source, fetching", "error", err) + + return nil + } + + if int64(len(data)) > s.maxResponseSize { + s.log.Warn("cached source exceeds max response size, discarding", + "hash", contentHash, + "max_bytes", s.maxResponseSize, + ) + + return nil + } + + return data +} + // processFromSourceOrFetch processes an image, using cached source content if available. func (s *Service) processFromSourceOrFetch( ctx context.Context, @@ -162,22 +202,8 @@ func (s *Service) processFromSourceOrFetch( var fetchBytes int64 if contentHash != "" { - // We have cached source - load it s.log.Debug("using cached source", "hash", contentHash) - - reader, err := s.cache.GetSourceContent(contentHash) - if err != nil { - s.log.Warn("failed to load cached source, fetching", "error", err) - // Fall through to fetch - } else { - sourceData, err = io.ReadAll(reader) - _ = reader.Close() - - if err != nil { - s.log.Warn("failed to read cached source, fetching", "error", err) - // Fall through to fetch - } - } + sourceData = s.loadCachedSource(contentHash) } // Fetch from upstream if we don't have source data or it's empty -- 2.49.1 From d36e511032880df932c23b4366bd670d9ef1f5a2 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Mar 2026 19:53:40 -0700 Subject: [PATCH 2/3] refactor: use Params struct for imageprocessor constructor Rename NewImageProcessor(maxInputBytes) to New(Params{}) with a Params struct containing MaxInputBytes. Zero-value Params{} uses sensible defaults (DefaultMaxInputBytes). All callers updated. Addresses review feedback on PR #37. --- internal/imgcache/processor.go | 15 +++++++++--- internal/imgcache/processor_test.go | 36 ++++++++++++++--------------- internal/imgcache/service.go | 2 +- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/internal/imgcache/processor.go b/internal/imgcache/processor.go index 16e9de0..887c2e7 100644 --- a/internal/imgcache/processor.go +++ b/internal/imgcache/processor.go @@ -44,11 +44,20 @@ type ImageProcessor struct { maxInputBytes int64 } -// NewImageProcessor creates a new image processor with the given maximum input -// size in bytes. If maxInputBytes is <= 0, DefaultMaxInputBytes is used. -func NewImageProcessor(maxInputBytes int64) *ImageProcessor { +// 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 } diff --git a/internal/imgcache/processor_test.go b/internal/imgcache/processor_test.go index d27bd74..51bf442 100644 --- a/internal/imgcache/processor_test.go +++ b/internal/imgcache/processor_test.go @@ -71,7 +71,7 @@ func createTestPNG(t *testing.T, width, height int) []byte { } func TestImageProcessor_ResizeJPEG(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 800, 600) @@ -118,7 +118,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) { } func TestImageProcessor_ConvertToPNG(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) @@ -151,7 +151,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) { } func TestImageProcessor_OriginalSize(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 640, 480) @@ -179,7 +179,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) { } func TestImageProcessor_FitContain(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // 800x400 image (2:1 aspect) into 400x400 box with contain @@ -206,7 +206,7 @@ func TestImageProcessor_FitContain(t *testing.T) { } func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // 800x600 image, request width=400 height=0 @@ -236,7 +236,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { } func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // 800x600 image, request width=0 height=300 @@ -266,7 +266,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { } func TestImageProcessor_ProcessPNG(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() input := createTestPNG(t, 400, 300) @@ -298,7 +298,7 @@ func TestImageProcessor_ImplementsInterface(t *testing.T) { } func TestImageProcessor_SupportedFormats(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) inputFormats := proc.SupportedInputFormats() if len(inputFormats) == 0 { @@ -312,7 +312,7 @@ func TestImageProcessor_SupportedFormats(t *testing.T) { } func TestImageProcessor_RejectsOversizedInput(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // Create an image that exceeds MaxInputDimension (e.g., 10000x100) @@ -337,7 +337,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) { } func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // Create an image with oversized height @@ -361,7 +361,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { } func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // Create an image at exactly MaxInputDimension - should be accepted @@ -383,7 +383,7 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { } func TestImageProcessor_EncodeWebP(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) @@ -426,7 +426,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) { } func TestImageProcessor_DecodeAVIF(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() // Load test AVIF file @@ -468,7 +468,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) { func TestImageProcessor_RejectsOversizedInputData(t *testing.T) { // Create a processor with a very small byte limit const limit = 1024 - proc := NewImageProcessor(limit) + proc := New(Params{MaxInputBytes: limit}) ctx := context.Background() // Create a valid JPEG that exceeds the byte limit @@ -499,7 +499,7 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) { input := createTestJPEG(t, 10, 10) limit := int64(len(input)) * 10 // 10× headroom - proc := NewImageProcessor(limit) + proc := New(Params{MaxInputBytes: limit}) ctx := context.Background() req := &ImageRequest{ @@ -518,20 +518,20 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) { func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) { // Passing 0 should use the default - proc := NewImageProcessor(0) + proc := New(Params{}) if proc.maxInputBytes != DefaultMaxInputBytes { t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) } // Passing negative should also use the default - proc = NewImageProcessor(-1) + proc = New(Params{MaxInputBytes: -1}) if proc.maxInputBytes != DefaultMaxInputBytes { t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) } } func TestImageProcessor_EncodeAVIF(t *testing.T) { - proc := NewImageProcessor(0) + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index 2000f6c..5cd66aa 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -82,7 +82,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) { return &Service{ cache: cfg.Cache, fetcher: fetcher, - processor: NewImageProcessor(maxResponseSize), + processor: New(Params{MaxInputBytes: maxResponseSize}), signer: signer, whitelist: NewHostWhitelist(cfg.Whitelist), log: log, -- 2.49.1 From d7e1cfaa241d2656f51beba0747a9e39a9ca25c2 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 17 Mar 2026 20:32:20 -0700 Subject: [PATCH 3/3] refactor: extract imageprocessor into its own package Move ImageProcessor, Params, New(), DefaultMaxInputBytes, ErrInputDataTooLarge, and related types from internal/imgcache/ into a new standalone package internal/imageprocessor/. The imageprocessor package defines its own Format, FitMode, Size, Request, and Result types, making it fully independent with no imports from imgcache. The imgcache service converts between its own types and imageprocessor types at the boundary. Changes: - New package: internal/imageprocessor/ with imageprocessor.go and tests - Removed: processor.go and processor_test.go from internal/imgcache/ - Removed: Processor interface and ProcessResult from imgcache.go (now unused) - Updated: service.go uses *imageprocessor.ImageProcessor directly - Copied: testdata/red.avif for AVIF decode test Addresses review feedback on PR #37: image processing is a distinct concern from the HTTP service layer and belongs in its own package. --- .../imageprocessor.go} | 116 +++++++++++++++--- .../imageprocessor_test.go} | 111 ++++++++--------- internal/imageprocessor/testdata/red.avif | Bin 0 -> 281 bytes internal/imgcache/imgcache.go | 30 ----- internal/imgcache/service.go | 14 ++- 5 files changed, 163 insertions(+), 108 deletions(-) rename internal/{imgcache/processor.go => imageprocessor/imageprocessor.go} (74%) rename internal/{imgcache/processor_test.go => imageprocessor/imageprocessor_test.go} (89%) create mode 100644 internal/imageprocessor/testdata/red.avif diff --git a/internal/imgcache/processor.go b/internal/imageprocessor/imageprocessor.go similarity index 74% rename from internal/imgcache/processor.go rename to internal/imageprocessor/imageprocessor.go index 887c2e7..9476b12 100644 --- a/internal/imgcache/processor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -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 diff --git a/internal/imgcache/processor_test.go b/internal/imageprocessor/imageprocessor_test.go similarity index 89% rename from internal/imgcache/processor_test.go rename to internal/imageprocessor/imageprocessor_test.go index 51bf442..59f9c79 100644 --- a/internal/imgcache/processor_test.go +++ b/internal/imageprocessor/imageprocessor_test.go @@ -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 diff --git a/internal/imageprocessor/testdata/red.avif b/internal/imageprocessor/testdata/red.avif new file mode 100644 index 0000000000000000000000000000000000000000..ae4624ba21e6803af856e275967a8231fa1b9a55 GIT binary patch literal 281 zcmZQzV30{GsVqn=%S>Ycg51nBLl8SRGZDnUmYZ6V2oeVZ#f+4kA_$X#p&&E41jdHZ zdkW|Xd$xjCHTpSn}fe-|KL1_jCc_2?YGcyltDOgk#$Vp`asRF7^EHf|! zF~c$oiVA?VMP@;AK9J@~EHiX&bjSh8iDVWRq=Fbgzyier1(_9@AOU6`9v&bimRXPs zb3f1#%#BRUKvDJFl*AGt1}=^WmfBuMUbcruf)WgT7LQvP7&Zupyfs+zn&%Gyqc|_Q literal 0 HcmV?d00001 diff --git a/internal/imgcache/imgcache.go b/internal/imgcache/imgcache.go index f6b0ef4..605db01 100644 --- a/internal/imgcache/imgcache.go +++ b/internal/imgcache/imgcache.go @@ -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 diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index 5cd66aa..d44e8a2 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -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) } -- 2.49.1