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 0000000..ae4624b Binary files /dev/null and b/internal/imageprocessor/testdata/red.avif differ 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) }