1 Commits

Author SHA1 Message Date
clawbot
de38b03508 feat: split Dockerfile into dedicated lint and build stages
All checks were successful
check / check (push) Successful in 5s
- Add dedicated lint stage using pre-built golangci-lint v2.10.1 image
- Move fmt-check and lint from build stage to lint stage for faster feedback
- Remove manual golangci-lint binary download (now handled by lint image)
- Remove curl from build stage dependencies (no longer needed)
- Add COPY --from=lint dependency to force BuildKit to run lint stage
- Build stage now runs only tests and compilation

closes #20
2026-03-02 00:05:52 -08:00
17 changed files with 298 additions and 726 deletions

View File

@@ -1,31 +1,32 @@
# Lint stage
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.10.1, 2026-03-01
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
# Install CGO dependencies needed for static analysis of vips/libheif code
RUN apt-get update && apt-get install -y --no-install-recommends \
libvips-dev \
libheif-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Run formatting check and linter
RUN make fmt-check
RUN make lint
# Build stage
# Build stage — tests and compilation
# golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
ARG VERSION=dev
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
# Install build dependencies for CGO image libraries
RUN apk add --no-cache \
build-base \

View File

@@ -6,7 +6,7 @@ Remaining tasks sorted by priority for a working 1.0 release.
### Image Processing
- [x] Add WebP encoding support (currently returns error)
- [x] Add AVIF encoding support (implemented via govips)
- [ ] Add AVIF encoding support (currently returns error)
### Manual Testing (verify auth/encrypted URLs work)
- [ ] Manual test: visit `/`, see login form

View File

@@ -17,7 +17,10 @@ import (
"sneak.berlin/go/pixa/internal/server"
)
var Version string //nolint:gochecknoglobals // set by ldflags
var (
Appname = "pixad" //nolint:gochecknoglobals // set by ldflags
Version string //nolint:gochecknoglobals // set by ldflags
)
var configPath string //nolint:gochecknoglobals // cobra flag
@@ -37,6 +40,7 @@ func main() {
}
func run(_ *cobra.Command, _ []string) {
globals.Appname = Appname
globals.Version = Version
// Set config path in environment if specified via flag

View File

@@ -9,7 +9,6 @@ import (
"log/slog"
"path/filepath"
"sort"
"strconv"
"strings"
"go.uber.org/fx"
@@ -22,10 +21,6 @@ import (
//go:embed schema/*.sql
var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0
// Params defines dependencies for Database.
type Params struct {
fx.In
@@ -40,46 +35,6 @@ type Database struct {
config *config.Config
}
// ParseMigrationVersion extracts the numeric version prefix from a migration
// filename. Filenames must follow the pattern "<version>.sql" or
// "<version>_<description>.sql", where version is a zero-padded numeric
// string (e.g. "001", "002"). Returns the version as an integer and an
// error if the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
if name == "" {
return 0, fmt.Errorf("invalid migration filename %q: empty name", filename)
}
// Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version.
versionStr := name
if idx := strings.IndexByte(name, '_'); idx >= 0 {
versionStr = name[:idx]
}
if versionStr == "" {
return 0, fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
}
// Validate the version is purely numeric.
for _, ch := range versionStr {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf(
"invalid migration filename %q: version %q contains non-numeric character %q",
filename, versionStr, string(ch),
)
}
}
version, err := strconv.Atoi(versionStr)
if err != nil {
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
}
return version, nil
}
// New creates a new Database instance.
func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := &Database{
@@ -129,87 +84,43 @@ func (s *Database) connect(ctx context.Context) error {
s.db = db
s.log.Info("database connected")
return ApplyMigrations(ctx, s.db, s.log)
return s.runMigrations(ctx)
}
// collectMigrations reads the embedded schema directory and returns
// migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) {
entries, err := schemaFS.ReadDir("schema")
func (s *Database) runMigrations(ctx context.Context) error {
// Create migrations tracking table
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err)
return fmt.Errorf("failed to create migrations table: %w", err)
}
var migrations []string
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err)
}
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
return migrations, nil
}
// bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableExists)
if err != nil {
return fmt.Errorf("failed to check for migrations table: %w", err)
}
if tableExists > 0 {
return nil
}
content, err := schemaFS.ReadFile("schema/000.sql")
if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
}
if log != nil {
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
return nil
}
// ApplyMigrations applies all pending migrations to db. An optional logger
// may be provided for informational output; pass nil for silent operation.
// This is exported so tests can apply the real schema without the full fx
// lifecycle.
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
return err
}
migrations, err := collectMigrations()
if err != nil {
return err
}
// Apply each migration that hasn't been applied yet
for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied.
// Check if already applied
var count int
err := db.QueryRowContext(ctx,
err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
@@ -218,40 +129,34 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
}
if count > 0 {
if log != nil {
log.Debug("migration already applied", "version", version)
}
s.log.Debug("migration already applied", "version", version)
continue
}
// Read and apply migration.
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
// Read and apply migration
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
if log != nil {
log.Info("applying migration", "version", version)
s.log.Info("applying migration", "version", version)
_, err = s.db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
_, execErr := db.ExecContext(ctx, string(content))
if execErr != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
}
// Record migration as applied.
_, recErr := db.ExecContext(ctx,
// Record migration as applied
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
if log != nil {
log.Info("migration applied successfully", "version", version)
}
s.log.Info("migration applied successfully", "version", version)
}
return nil
@@ -261,3 +166,77 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
func (s *Database) DB() *sql.DB {
return s.db
}
// ApplyMigrations applies all migrations to the given database.
// This is useful for testing where you want to use the real schema
// without the full fx lifecycle.
func ApplyMigrations(db *sql.DB) error {
ctx := context.Background()
// Create migrations tracking table
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err)
}
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
// Apply each migration that hasn't been applied yet
for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
continue
}
// Read and apply migration
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
// Record migration as applied
_, err = db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
}
return nil
}

View File

@@ -1,224 +0,0 @@
package database
import (
"context"
"database/sql"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
// openTestDB returns a fresh in-memory SQLite database.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestParseMigrationVersion(t *testing.T) {
tests := []struct {
name string
filename string
want int
wantErr bool
}{
{
name: "version only",
filename: "001.sql",
want: 1,
},
{
name: "version with description",
filename: "001_initial_schema.sql",
want: 1,
},
{
name: "multi-digit version",
filename: "042_add_indexes.sql",
want: 42,
},
{
name: "long version number",
filename: "00001_long_prefix.sql",
want: 1,
},
{
name: "description with multiple underscores",
filename: "003_add_user_auth_tables.sql",
want: 3,
},
{
name: "empty filename",
filename: ".sql",
wantErr: true,
},
{
name: "leading underscore",
filename: "_description.sql",
wantErr: true,
},
{
name: "non-numeric version",
filename: "abc_migration.sql",
wantErr: true,
},
{
name: "mixed alphanumeric version",
filename: "001a_migration.sql",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseMigrationVersion(tt.filename)
if tt.wantErr {
if err == nil {
t.Errorf("ParseMigrationVersion(%q) expected error, got %d", tt.filename, got)
}
return
}
if err != nil {
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
return
}
if got != tt.want {
t.Errorf("ParseMigrationVersion(%q) = %d, want %d", tt.filename, got, tt.want)
}
})
}
}
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil {
t.Fatalf("ApplyMigrations failed: %v", err)
}
// The schema_migrations table must exist and contain at least
// version 0 (the bootstrap) and 1 (the initial schema).
rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
if err != nil {
t.Fatalf("failed to query schema_migrations: %v", err)
}
defer rows.Close()
var versions []int
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
t.Fatalf("failed to scan version: %v", err)
}
versions = append(versions, v)
}
if err := rows.Err(); err != nil {
t.Fatalf("row iteration error: %v", err)
}
if len(versions) < 2 {
t.Fatalf("expected at least 2 migrations recorded, got %d: %v", len(versions), versions)
}
if versions[0] != 0 {
t.Errorf("first recorded migration = %d, want %d", versions[0], 0)
}
if versions[1] != 1 {
t.Errorf("second recorded migration = %d, want %d", versions[1], 1)
}
// Verify that the application tables created by 001.sql exist.
for _, table := range []string{"source_content", "source_metadata", "output_content", "request_cache", "negative_cache", "cache_stats"} {
var count int
err := db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
table,
).Scan(&count)
if err != nil {
t.Fatalf("failed to check for table %s: %v", table, err)
}
if count != 1 {
t.Errorf("table %s does not exist after migrations", table)
}
}
}
func TestApplyMigrations_Idempotent(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
// Running a second time must succeed without errors.
if err := ApplyMigrations(ctx, db, nil); err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
// Verify no duplicate rows in schema_migrations.
var count int
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = 0").Scan(&count)
if err != nil {
t.Fatalf("failed to count version 0 rows: %v", err)
}
if count != 1 {
t.Errorf("expected exactly 1 row for version 0, got %d", count)
}
}
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := bootstrapMigrationsTable(ctx, db, nil); err != nil {
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
}
// schema_migrations table must exist.
var tableCount int
err := db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableCount)
if err != nil {
t.Fatalf("failed to check for table: %v", err)
}
if tableCount != 1 {
t.Fatalf("schema_migrations table not created")
}
// Version 0 must be recorded.
var recorded int
err = db.QueryRow(
"SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
).Scan(&recorded)
if err != nil {
t.Fatalf("failed to check version: %v", err)
}
if recorded != 1 {
t.Errorf("expected version 0 to be recorded, got count %d", recorded)
}
}

View File

@@ -1,9 +0,0 @@
-- Migration 000: Schema migrations tracking table
-- Applied as a bootstrap step before the normal migration loop.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

View File

@@ -5,10 +5,11 @@ import (
"go.uber.org/fx"
)
const appname = "pixad"
// Version is populated from main() via ldflags.
var Version string //nolint:gochecknoglobals // set from main
// Build-time variables populated from main() via ldflags.
var (
Appname string //nolint:gochecknoglobals // set from main
Version string //nolint:gochecknoglobals // set from main
)
// Globals holds application-wide constants.
type Globals struct {
@@ -19,7 +20,7 @@ type Globals struct {
// New creates a new Globals instance from build-time variables.
func New(_ fx.Lifecycle) (*Globals, error) {
return &Globals{
Appname: appname,
Appname: Appname,
Version: Version,
}, nil
}

View File

@@ -82,7 +82,7 @@ func setupTestDB(t *testing.T) *sql.DB {
t.Fatalf("failed to open test db: %v", err)
}
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

View File

@@ -199,6 +199,36 @@ 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,5 +1,4 @@
// Package imageprocessor provides image format conversion and resizing using libvips.
package imageprocessor
package imgcache
import (
"bytes"
@@ -23,133 +22,38 @@ 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
// 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 image transformation using libvips via govips.
type ImageProcessor struct {
maxInputBytes int64
}
// ImageProcessor implements the Processor interface using libvips via govips.
type ImageProcessor struct{}
// 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 {
// NewImageProcessor creates a new image processor.
func NewImageProcessor() *ImageProcessor {
initVips()
maxInputBytes := params.MaxInputBytes
if maxInputBytes <= 0 {
maxInputBytes = DefaultMaxInputBytes
}
return &ImageProcessor{
maxInputBytes: maxInputBytes,
}
return &ImageProcessor{}
}
// Process transforms an image according to the request.
func (p *ImageProcessor) Process(
_ context.Context,
input io.Reader,
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.
limited := io.LimitReader(input, p.maxInputBytes+1)
data, err := io.ReadAll(limited)
req *ImageRequest,
) (*ProcessResult, error) {
// Read input
data, err := io.ReadAll(input)
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 {
@@ -205,10 +109,10 @@ func (p *ImageProcessor) Process(
return nil, fmt.Errorf("failed to encode: %w", err)
}
return &Result{
return &ProcessResult{
Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)),
ContentType: FormatToMIME(outputFormat),
ContentType: ImageFormatToMIME(outputFormat),
Width: img.Width(),
Height: img.Height(),
InputWidth: origWidth,
@@ -220,17 +124,17 @@ func (p *ImageProcessor) Process(
// SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
string(MIMETypeJPEG),
string(MIMETypePNG),
string(MIMETypeGIF),
string(MIMETypeWebP),
string(MIMETypeAVIF),
}
}
// SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []Format {
return []Format{
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
return []ImageFormat{
FormatJPEG,
FormatPNG,
FormatGIF,
@@ -239,24 +143,6 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
}
}
// 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()
@@ -285,6 +171,7 @@ 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)
@@ -295,7 +182,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)
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case FitInside:
@@ -331,7 +218,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 Format, quality int) ([]byte, error) {
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
if quality <= 0 {
quality = defaultQuality
}
@@ -379,8 +266,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int)
return output, nil
}
// formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) Format {
// formatFromString converts a format string to ImageFormat.
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
switch format {
case "jpeg":
return FormatJPEG

View File

@@ -1,4 +1,4 @@
package imageprocessor
package imgcache
import (
"bytes"
@@ -70,36 +70,13 @@ 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{})
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 800, 600)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 400, Height: 300},
Format: FormatJPEG,
Quality: 85,
@@ -130,19 +107,23 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/jpeg" {
t.Errorf("Output format = %v, want image/jpeg", mime)
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{})
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
@@ -159,19 +140,23 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/png" {
t.Errorf("Output format = %v, want image/png", mime)
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{})
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 640, 480)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG,
Quality: 85,
@@ -194,14 +179,14 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
}
func TestImageProcessor_FitContain(t *testing.T) {
proc := New(Params{})
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 := &Request{
req := &ImageRequest{
Size: Size{Width: 400, Height: 400},
Format: FormatJPEG,
Quality: 85,
@@ -221,14 +206,14 @@ func TestImageProcessor_FitContain(t *testing.T) {
}
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
// 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 400, Height: 0},
Format: FormatJPEG,
Quality: 85,
@@ -251,14 +236,14 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
}
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
// 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 0, Height: 300},
Format: FormatJPEG,
Quality: 85,
@@ -281,12 +266,12 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
}
func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
input := createTestPNG(t, 400, 300)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
@@ -307,8 +292,13 @@ 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{})
proc := NewImageProcessor()
inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 {
@@ -322,14 +312,14 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
}
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := New(Params{})
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 := &Request{
req := &ImageRequest{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
@@ -347,13 +337,13 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
}
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
// Create an image with oversized height
input := createTestJPEG(t, 100, 10000)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
@@ -371,13 +361,14 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
}
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
proc := New(Params{})
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 := &Request{
req := &ImageRequest{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
@@ -392,12 +383,12 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
}
func TestImageProcessor_EncodeWebP(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 100, Height: 75},
Format: FormatWebP,
Quality: 80,
@@ -416,9 +407,13 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/webp" {
t.Errorf("Output format = %v, want image/webp", mime)
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
@@ -431,7 +426,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
}
func TestImageProcessor_DecodeAVIF(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
// Load test AVIF file
@@ -441,7 +436,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
}
// Request resize and convert to JPEG
req := &Request{
req := &ImageRequest{
Size: Size{Width: 2, Height: 2},
Format: FormatJPEG,
Quality: 85,
@@ -460,84 +455,23 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/jpeg" {
t.Errorf("Output format = %v, want image/jpeg", mime)
}
}
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 := &Request{
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 := &Request{
Size: Size{Width: 10, Height: 10},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
mime, err := DetectFormat(data)
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)
t.Fatalf("DetectFormat() error = %v", err)
}
// 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)
if mime != MIMETypeJPEG {
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
}
}
func TestImageProcessor_EncodeAVIF(t *testing.T) {
proc := New(Params{})
proc := NewImageProcessor()
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
req := &Request{
req := &ImageRequest{
Size: Size{Width: 100, Height: 75},
Format: FormatAVIF,
Quality: 85,
@@ -556,9 +490,13 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/avif" {
t.Errorf("Output format = %v, want image/avif", mime)
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

View File

@@ -11,19 +11,17 @@ 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 *imageprocessor.ImageProcessor
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
allowHTTP bool
maxResponseSize int64
cache *Cache
fetcher Fetcher
processor Processor
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
allowHTTP bool
}
// ServiceConfig holds configuration for the image service.
@@ -52,17 +50,15 @@ 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)
}
@@ -78,17 +74,14 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
allowHTTP = cfg.FetcherConfig.AllowHTTP
}
maxResponseSize := fetcherCfg.MaxResponseSize
return &Service{
cache: cfg.Cache,
fetcher: fetcher,
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),
log: log,
allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize,
cache: cfg.Cache,
fetcher: fetcher,
processor: NewImageProcessor(),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),
log: log,
allowHTTP: allowHTTP,
}, nil
}
@@ -153,40 +146,6 @@ 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,
@@ -203,8 +162,22 @@ func (s *Service) processFromSourceOrFetch(
var fetchBytes int64
if contentHash != "" {
// We have cached source - load it
s.log.Debug("using cached source", "hash", contentHash)
sourceData = s.loadCachedSource(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
}
}
}
// Fetch from upstream if we don't have source data or it's empty
@@ -301,14 +274,7 @@ func (s *Service) processAndStore(
// Process the image
processStart := time.Now()
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)
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req)
if err != nil {
return nil, fmt.Errorf("image processing failed: %w", err)
}

View File

@@ -16,7 +16,7 @@ func setupStatsTestDB(t *testing.T) *sql.DB {
if err != nil {
t.Fatal(err)
}
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
if err := database.ApplyMigrations(db); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })

View File

@@ -2,7 +2,6 @@ package imgcache
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
@@ -194,7 +193,7 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
}
// Use the real production schema via migrations
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}

View File

@@ -48,7 +48,7 @@ fi
# Test 3: Wrong password shows error
echo "--- Test 3: Login with wrong password ---"
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=wrong-key" -c "$COOKIE_JAR")
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "password=wrong-key" -c "$COOKIE_JAR")
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
pass "Wrong password shows error message"
else
@@ -57,7 +57,7 @@ fi
# Test 4: Correct password redirects to generator
echo "--- Test 4: Login with correct signing key ---"
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
pass "Correct password shows generator page"
@@ -68,12 +68,12 @@ fi
# Test 5: Generate encrypted URL
echo "--- Test 5: Generate encrypted URL ---"
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "url=$TEST_IMAGE_URL" \
-d "source_url=$TEST_IMAGE_URL" \
-d "width=800" \
-d "height=600" \
-d "format=jpeg" \
-d "quality=85" \
-d "fit=cover" \
-d "fit_mode=cover" \
-d "ttl=3600")
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
pass "Encrypted URL generated"
@@ -121,10 +121,10 @@ fi
# Test 9: Generate short-TTL URL and verify expiration
echo "--- Test 9: Expired URL returns 410 ---"
# Login again
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
# Generate URL with 1 second TTL
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "url=$TEST_IMAGE_URL" \
-d "source_url=$TEST_IMAGE_URL" \
-d "width=100" \
-d "height=100" \
-d "format=jpeg" \