7 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
2e934c8894 fix: QA audit fixes for 1.0/MVP readiness (#25)
All checks were successful
check / check (push) Successful in 5s
closes #24

## QA Audit Fixes

This PR addresses issues found during the 1.0/MVP QA audit.

### Changes

1. **TODO.md: Mark AVIF encoding as done** — AVIF encoding is fully implemented via govips in `processor.go` but was still listed as a TODO item.

2. **scripts/manual-test.sh: Fix form field names** — The manual test script was using wrong field names:
   - Login form: was sending `password=...`, should be `key=...` (matching the HTML form's `name="key"`)
   - Generator form: was sending `source_url`, `fit_mode` — should be `url`, `fit` (matching the handler's `r.FormValue()` calls)
   - This means **the manual test script never actually worked** — login always failed silently because the `key` field was empty.

### Full QA Audit Results

The comprehensive QA audit report has been posted as a comment on [issue #24](#24).

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #25
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-15 17:58:13 +01:00
2f15340f26 Split Dockerfile: pre-built golangci-lint stage for faster CI (#23)
All checks were successful
check / check (push) Successful in 5s
## Summary

Splits the Dockerfile into a dedicated lint stage using the pre-built `golangci/golangci-lint:v2.10.1-alpine` Docker image, replacing the manual binary download with curl/sha256 verification.

## Changes

- **Lint stage** (`AS lint`): Uses `golangci/golangci-lint:v2.10.1-alpine` pinned by sha256. Runs `make fmt-check` + `make lint`. Includes CGO deps (`build-base`, `vips-dev`, `libheif-dev`, `pkgconfig`) needed for type-checking govips imports.
- **Build stage** (`AS builder`): Depends on lint stage via `COPY --from=lint /src/go.sum /dev/null`. Runs `make test` + builds the binary. Removes `curl` (no longer needed) and the manual golangci-lint download block.
- **Runtime stage**: Unchanged.

## Benefits

- Eliminates slow multi-arch binary download + sha256 verification step
- Lint and build stages can potentially run in parallel with BuildKit
- Better Docker layer caching — lint deps cached separately from build deps
- All images remain pinned by sha256 with version+date comments

## Verification

- `docker build .` passes: fmt-check , lint (0 issues) , all tests pass , binary builds 

Closes [#18](#18)

<!-- session: agent:sdlc-manager:subagent:7aac9c54-81c8-4494-94ab-0843f97a1e62 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #23
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:09:51 +01:00
811c210b09 Merge pull request 'fix: Docker build failures on arm64 (closes #15)' (#16) from fix/docker-multiarch-lint into main
All checks were successful
check / check (push) Successful in 6s
Reviewed-on: #16
2026-02-25 20:51:44 +01:00
clawbot
5ca64a37ce fix: detect architecture for golangci-lint download in Docker build
All checks were successful
check / check (push) Successful in 1m34s
The golangci-lint binary was hardcoded as linux-amd64, causing Docker builds
to fail on arm64 hosts. The amd64 ELF binary cannot execute on aarch64,
producing a misleading shell syntax error during make check.

Use uname -m to detect the container architecture at build time and download
the matching binary. Both amd64 and arm64 SHA-256 hashes are pinned.

Closes #15
2026-02-25 06:12:47 -08:00
12 changed files with 462 additions and 174 deletions

View File

@@ -1,7 +1,29 @@
# Lint stage
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
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
# 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
# Install build dependencies for CGO image libraries
@@ -9,15 +31,7 @@ RUN apk add --no-cache \
build-base \
vips-dev \
libheif-dev \
pkgconfig \
curl
# golangci-lint v2.10.1, 2026-02-25
RUN curl -sSfL https://github.com/golangci/golangci-lint/releases/download/v2.10.1/golangci-lint-2.10.1-linux-amd64.tar.gz -o /tmp/golangci-lint.tar.gz && \
echo "dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99 /tmp/golangci-lint.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/golangci-lint.tar.gz -C /tmp && \
mv /tmp/golangci-lint-2.10.1-linux-amd64/golangci-lint /usr/local/bin/ && \
rm -rf /tmp/golangci-lint*
pkgconfig
WORKDIR /src
@@ -28,8 +42,8 @@ RUN GOTOOLCHAIN=auto go mod download
# Copy source code
COPY . .
# Run all checks (fmt-check, lint, test)
RUN make check
# Run tests
RUN make test
# Build with CGO enabled
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad

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)
- [ ] Add AVIF encoding support (currently returns error)
- [x] Add AVIF encoding support (implemented via govips)
### Manual Testing (verify auth/encrypted URLs work)
- [ ] Manual test: visit `/`, see login form

View File

@@ -35,6 +35,41 @@ 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 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.
func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := &Database{
@@ -84,96 +119,33 @@ func (s *Database) connect(ctx context.Context) error {
s.db = db
s.log.Info("database connected")
return s.runMigrations(ctx)
return ApplyMigrations(ctx, s.db, s.log)
}
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 fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
// collectMigrations reads the embedded schema directory and returns
// migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) {
entries, err := schemaFS.ReadDir("schema")
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
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 := 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 {
s.log.Debug("migration already applied", "version", version)
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
return migrations, 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
// ensureMigrationsTable creates the schema_migrations tracking table if
// it does not already exist.
func ensureMigrationsTable(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
@@ -184,27 +156,32 @@ func ApplyMigrations(db *sql.DB) error {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
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 := ensureMigrationsTable(ctx, db); err != nil {
return err
}
migrations, err := collectMigrations()
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 {
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
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
@@ -214,29 +191,46 @@ func ApplyMigrations(db *sql.DB) error {
}
if count > 0 {
if log != nil {
log.Debug("migration already applied", "version", version)
}
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)
// 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)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
if log != nil {
log.Info("applying migration", "version", version)
}
// Record migration as applied
_, err = db.ExecContext(ctx,
_, 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,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
}
if log != nil {
log.Info("migration applied successfully", "version", version)
}
}
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)
}
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)
}

View File

@@ -26,20 +26,45 @@ func initVips() {
// Images larger than this are rejected to prevent DoS via decompression bombs.
const MaxInputDimension = 8192
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
// This matches the default upstream fetcher limit.
const DefaultMaxInputBytes = 50 << 20
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit.
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ImageProcessor implements the Processor interface using libvips via govips.
type ImageProcessor struct{}
type ImageProcessor struct {
maxInputBytes int64
}
// NewImageProcessor creates a new image processor.
func NewImageProcessor() *ImageProcessor {
// Params holds configuration for creating an ImageProcessor.
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
type Params struct {
// MaxInputBytes is the maximum allowed input size in bytes.
// If <= 0, DefaultMaxInputBytes is used.
MaxInputBytes int64
}
// New creates a new image processor with the given parameters.
// A zero-value Params{} uses sensible defaults.
func New(params Params) *ImageProcessor {
initVips()
return &ImageProcessor{}
maxInputBytes := params.MaxInputBytes
if maxInputBytes <= 0 {
maxInputBytes = DefaultMaxInputBytes
}
return &ImageProcessor{
maxInputBytes: maxInputBytes,
}
}
// Process transforms an image according to the request.
@@ -48,12 +73,20 @@ func (p *ImageProcessor) Process(
input io.Reader,
req *ImageRequest,
) (*ProcessResult, error) {
// Read input
data, err := io.ReadAll(input)
// Read input with a size limit to prevent unbounded memory consumption.
// We read at most maxInputBytes+1 so we can detect if the input exceeds
// the limit without consuming additional memory.
limited := io.LimitReader(input, p.maxInputBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
// Decode image
img, err := vips.NewImageFromBuffer(data)
if err != nil {

View File

@@ -71,7 +71,7 @@ func createTestPNG(t *testing.T, width, height int) []byte {
}
func TestImageProcessor_ResizeJPEG(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 800, 600)
@@ -118,7 +118,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
}
func TestImageProcessor_ConvertToPNG(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
@@ -151,7 +151,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
}
func TestImageProcessor_OriginalSize(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 640, 480)
@@ -179,7 +179,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
}
func TestImageProcessor_FitContain(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// 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) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// 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) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// 800x600 image, request width=0 height=300
@@ -266,7 +266,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
}
func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
input := createTestPNG(t, 400, 300)
@@ -298,7 +298,7 @@ func TestImageProcessor_ImplementsInterface(t *testing.T) {
}
func TestImageProcessor_SupportedFormats(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 {
@@ -312,7 +312,7 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
}
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// 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) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// Create an image with oversized height
@@ -361,7 +361,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
}
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// 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) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 200, 150)
@@ -426,7 +426,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
}
func TestImageProcessor_DecodeAVIF(t *testing.T) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
// 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) {
proc := NewImageProcessor()
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 200, 150)

View File

@@ -15,13 +15,14 @@ import (
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
type Service struct {
cache *Cache
fetcher Fetcher
processor Processor
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
allowHTTP bool
cache *Cache
fetcher Fetcher
processor Processor
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
allowHTTP bool
maxResponseSize int64
}
// 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")
}
// Resolve fetcher config for defaults
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
}
// Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher Fetcher
if cfg.Fetcher != nil {
fetcher = cfg.Fetcher
} else {
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
}
fetcher = NewHTTPFetcher(fetcherCfg)
}
@@ -74,14 +77,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
allowHTTP = cfg.FetcherConfig.AllowHTTP
}
maxResponseSize := fetcherCfg.MaxResponseSize
return &Service{
cache: cfg.Cache,
fetcher: fetcher,
processor: NewImageProcessor(),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),
log: log,
allowHTTP: allowHTTP,
cache: cfg.Cache,
fetcher: fetcher,
processor: New(Params{MaxInputBytes: maxResponseSize}),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),
log: log,
allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize,
}, nil
}
@@ -146,6 +152,40 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
return response, nil
}
// loadCachedSource attempts to load source content from cache, returning nil
// if the cached data is unavailable or exceeds maxResponseSize.
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
reader, err := s.cache.GetSourceContent(contentHash)
if err != nil {
s.log.Warn("failed to load cached source, fetching", "error", err)
return nil
}
// Bound the read to maxResponseSize to prevent unbounded memory use
// from unexpectedly large cached files.
limited := io.LimitReader(reader, s.maxResponseSize+1)
data, err := io.ReadAll(limited)
_ = reader.Close()
if err != nil {
s.log.Warn("failed to read cached source, fetching", "error", err)
return nil
}
if int64(len(data)) > s.maxResponseSize {
s.log.Warn("cached source exceeds max response size, discarding",
"hash", contentHash,
"max_bytes", s.maxResponseSize,
)
return nil
}
return data
}
// processFromSourceOrFetch processes an image, using cached source content if available.
func (s *Service) processFromSourceOrFetch(
ctx context.Context,
@@ -162,22 +202,8 @@ func (s *Service) processFromSourceOrFetch(
var fetchBytes int64
if contentHash != "" {
// We have cached source - load it
s.log.Debug("using cached source", "hash", contentHash)
reader, err := s.cache.GetSourceContent(contentHash)
if err != nil {
s.log.Warn("failed to load cached source, fetching", "error", err)
// Fall through to fetch
} else {
sourceData, err = io.ReadAll(reader)
_ = reader.Close()
if err != nil {
s.log.Warn("failed to read cached source, fetching", "error", err)
// Fall through to fetch
}
}
sourceData = s.loadCachedSource(contentHash)
}
// Fetch from upstream if we don't have source data or it's empty

View File

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

View File

@@ -2,6 +2,7 @@ package imgcache
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
@@ -193,7 +194,7 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
}
// 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)
}

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 "password=wrong-key" -c "$COOKIE_JAR")
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=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 "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
curl -sf -X POST "$BASE_URL/" -d "key=$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 "source_url=$TEST_IMAGE_URL" \
-d "url=$TEST_IMAGE_URL" \
-d "width=800" \
-d "height=600" \
-d "format=jpeg" \
-d "quality=85" \
-d "fit_mode=cover" \
-d "fit=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 "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
curl -sf -X POST "$BASE_URL/" -d "key=$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 "source_url=$TEST_IMAGE_URL" \
-d "url=$TEST_IMAGE_URL" \
-d "width=100" \
-d "height=100" \
-d "format=jpeg" \