Files
pad/internal/attachments/processor.go
T
xarmian 02be33902f feat(attachments): ImageProcessor interface + pure-Go impl + thumbnail pipeline (TASK-878) (#295)
* feat(attachments): ImageProcessor interface + pure-Go impl + thumbnail pipeline (TASK-878)

Adds the abstraction Phase 1 needs to derive thumbnail variants on
upload, with a pure-Go default implementation that keeps Pad's
single-binary distribution intact (no cgo). The libvips-tagged
build (Phase 2 / Pad Cloud Docker) will replace processor_purego.go
with a vips-backed implementation behind the same Processor
interface — see DOC-865.

internal/attachments/processor.go:
  Processor interface — Decode(io.Reader)→(image.Image, format),
  Resize(img, maxLong), Rotate(img, deg), Crop(img, rect),
  Encode(img, format, w), Capabilities().
  Capabilities struct (image_formats, can_transcode, max_pixels)
  surfaces what the editor needs to gate per-format rotate/crop UI
  on (TASK-879/880). ErrUnsupportedFormat + ErrImageTooLarge are
  separate sentinels so callers can distinguish "format not
  supported" from "image dimensions too big".

internal/attachments/processor_purego.go (//go:build !libvips):
  Uses github.com/disintegration/imaging plus the stdlib decoders.
  Supports PNG/JPEG/GIF/BMP/TIFF for all ops. WebP/AVIF/HEIC
  reach Decode and bounce out via ErrUnsupportedFormat — uploads
  still succeed (the MIME allowlist is the upload gate), but
  thumbnails skip and the editor disables rotate/crop UI per
  Capabilities.

  Memory ceiling: Decode peeks via image.DecodeConfig (header only)
  before allocating any pixel buffer and rejects images whose
  width*height exceeds MaxPixelsDefault (8000² = 64MP). At 4 bytes
  per pixel that caps the decode buffer at ~256 MiB and prevents an
  attacker uploading a forged 100kx100k claim from OOMing the
  server. The forged-CRC test exercises this gate.

internal/server/handlers_attachments_thumbnails.go:
  deriveThumbnails(parentID) runs in goAsync after every image
  upload. Generates thumb-sm (256px long edge) + thumb-md (1024px),
  each as its own attachments row with parent_id pointing at the
  original. Server.Stop() drains the goroutine before SQLite
  closes, so tests can assert post-conditions deterministically.

  Skip cases: parent deleted (race), source format not supported
  (logged at debug), source already smaller than the variant's
  bound, variant already exists (idempotent reruns). Variants
  count toward workspace storage usage — DOC-865 is explicit about
  this and TestThumbnails_CountsTowardWorkspaceUsage proves it.

  Output format policy: PNG inputs stay PNG to preserve transparency;
  everything else encodes as JPEG q=85.

internal/server/handlers_capabilities.go:
  GET /api/v1/server/capabilities returns the Processor's static
  capability profile under {image: {...}}. Public route — the
  editor needs it before login (e.g. shared-item preview surfaces).
  Reports an empty image-formats list when no processor is wired,
  signalling the editor to disable rotate/crop UI rather than
  500-ing the editor mount.

cmd/pad/main.go: wires SetImageProcessor(NewProcessor()) alongside
SetAttachments at startup; logs the supported formats so operators
know whether they're on the pure-Go or libvips build.

Tests:
  - processor_test.go: 12 unit tests covering capability profile,
    decode round-trip for PNG/JPEG/GIF, rejection of unsupported
    formats and oversized images (forged-CRC PNG), resize aspect
    preservation + pass-through for already-small inputs, rotate
    multiples-of-90 + negative + 360-modulo handling, crop with
    bounds clipping + empty-intersection rejection, encode round-
    trip for PNG/JPEG, ThumbnailFormat/Mime/Ext policy.
  - handlers_attachments_thumbnails_test.go: 5 integration tests
    covering thumb-sm + thumb-md generation on PNG/JPEG uploads,
    skip-when-source-already-small, ?variant=thumb-md serving via
    the existing GET handler, workspace usage accounting.
  - handlers_capabilities tests cover the happy path + the
    no-processor degraded path.

Parent: PLAN-866. Closes the thumbnail-fallback gap that TASK-874 /
TASK-876 left open (thumb-md URLs were falling back to original
because no thumbnails existed). Unblocks TASK-879 (rotation tool)
and TASK-880 (crop tool) — both will reuse Processor.Rotate /
Processor.Crop with the same Capabilities-driven UI gating.

* fix(attachments): make /server/capabilities public per Codex review (round 1)

Codex flagged that GET /api/v1/server/capabilities was registered
inside the auth-gated API group but missing from isPublicAPIPath,
so once any user existed the editor's pre-login fetch would 401 —
contradicting the route's "public" register-time intent and breaking
the share-preview surface.

Fix: add the path to isPublicAPIPath. The handler is read-only,
returns a static profile, and has no per-user state, so making it
public has no security implication. Added
TestServerCapabilities_PublicAfterBootstrap as a regression guard:
it bootstraps an admin (so RequireAuth is active) and then fetches
the endpoint with no auth cookie, asserting 200.

* fix(attachments): make -tags libvips compile per Codex review (round 2)

Codex flagged that build tag !libvips on processor_purego.go meant
NewProcessor + the Thumbnail* helpers were absent under
\`go build -tags libvips\`, so cmd/pad/main.go and the thumbnail
handler — which call them unconditionally — broke that build.

Two minimal fixes preserving the documented Phase 2 split:

  1. Move ThumbnailFormat / ThumbnailMime / ThumbnailExt out of the
     tagged file and into processor.go (untagged). They're pure
     format-name policy, not implementation specifics, so both
     backends share the same definitions.

  2. Add processor_libvips.go (//go:build libvips) with a stub
     NewProcessor that panics at runtime with a clear
     "Phase 2 hasn't shipped libvips yet" message. The libvips
     build now compiles; anyone actually instantiating the
     processor under that tag gets a loud failure rather than a
     silent degradation. Phase 2 will replace the body with the
     real govips-v2-backed implementation.

Verified: \`go build ./...\` and \`go build -tags libvips ./...\` both
clean. Existing tests still pass on the default tag.

* fix(attachments): make tests compile under -tags libvips per Codex review (round 3)

Codex flagged that running \`go test -tags libvips ./internal/attachments\`
or \`./internal/server\` panicked through the libvips NewProcessor
stub: processor_test.go and the thumbnail/capability server tests
all called NewProcessor() unconditionally, even though the libvips
build's stub is intentionally panicking until Phase 2 ships the
real implementation.

Three minimal fixes:

  1. Tag processor_test.go !libvips. It tests the pure-Go
     implementation specifically — there's no value in running it
     under libvips, and the stub processor would explode the moment
     NewProcessor() ran.

  2. Tag handlers_attachments_thumbnails_test.go !libvips. Same
     reasoning — these integration tests assert thumbnail
     derivation against a working processor.

  3. Split testServerWithAttachments's processor wiring into two
     build-tagged helper files:
       * testimageprocessor_purego_test.go (//go:build !libvips)
         wires the real pure-Go processor.
       * testimageprocessor_libvips_test.go (//go:build libvips)
         is a no-op so the rest of the server test surface
         (uploads, downloads, auth, etc.) compiles + runs cleanly
         under -tags libvips.

Verification:
  go build ./...                              — OK
  go build -tags libvips ./...                — OK
  go test ./internal/attachments ./internal/server (default)        — pass
  go test -tags libvips ./internal/server -run "TestUpload|TestDownload" — pass

Phase 2 will introduce a real libvips test backend and drop the
!libvips tags on the thumbnail tests.

* fix(attachments): libvips binary boots cleanly per Codex review (round 4)

Codex flagged that the libvips build still crashed at \`pad serve\`
startup: cmd/pad/main.go calls attachments.NewProcessor()
unconditionally, and the libvips stub was panicking — so any
operator who built with -tags libvips today (Phase 2 isn't shipped
yet) lost the entire server, not just image processing.

Two minimal changes:

  1. processor_libvips.go: stop panicking. Return nil + slog.Warn
     instead. Every call site already nil-checks the processor (the
     upload handler skips thumbnail derivation, the capabilities
     endpoint reports a degraded empty formats list), so the
     libvips-tagged binary now has the same runtime profile as a
     self-host build that opted out of image processing entirely
     — uploads succeed, originals display, only derived
     transformations are unavailable. The slog.Warn keeps the
     "this build doesn't have it yet" signal loud.

  2. cmd/pad/main.go: skip srv.SetImageProcessor when NewProcessor
     returns nil, and log a "not wired" message in that branch.
     Distinguishes the wired vs. unwired states cleanly in the
     boot log.

Phase 2 will replace processor_libvips.go's body with the real
govips-v2-backed implementation; main.go's wiring is already shape-
correct for that transition.

Verification:
  go build ./...                — OK
  go build -tags libvips ./...  — OK
  go test ./...                 — pass (74s server tests included)
  go test -tags libvips ./internal/server -run "TestUpload|TestDownload|TestServerCapabilities_Public" — pass
2026-04-29 14:35:49 -04:00

166 lines
7.2 KiB
Go

package attachments
import (
"errors"
"image"
"io"
)
// Processor abstracts image decode / transform / encode operations so
// that two implementations can coexist:
//
// - Pure-Go (default, no build tag) — `processor_purego.go`. Uses
// `github.com/disintegration/imaging` and the stdlib decoders. Keeps
// Pad's single-binary distribution intact (no cgo). Handles PNG /
// JPEG / GIF / BMP / TIFF for all ops; WebP / AVIF / HEIC are
// accepted on upload but rejected at the processor level — the
// editor surface uses Capabilities() to gate rotate/crop UI for
// those formats.
//
// - libvips (`-tags libvips`, requires cgo) — Phase 2 / Pad Cloud
// Docker. Faster, lower-memory, and adds native WebP / AVIF / HEIC
// processing. The interface is intentionally simple so the libvips
// backend can wrap a *vips.Image internally and only round-trip
// through image.Image at the API boundary.
//
// Methods are safe for concurrent use; backends may share state (e.g.
// libvips' global concurrency pool) but must not introduce per-call
// races. All methods MUST be self-contained — never assume call order.
type Processor interface {
// Decode reads bytes from r and returns the decoded image plus the
// detected format ("png", "jpeg", "gif", "bmp", "tiff", …). The
// returned format is the canonical lowercase name; callers can pass
// it back to Encode unchanged. If r holds bytes that this backend
// can't decode, the error wraps ErrUnsupportedFormat.
Decode(r io.Reader) (img image.Image, format string, err error)
// Resize returns a new image with the longer edge fitted to maxLong,
// preserving aspect ratio. Images already within maxLong are
// returned unchanged so callers don't pay the encode cost on
// pass-through. maxLong must be positive.
Resize(img image.Image, maxLong int) (image.Image, error)
// Rotate returns a new image rotated by deg degrees clockwise.
// Only multiples of 90 (90, 180, 270, plus their negatives) are
// supported in Phase 1 — that's what the editor's rotation tool
// (TASK-879) emits, and it sidesteps the resampling cost of
// arbitrary-angle rotation.
Rotate(img image.Image, deg int) (image.Image, error)
// Crop returns the rectangular sub-image bounded by `rect`. The
// rectangle is interpreted in the image's coordinate space (origin
// top-left) and intersected with the image bounds — a rect that
// extends past the bounds is silently clipped rather than rejected,
// so the editor's crop tool (TASK-880) can be lazy with rounding.
Crop(img image.Image, rect image.Rectangle) (image.Image, error)
// Encode writes img to w in the given format ("png" / "jpeg").
// JPEG is encoded at quality 85 — small enough for thumbnails,
// high enough that pixel-level lossy artifacts are rare on the
// kinds of content (screenshots, photos, diagrams) Pad sees most.
// Unknown formats wrap ErrUnsupportedFormat.
Encode(img image.Image, format string, w io.Writer) error
// Capabilities reports what this backend can do. The editor reads
// these flags via GET /api/v1/server/capabilities and gates
// rotate/crop UI on per-format support, with an explanatory tooltip
// when disabled. Uploads always succeed (the MIME allowlist is the
// gate); display always works (browsers handle WebP / AVIF / HEIC
// natively). Self-hosters with the pure-Go build see their image
// formats list shrink, never an upload rejection.
Capabilities() Capabilities
}
// Capabilities describes the static capability profile of a Processor.
// It's safe to embed in HTTP responses — nothing here changes between
// requests, so callers can cache for the lifetime of the binary.
type Capabilities struct {
// ImageFormats are the canonical lowercase format names the backend
// can decode AND encode (i.e. fully supports for transformation).
// Pure-Go: ["png", "jpeg", "gif", "bmp", "tiff"]; libvips adds
// "webp", "avif", "heic" on top.
ImageFormats []string `json:"image_formats"`
// CanTranscode reports whether the backend can re-encode between
// formats. Pure-Go is true (it can decode any supported format and
// encode to PNG / JPEG); libvips is also true. Effectively a
// future-proofing flag — false would indicate a degraded build.
CanTranscode bool `json:"can_transcode"`
// MaxPixels is the hard ceiling on input image area (width * height).
// Decode rejects bigger images before allocating decoded pixel
// buffers — the rejection is on raw dimensions read from the file
// header, not on the decoded buffer. 64 megapixels (8000 * 8000)
// is generous enough for high-end DSLRs and low enough that the
// decode buffer (~256 MiB at 4 bytes/pixel) doesn't OOM the server
// under concurrent uploads.
MaxPixels int `json:"max_pixels"`
}
// MaxPixelsDefault is the default value used by the pure-Go backend.
// Public so the Capabilities struct's MaxPixels field has a single
// source of truth and so tests can reference it.
const MaxPixelsDefault = 8000 * 8000
// ErrUnsupportedFormat is wrapped by Decode when the input bytes are
// in a format the backend cannot decode (e.g. pure-Go on WebP), and by
// Encode when the requested output format is not "png" or "jpeg".
//
// Callers handle this by skipping the operation, not by failing the
// surrounding flow — the upload itself succeeded, the user sees the
// original at native resolution, and the editor disables the rotate /
// crop UI for that format.
var ErrUnsupportedFormat = errors.New("attachments: image format not supported by this processor")
// ErrImageTooLarge is wrapped by Decode when the input image's
// pixel area exceeds MaxPixels. Reported separately from
// ErrUnsupportedFormat because the format IS supported — only the
// size isn't — and the upload-side caller may want to surface a
// distinct user-facing message.
var ErrImageTooLarge = errors.New("attachments: image dimensions exceed processor limit")
// Format-policy helpers — shared between the pure-Go and libvips
// backends. These are intentionally NOT inside a build-tagged file
// so they compile under every build tag. Both backends produce PNG
// for PNG inputs (preserves alpha) and JPEG for everything else.
// ThumbnailFormat picks the best output format for a thumbnail
// derived from an input of the given format. PNG inputs stay PNG so
// transparency survives; everything else encodes as JPEG (smaller
// files, good enough for thumbnails). Single source of truth shared
// by the upload pipeline and tests.
func ThumbnailFormat(inputFormat string) string {
if inputFormat == "png" {
return "png"
}
return "jpeg"
}
// ThumbnailMime returns the canonical MIME type for a thumbnail
// encoded in `format` (paired with ThumbnailFormat above).
func ThumbnailMime(format string) string {
switch format {
case "png":
return "image/png"
case "jpeg", "jpg":
return "image/jpeg"
default:
return "application/octet-stream"
}
}
// ThumbnailExt returns the file extension for a thumbnail encoded in
// `format`. Used to build a synthetic filename for the derived row
// (parent's basename + variant + extension) so downloads with
// Content-Disposition expose a sensible filename.
func ThumbnailExt(format string) string {
switch format {
case "png":
return ".png"
case "jpeg", "jpg":
return ".jpg"
default:
return ".bin"
}
}