refactor: extract imageprocessor into its own package
All checks were successful
check / check (push) Successful in 58s
All checks were successful
check / check (push) Successful in 58s
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,318 +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
|
||||
|
||||
// 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 {
|
||||
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 *ImageRequest,
|
||||
) (*ProcessResult, error) {
|
||||
// Read input with a size limit to prevent unbounded memory consumption.
|
||||
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
||||
// the limit without consuming additional memory.
|
||||
limited := io.LimitReader(input, p.maxInputBytes+1)
|
||||
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
if int64(len(data)) > p.maxInputBytes {
|
||||
return nil, ErrInputDataTooLarge
|
||||
}
|
||||
|
||||
// Decode image
|
||||
img, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
defer img.Close()
|
||||
|
||||
// Get original dimensions
|
||||
origWidth := img.Width()
|
||||
origHeight := img.Height()
|
||||
|
||||
// Detect input format
|
||||
inputFormat := p.detectFormat(img)
|
||||
|
||||
// Validate input dimensions to prevent DoS via decompression bombs
|
||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||
return nil, ErrInputTooLarge
|
||||
}
|
||||
|
||||
// Determine target dimensions
|
||||
targetWidth := req.Size.Width
|
||||
targetHeight := req.Size.Height
|
||||
|
||||
// Handle dimension calculation
|
||||
if targetWidth == 0 && targetHeight == 0 {
|
||||
// Both are 0: keep original size
|
||||
targetWidth = origWidth
|
||||
targetHeight = origHeight
|
||||
} else if targetWidth == 0 {
|
||||
// Only height specified: calculate width proportionally
|
||||
targetWidth = origWidth * targetHeight / origHeight
|
||||
} else if targetHeight == 0 {
|
||||
// Only width specified: calculate height proportionally
|
||||
targetHeight = origHeight * targetWidth / origWidth
|
||||
}
|
||||
|
||||
// Resize if needed
|
||||
if targetWidth != origWidth || targetHeight != origHeight {
|
||||
if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil {
|
||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine output format
|
||||
outputFormat := req.Format
|
||||
if outputFormat == FormatOriginal || outputFormat == "" {
|
||||
outputFormat = p.formatFromString(inputFormat)
|
||||
}
|
||||
|
||||
// Encode to target format
|
||||
output, err := p.encode(img, outputFormat, req.Quality)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||
}
|
||||
|
||||
return &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(¶ms)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,574 +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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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 := New(Params{})
|
||||
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_RejectsOversizedInputData(t *testing.T) {
|
||||
// Create a processor with a very small byte limit
|
||||
const limit = 1024
|
||||
proc := New(Params{MaxInputBytes: 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 := New(Params{MaxInputBytes: 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 := New(Params{})
|
||||
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||
}
|
||||
|
||||
// Passing negative should also use the default
|
||||
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 := New(Params{})
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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