Bound imageprocessor.Process input read to prevent unbounded memory use (#37)
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>
This commit was merged in pull request #37.
This commit is contained in:
2026-03-20 07:01:15 +01:00
committed by Jeffrey Paul
parent 9c29cb57df
commit 55a609dd77
5 changed files with 334 additions and 155 deletions

View File

@@ -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

View File

@@ -1,285 +0,0 @@
package imgcache
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)
})
}
// 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
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
// 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{}
// NewImageProcessor creates a new image processor.
func NewImageProcessor() *ImageProcessor {
initVips()
return &ImageProcessor{}
}
// Process transforms an image according to the request.
func (p *ImageProcessor) Process(
_ context.Context,
input io.Reader,
req *ImageRequest,
) (*ProcessResult, error) {
// Read input
data, err := io.ReadAll(input)
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
// 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 &ProcessResult{
Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)),
ContentType: ImageFormatToMIME(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{
string(MIMETypeJPEG),
string(MIMETypePNG),
string(MIMETypeGIF),
string(MIMETypeWebP),
string(MIMETypeAVIF),
}
}
// SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
return []ImageFormat{
FormatJPEG,
FormatPNG,
FormatGIF,
FormatWebP,
FormatAVIF,
}
}
// 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
// Calculate target 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) - use ThumbnailWithSize with Force
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 ImageFormat, 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(&params)
if err != nil {
return nil, err
}
return output, nil
}
// formatFromString converts a format string to ImageFormat.
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
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
}
}

View File

@@ -1,509 +0,0 @@
package imgcache
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"os"
"testing"
"github.com/davidbyttow/govips/v2/vips"
)
func TestMain(m *testing.M) {
initVips()
code := m.Run()
vips.Shutdown()
os.Exit(code)
}
// createTestJPEG creates a simple test JPEG image.
func createTestJPEG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Fill with a gradient
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width),
G: uint8(y * 255 / height),
B: 128,
A: 255,
})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
return buf.Bytes()
}
// createTestPNG creates a simple test PNG image.
func createTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width),
G: uint8(y * 255 / height),
B: 128,
A: 255,
})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
func TestImageProcessor_ResizeJPEG(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 800, 600)
req := &ImageRequest{
Size: Size{Width: 400, Height: 300},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
if result.ContentLength == 0 {
t.Error("Process() returned zero content length")
}
// Verify it's valid JPEG by reading the content
data, err := io.ReadAll(result.Content)
if err != nil {
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)
}
}
func TestImageProcessor_ConvertToPNG(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &ImageRequest{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
data, err := io.ReadAll(result.Content)
if err != nil {
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)
}
}
func TestImageProcessor_OriginalSize(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 640, 480)
req := &ImageRequest{
Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 640 {
t.Errorf("Process() width = %d, want 640", result.Width)
}
if result.Height != 480 {
t.Errorf("Process() height = %d, want 480", result.Height)
}
}
func TestImageProcessor_FitContain(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// 800x400 image (2:1 aspect) into 400x400 box with contain
// Should result in 400x200 (maintaining aspect ratio)
input := createTestJPEG(t, 800, 400)
req := &ImageRequest{
Size: Size{Width: 400, Height: 400},
Format: FormatJPEG,
Quality: 85,
FitMode: FitContain,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
// With contain, the image should fit within the box
if result.Width > 400 || result.Height > 400 {
t.Errorf("Process() size %dx%d exceeds 400x400 box", result.Width, result.Height)
}
}
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &ImageRequest{
Size: Size{Width: 400, Height: 0},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
}
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &ImageRequest{
Size: Size{Width: 0, Height: 300},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
}
func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestPNG(t, 400, 300)
req := &ImageRequest{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 200 {
t.Errorf("Process() width = %d, want 200", result.Width)
}
if result.Height != 150 {
t.Errorf("Process() height = %d, want 150", result.Height)
}
}
func TestImageProcessor_ImplementsInterface(t *testing.T) {
// Verify ImageProcessor implements Processor interface
var _ Processor = (*ImageProcessor)(nil)
}
func TestImageProcessor_SupportedFormats(t *testing.T) {
proc := NewImageProcessor()
inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 {
t.Error("SupportedInputFormats() returned empty slice")
}
outputFormats := proc.SupportedOutputFormats()
if len(outputFormats) == 0 {
t.Error("SupportedOutputFormats() returned empty slice")
}
}
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
// This should be rejected before processing to prevent DoS
input := createTestJPEG(t, 10000, 100)
req := &ImageRequest{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
if err != ErrInputTooLarge {
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
}
}
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// Create an image with oversized height
input := createTestJPEG(t, 100, 10000)
req := &ImageRequest{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
if err != ErrInputTooLarge {
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
}
}
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
proc := NewImageProcessor()
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{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() should accept images at MaxInputDimension, got error: %v", err)
}
defer result.Content.Close()
}
func TestImageProcessor_EncodeWebP(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &ImageRequest{
Size: Size{Width: 100, Height: 75},
Format: FormatWebP,
Quality: 80,
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()
// Verify output is valid WebP
data, err := io.ReadAll(result.Content)
if err != nil {
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)
}
// Verify dimensions
if result.Width != 100 {
t.Errorf("Width = %d, want 100", result.Width)
}
if result.Height != 75 {
t.Errorf("Height = %d, want 75", result.Height)
}
}
func TestImageProcessor_DecodeAVIF(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
// Load test AVIF file
input, err := os.ReadFile("testdata/red.avif")
if err != nil {
t.Fatalf("failed to read test AVIF: %v", err)
}
// Request resize and convert to JPEG
req := &ImageRequest{
Size: Size{Width: 2, Height: 2},
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 (AVIF decoding should work)", err)
}
defer result.Content.Close()
// Verify output is valid JPEG
data, err := io.ReadAll(result.Content)
if err != nil {
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)
}
}
func TestImageProcessor_EncodeAVIF(t *testing.T) {
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &ImageRequest{
Size: Size{Width: 100, Height: 75},
Format: FormatAVIF,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v, want nil (AVIF encoding should work)", err)
}
defer result.Content.Close()
// Verify output is valid AVIF
data, err := io.ReadAll(result.Content)
if err != nil {
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)
}
// Verify dimensions
if result.Width != 100 {
t.Errorf("Width = %d, want 100", result.Width)
}
if result.Height != 75 {
t.Errorf("Height = %d, want 75", result.Height)
}
}

View File

@@ -11,17 +11,19 @@ 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
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
allowHTTP bool
cache *Cache
fetcher Fetcher
processor *imageprocessor.ImageProcessor
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
allowHTTP bool
maxResponseSize int64
}
// ServiceConfig holds configuration for the image service.
@@ -50,15 +52,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 +78,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: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),
log: log,
allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize,
}, nil
}
@@ -146,6 +153,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 +203,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
@@ -274,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)
}