3 Commits

Author SHA1 Message Date
user
d36e511032 refactor: use Params struct for imageprocessor constructor
All checks were successful
check / check (push) Successful in 1m36s
Rename NewImageProcessor(maxInputBytes) to New(Params{}) with a Params
struct containing MaxInputBytes. Zero-value Params{} uses sensible
defaults (DefaultMaxInputBytes). All callers updated.

Addresses review feedback on PR #37.
2026-03-17 19:53:44 -07:00
user
18f218e039 bound imageprocessor.Process input read to prevent unbounded memory use
ImageProcessor.Process used io.ReadAll without a size limit, allowing
arbitrarily large inputs to exhaust memory. Add a configurable
maxInputBytes limit (default 50 MiB, matching the fetcher limit) and
reject inputs that exceed it with ErrInputDataTooLarge.

Also bound the cached source content read in the service layer to
prevent unexpectedly large cached files from consuming unbounded memory.

Extracted loadCachedSource helper to reduce nesting complexity.
2026-03-17 19:53:44 -07:00
9c29cb57df feat: parse version prefix from migration filenames (#33)
All checks were successful
check / check (push) Successful in 1m49s
Closes #28

Migration filenames now follow the pattern `<version>_<description>.sql` (e.g. `001_initial_schema.sql`). The version stored in `schema_migrations` is the numeric prefix only, not the full filename stem.

## Changes

- **`ParseMigrationVersion()`** — new exported function that extracts the numeric prefix from migration filenames. Validates that the prefix is purely numeric and rejects malformed filenames (empty prefix, non-numeric characters, leading underscore).
- **Renamed `001.sql` → `001_initial_schema.sql`** — migration files can now have descriptive names while the tracked version remains `001`. This is safe pre-1.0.0 (no installed base).
- **Deduplicated migration logic** — `runMigrations()` and `ApplyMigrations()` now share a single `applyMigrations()` implementation, plus extracted `collectMigrations()` and `ensureMigrationsTable()` helpers.
- **Unit tests** — `TestParseMigrationVersion` covers valid patterns (version-only, with description, multi-digit, multiple underscores) and error cases (empty, leading underscore, non-numeric, mixed alphanumeric). `TestApplyMigrations` and `TestApplyMigrationsIdempotent` verify end-to-end migration application against an in-memory SQLite database.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #33
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-18 03:18:38 +01:00
13 changed files with 491 additions and 204 deletions

View File

@@ -96,10 +96,11 @@ expiration 1704067200:
4. URL: 4. URL:
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200` `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
**Whitelist entries** are exact host matches only (e.g. **Whitelist patterns:**
`cdn.example.com`). Suffix/wildcard matching is not supported —
signatures are per-URL, so each allowed host must be listed - **Exact match**: `cdn.example.com` — matches only that host
explicitly. - **Suffix match**: `.example.com` — matches `cdn.example.com`,
`images.example.com`, and `example.com`
### Configuration ### Configuration

View File

@@ -13,7 +13,8 @@ state_dir: ./data
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32" signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
# Hosts that don't require signatures (exact match only) # Hosts that don't require signatures
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
whitelist_hosts: whitelist_hosts:
- s3.sneak.cloud - s3.sneak.cloud
- static.sneak.cloud - static.sneak.cloud

View File

@@ -35,6 +35,41 @@ type Database struct {
config *config.Config 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 string and an error if
// the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (string, error) {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
if name == "" {
return "", 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.
version := name
if idx := strings.IndexByte(name, '_'); idx >= 0 {
version = name[:idx]
}
if version == "" {
return "", fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
}
// Validate the version is purely numeric.
for _, ch := range version {
if ch < '0' || ch > '9' {
return "", fmt.Errorf(
"invalid migration filename %q: version %q contains non-numeric character %q",
filename, version, string(ch),
)
}
}
return version, nil
}
// New creates a new Database instance. // New creates a new Database instance.
func New(lc fx.Lifecycle, params Params) (*Database, error) { func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := &Database{ s := &Database{
@@ -84,96 +119,33 @@ func (s *Database) connect(ctx context.Context) error {
s.db = db s.db = db
s.log.Info("database connected") s.log.Info("database connected")
return s.runMigrations(ctx) return ApplyMigrations(ctx, s.db, s.log)
} }
func (s *Database) runMigrations(ctx context.Context) error { // collectMigrations reads the embedded schema directory and returns
// Create migrations tracking table // migration filenames sorted lexicographically.
_, err := s.db.ExecContext(ctx, ` func collectMigrations() ([]string, error) {
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") entries, err := schemaFS.ReadDir("schema")
if err != nil { if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err) return nil, fmt.Errorf("failed to read schema directory: %w", err)
} }
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string var migrations []string
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name()) migrations = append(migrations, entry.Name())
} }
} }
sort.Strings(migrations) sort.Strings(migrations)
// Apply each migration that hasn't been applied yet return migrations, nil
for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied
var count int
err := s.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 { // ensureMigrationsTable creates the schema_migrations tracking table if
s.log.Debug("migration already applied", "version", version) // it does not already exist.
func ensureMigrationsTable(ctx context.Context, db *sql.DB) error {
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)
}
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)
}
// Record migration as applied
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
s.log.Info("migration applied successfully", "version", version)
}
return nil
}
// DB returns the underlying sql.DB.
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, ` _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY, version TEXT PRIMARY KEY,
@@ -184,27 +156,32 @@ func ApplyMigrations(db *sql.DB) error {
return fmt.Errorf("failed to create migrations table: %w", err) return fmt.Errorf("failed to create migrations table: %w", err)
} }
// Get list of migration files return nil
entries, err := schemaFS.ReadDir("schema") }
// 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 := ensureMigrationsTable(ctx, db); err != nil {
return err
}
migrations, err := collectMigrations()
if err != nil { if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err) return 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 { for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration)) version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
// Check if already applied // Check if already applied.
var count int var count int
err := db.QueryRowContext(ctx, err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", "SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version, version,
@@ -214,29 +191,46 @@ func ApplyMigrations(db *sql.DB) error {
} }
if count > 0 { if count > 0 {
if log != nil {
log.Debug("migration already applied", "version", version)
}
continue continue
} }
// Read and apply migration // Read and apply migration.
content, err := schemaFS.ReadFile(filepath.Join("schema", migration)) content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil { if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err) return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
} }
_, err = db.ExecContext(ctx, string(content)) if log != nil {
if err != nil { log.Info("applying migration", "version", version)
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
} }
// Record migration as applied _, execErr := db.ExecContext(ctx, string(content))
_, err = db.ExecContext(ctx, if execErr != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
}
// Record migration as applied.
_, recErr := db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)", "INSERT INTO schema_migrations (version) VALUES (?)",
version, version,
) )
if err != nil { if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err) return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
}
if log != nil {
log.Info("migration applied successfully", "version", version)
} }
} }
return nil return nil
} }
// DB returns the underlying sql.DB.
func (s *Database) DB() *sql.DB {
return s.db
}

View File

@@ -0,0 +1,155 @@
package database
import (
"context"
"database/sql"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
func TestParseMigrationVersion(t *testing.T) {
tests := []struct {
name string
filename string
want string
wantErr bool
}{
{
name: "version only",
filename: "001.sql",
want: "001",
},
{
name: "version with description",
filename: "001_initial_schema.sql",
want: "001",
},
{
name: "multi-digit version",
filename: "042_add_indexes.sql",
want: "042",
},
{
name: "long version number",
filename: "00001_long_prefix.sql",
want: "00001",
},
{
name: "description with multiple underscores",
filename: "003_add_user_auth_tables.sql",
want: "003",
},
{
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 %q", 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) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}
func TestApplyMigrations(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open in-memory database: %v", err)
}
defer db.Close()
// Apply migrations should succeed.
if err := ApplyMigrations(context.Background(), db, nil); err != nil {
t.Fatalf("ApplyMigrations failed: %v", err)
}
// Verify the schema_migrations table recorded the version.
var version string
err = db.QueryRowContext(context.Background(),
"SELECT version FROM schema_migrations LIMIT 1",
).Scan(&version)
if err != nil {
t.Fatalf("failed to query schema_migrations: %v", err)
}
if version != "001" {
t.Errorf("expected version %q, got %q", "001", version)
}
// Verify a table from the migration exists (source_content).
var tableName string
err = db.QueryRowContext(context.Background(),
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_content'",
).Scan(&tableName)
if err != nil {
t.Fatalf("expected source_content table to exist: %v", err)
}
}
func TestApplyMigrationsIdempotent(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open in-memory database: %v", err)
}
defer db.Close()
// Apply twice should succeed (idempotent).
if err := ApplyMigrations(context.Background(), db, nil); err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
if err := ApplyMigrations(context.Background(), db, nil); err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
// Should still have exactly one migration recorded.
var count int
err = db.QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&count)
if err != nil {
t.Fatalf("failed to count schema_migrations: %v", err)
}
if count != 1 {
t.Errorf("expected 1 migration record, got %d", count)
}
}

View File

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

View File

@@ -26,20 +26,45 @@ func initVips() {
// Images larger than this are rejected to prevent DoS via decompression bombs. // Images larger than this are rejected to prevent DoS via decompression bombs.
const MaxInputDimension = 8192 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. // ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") 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. // ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format") var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ImageProcessor implements the Processor interface using libvips via govips. // ImageProcessor implements the Processor interface using libvips via govips.
type ImageProcessor struct{} type ImageProcessor struct {
maxInputBytes int64
}
// NewImageProcessor creates a new image processor. // Params holds configuration for creating an ImageProcessor.
func NewImageProcessor() *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() initVips()
return &ImageProcessor{} maxInputBytes := params.MaxInputBytes
if maxInputBytes <= 0 {
maxInputBytes = DefaultMaxInputBytes
}
return &ImageProcessor{
maxInputBytes: maxInputBytes,
}
} }
// Process transforms an image according to the request. // Process transforms an image according to the request.
@@ -48,12 +73,20 @@ func (p *ImageProcessor) Process(
input io.Reader, input io.Reader,
req *ImageRequest, req *ImageRequest,
) (*ProcessResult, error) { ) (*ProcessResult, error) {
// Read input // Read input with a size limit to prevent unbounded memory consumption.
data, err := io.ReadAll(input) // 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 { if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err) return nil, fmt.Errorf("failed to read input: %w", err)
} }
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
// Decode image // Decode image
img, err := vips.NewImageFromBuffer(data) img, err := vips.NewImageFromBuffer(data)
if err != nil { if err != nil {

View File

@@ -71,7 +71,7 @@ func createTestPNG(t *testing.T, width, height int) []byte {
} }
func TestImageProcessor_ResizeJPEG(t *testing.T) { func TestImageProcessor_ResizeJPEG(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
@@ -118,7 +118,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
} }
func TestImageProcessor_ConvertToPNG(t *testing.T) { func TestImageProcessor_ConvertToPNG(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
@@ -151,7 +151,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
} }
func TestImageProcessor_OriginalSize(t *testing.T) { func TestImageProcessor_OriginalSize(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 640, 480) input := createTestJPEG(t, 640, 480)
@@ -179,7 +179,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
} }
func TestImageProcessor_FitContain(t *testing.T) { func TestImageProcessor_FitContain(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// 800x400 image (2:1 aspect) into 400x400 box with contain // 800x400 image (2:1 aspect) into 400x400 box with contain
@@ -206,7 +206,7 @@ func TestImageProcessor_FitContain(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// 800x600 image, request width=400 height=0 // 800x600 image, request width=400 height=0
@@ -236,7 +236,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// 800x600 image, request width=0 height=300 // 800x600 image, request width=0 height=300
@@ -266,7 +266,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
} }
func TestImageProcessor_ProcessPNG(t *testing.T) { func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestPNG(t, 400, 300) input := createTestPNG(t, 400, 300)
@@ -298,7 +298,7 @@ func TestImageProcessor_ImplementsInterface(t *testing.T) {
} }
func TestImageProcessor_SupportedFormats(t *testing.T) { func TestImageProcessor_SupportedFormats(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
inputFormats := proc.SupportedInputFormats() inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 { if len(inputFormats) == 0 {
@@ -312,7 +312,7 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInput(t *testing.T) { func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Create an image that exceeds MaxInputDimension (e.g., 10000x100) // Create an image that exceeds MaxInputDimension (e.g., 10000x100)
@@ -337,7 +337,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Create an image with oversized height // Create an image with oversized height
@@ -361,7 +361,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
} }
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Create an image at exactly MaxInputDimension - should be accepted // Create an image at exactly MaxInputDimension - should be accepted
@@ -383,7 +383,7 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
} }
func TestImageProcessor_EncodeWebP(t *testing.T) { func TestImageProcessor_EncodeWebP(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
@@ -426,7 +426,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
} }
func TestImageProcessor_DecodeAVIF(t *testing.T) { func TestImageProcessor_DecodeAVIF(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Load test AVIF file // Load test AVIF file
@@ -465,8 +465,73 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
} }
} }
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
// Create a processor with a very small byte limit
const limit = 1024
proc := 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) { func TestImageProcessor_EncodeAVIF(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)

View File

@@ -22,6 +22,7 @@ type Service struct {
whitelist *HostWhitelist whitelist *HostWhitelist
log *slog.Logger log *slog.Logger
allowHTTP bool allowHTTP bool
maxResponseSize int64
} }
// ServiceConfig holds configuration for the image service. // ServiceConfig holds configuration for the image service.
@@ -50,15 +51,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return nil, errors.New("signing key is required") 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 // Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher Fetcher var fetcher Fetcher
if cfg.Fetcher != nil { if cfg.Fetcher != nil {
fetcher = cfg.Fetcher fetcher = cfg.Fetcher
} else { } else {
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
}
fetcher = NewHTTPFetcher(fetcherCfg) fetcher = NewHTTPFetcher(fetcherCfg)
} }
@@ -74,14 +77,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
allowHTTP = cfg.FetcherConfig.AllowHTTP allowHTTP = cfg.FetcherConfig.AllowHTTP
} }
maxResponseSize := fetcherCfg.MaxResponseSize
return &Service{ return &Service{
cache: cfg.Cache, cache: cfg.Cache,
fetcher: fetcher, fetcher: fetcher,
processor: NewImageProcessor(), processor: New(Params{MaxInputBytes: maxResponseSize}),
signer: signer, signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist), whitelist: NewHostWhitelist(cfg.Whitelist),
log: log, log: log,
allowHTTP: allowHTTP, allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize,
}, nil }, nil
} }
@@ -146,6 +152,40 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
return response, nil 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. // processFromSourceOrFetch processes an image, using cached source content if available.
func (s *Service) processFromSourceOrFetch( func (s *Service) processFromSourceOrFetch(
ctx context.Context, ctx context.Context,
@@ -162,22 +202,8 @@ func (s *Service) processFromSourceOrFetch(
var fetchBytes int64 var fetchBytes int64
if contentHash != "" { if contentHash != "" {
// We have cached source - load it
s.log.Debug("using cached source", "hash", contentHash) 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 // Fetch from upstream if we don't have source data or it's empty

View File

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

View File

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

View File

@@ -5,20 +5,23 @@ import (
"strings" "strings"
) )
// HostWhitelist checks whether a source host is allowed without a signature. // HostWhitelist implements the Whitelist interface for checking allowed source hosts.
// Only exact host matches are supported. Signatures are per-URL, so
// wildcard/suffix matching is intentionally not provided.
type HostWhitelist struct { type HostWhitelist struct {
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
exactHosts map[string]struct{} exactHosts map[string]struct{}
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
suffixHosts []string
} }
// NewHostWhitelist creates a whitelist from a list of hostnames. // NewHostWhitelist creates a whitelist from a list of host patterns.
// Each entry is treated as an exact host match. Leading dots are // Patterns starting with "." are treated as suffix matches.
// stripped so that legacy ".example.com" entries become "example.com". // Examples:
// - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
func NewHostWhitelist(patterns []string) *HostWhitelist { func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{ w := &HostWhitelist{
exactHosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0),
} }
for _, pattern := range patterns { for _, pattern := range patterns {
@@ -27,11 +30,9 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue continue
} }
// Strip leading dot — suffix matching is no longer supported; if strings.HasPrefix(pattern, ".") {
// ".example.com" is normalised to "example.com" as an exact entry. w.suffixHosts = append(w.suffixHosts, pattern)
pattern = strings.TrimPrefix(pattern, ".") } else {
if pattern != "" {
w.exactHosts[pattern] = struct{}{} w.exactHosts[pattern] = struct{}{}
} }
} }
@@ -39,7 +40,7 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
return w return w
} }
// IsWhitelisted checks if a URL's host is in the whitelist (exact match only). // IsWhitelisted checks if a URL's host is in the whitelist.
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
@@ -50,17 +51,32 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false return false
} }
_, ok := w.exactHosts[host] // Check exact match
if _, ok := w.exactHosts[host]; ok {
return true
}
return ok // Check suffix match
for _, suffix := range w.suffixHosts {
if strings.HasSuffix(host, suffix) {
return true
}
// Also match if host equals the suffix without the leading dot
// e.g., pattern ".example.com" should match "example.com"
if host == strings.TrimPrefix(suffix, ".") {
return true
}
}
return false
} }
// IsEmpty returns true if the whitelist has no entries. // IsEmpty returns true if the whitelist has no entries.
func (w *HostWhitelist) IsEmpty() bool { func (w *HostWhitelist) IsEmpty() bool {
return len(w.exactHosts) == 0 return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
} }
// Count returns the total number of whitelist entries. // Count returns the total number of whitelist entries.
func (w *HostWhitelist) Count() int { func (w *HostWhitelist) Count() int {
return len(w.exactHosts) return len(w.exactHosts) + len(w.suffixHosts)
} }

View File

@@ -31,41 +31,41 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
want: false, want: false,
}, },
{ {
name: "no suffix matching for subdomains", name: "suffix match",
patterns: []string{"example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: true,
}, },
{ {
name: "leading dot stripped to exact match", name: "suffix match deep subdomain",
patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg",
want: true,
},
{
name: "suffix match apex domain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg", testURL: "https://example.com/image.jpg",
want: true, want: true,
}, },
{ {
name: "leading dot does not enable suffix matching", name: "suffix match not found",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://notexample.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "leading dot does not match deep subdomain", name: "suffix match partial not allowed",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg", testURL: "https://fakeexample.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "multiple patterns exact only", name: "multiple patterns",
patterns: []string{"cdn.example.com", "photos.images.org", "static.test.net"}, patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg", testURL: "https://photos.images.org/image.jpg",
want: true, want: true,
}, },
{
name: "multiple patterns no suffix leak",
patterns: []string{"cdn.example.com", "images.org"},
testURL: "https://photos.images.org/image.jpg",
want: false,
},
{ {
name: "empty whitelist", name: "empty whitelist",
patterns: []string{}, patterns: []string{},
@@ -86,7 +86,7 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
}, },
{ {
name: "whitespace in patterns", name: "whitespace in patterns",
patterns: []string{" cdn.example.com ", " other.com "}, patterns: []string{" cdn.example.com ", " .other.com "},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: true, want: true,
}, },
@@ -139,11 +139,6 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
patterns: []string{"example.com"}, patterns: []string{"example.com"},
want: false, want: false,
}, },
{
name: "leading dot normalised to entry",
patterns: []string{".example.com"},
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -173,14 +168,14 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3, want: 3,
}, },
{ {
name: "leading dots normalised to exact", name: "suffix hosts only",
patterns: []string{".a.com", ".b.com"}, patterns: []string{".a.com", ".b.com"},
want: 2, want: 2,
}, },
{ {
name: "mixed deduplication", name: "mixed",
patterns: []string{"example.com", ".example.com"}, patterns: []string{"exact.com", ".suffix.com"},
want: 1, want: 2,
}, },
} }