mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
02be33902f
* 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
176 lines
6.3 KiB
Go
176 lines
6.3 KiB
Go
//go:build !libvips
|
|
|
|
package attachments
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
|
|
// Stdlib decoders for every format the pure-Go backend supports.
|
|
// image.Decode dispatches via the registered decoders below — we
|
|
// don't call them directly, but the blank imports register them.
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
|
|
"github.com/disintegration/imaging"
|
|
// BMP and TIFF live in golang.org/x/image — pulled in via imaging
|
|
// already, but explicit to make the support matrix self-evident.
|
|
_ "golang.org/x/image/bmp"
|
|
_ "golang.org/x/image/tiff"
|
|
)
|
|
|
|
// pureGoProcessor is the default no-cgo Processor. Constructed via
|
|
// NewProcessor — the package exposes a single constructor so callers
|
|
// don't need to know which backend they got at compile time. The
|
|
// libvips equivalent in `processor_libvips.go` (a future Phase 2 file)
|
|
// will provide the same NewProcessor signature behind the libvips
|
|
// build tag.
|
|
type pureGoProcessor struct {
|
|
caps Capabilities
|
|
}
|
|
|
|
// NewProcessor returns the Processor compiled into this binary. Pure-Go
|
|
// build → pureGoProcessor. libvips build → vipsProcessor (Phase 2).
|
|
//
|
|
// Constructed once at server startup; safe for concurrent use across
|
|
// every upload handler (the underlying imaging package operates on
|
|
// per-call image.Image values with no shared state).
|
|
func NewProcessor() Processor {
|
|
return &pureGoProcessor{
|
|
caps: Capabilities{
|
|
ImageFormats: []string{"png", "jpeg", "gif", "bmp", "tiff"},
|
|
CanTranscode: true,
|
|
MaxPixels: MaxPixelsDefault,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *pureGoProcessor) Capabilities() Capabilities { return p.caps }
|
|
|
|
// Decode peeks at the image header to enforce MaxPixels BEFORE allocating
|
|
// the full pixel buffer. image.DecodeConfig reads only the header bytes
|
|
// (a few hundred at most), so an attacker who uploads a "claimed
|
|
// 100k x 100k pixels" PNG can't OOM us — we reject before image.Decode
|
|
// allocates the row buffers.
|
|
//
|
|
// The header peek requires we read all of `r` once into a buffer so we
|
|
// can replay it for the actual Decode. This costs a single io.ReadAll
|
|
// — bounded by the upload-handler's MaxBytesReader (25 MiB by default).
|
|
func (p *pureGoProcessor) Decode(r io.Reader) (image.Image, string, error) {
|
|
buf, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("attachments: read image bytes: %w", err)
|
|
}
|
|
cfg, format, err := image.DecodeConfig(bytes.NewReader(buf))
|
|
if err != nil {
|
|
// image.Decode would also fail; bail with a clearer error
|
|
// message that distinguishes "format unknown" from "format
|
|
// known, decoding failed mid-way" (a corrupt-bytes case).
|
|
return nil, "", fmt.Errorf("%w: %v", ErrUnsupportedFormat, err)
|
|
}
|
|
if !p.formatSupported(format) {
|
|
return nil, format, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
|
|
}
|
|
if cfg.Width <= 0 || cfg.Height <= 0 {
|
|
return nil, format, fmt.Errorf("%w: zero dimension (%dx%d)",
|
|
ErrUnsupportedFormat, cfg.Width, cfg.Height)
|
|
}
|
|
if int64(cfg.Width)*int64(cfg.Height) > int64(p.caps.MaxPixels) {
|
|
return nil, format, fmt.Errorf("%w: %dx%d exceeds %d",
|
|
ErrImageTooLarge, cfg.Width, cfg.Height, p.caps.MaxPixels)
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(buf))
|
|
if err != nil {
|
|
return nil, format, fmt.Errorf("attachments: decode %s: %w", format, err)
|
|
}
|
|
return img, format, nil
|
|
}
|
|
|
|
// Resize fits the longer edge to maxLong, preserving aspect ratio. The
|
|
// imaging package picks the right scale factor for whichever edge is
|
|
// longer — passing 0 for the other dimension tells it "auto".
|
|
func (p *pureGoProcessor) Resize(img image.Image, maxLong int) (image.Image, error) {
|
|
if maxLong <= 0 {
|
|
return nil, fmt.Errorf("attachments: Resize: maxLong must be positive")
|
|
}
|
|
bounds := img.Bounds()
|
|
w, h := bounds.Dx(), bounds.Dy()
|
|
if w <= maxLong && h <= maxLong {
|
|
// Pass-through avoids the encode/decode cost when the input is
|
|
// already smaller than the target — common for thumb-md (1024px)
|
|
// against typical screenshots.
|
|
return img, nil
|
|
}
|
|
if w >= h {
|
|
return imaging.Resize(img, maxLong, 0, imaging.Lanczos), nil
|
|
}
|
|
return imaging.Resize(img, 0, maxLong, imaging.Lanczos), nil
|
|
}
|
|
|
|
// Rotate accepts only multiples of 90 degrees (clockwise). imaging's
|
|
// Rotate90/180/270 are exact pixel reorderings — no resampling, no
|
|
// quality loss, ideal for the editor's rotation tool (TASK-879).
|
|
func (p *pureGoProcessor) Rotate(img image.Image, deg int) (image.Image, error) {
|
|
// Normalize to [0, 360) so callers can pass -90, 270, etc. and get
|
|
// the same rotation. Using positive modulo (Go's % is sign-preserving)
|
|
// so -90 → 270 instead of -90.
|
|
d := ((deg % 360) + 360) % 360
|
|
switch d {
|
|
case 0:
|
|
return img, nil
|
|
case 90:
|
|
return imaging.Rotate90(img), nil
|
|
case 180:
|
|
return imaging.Rotate180(img), nil
|
|
case 270:
|
|
return imaging.Rotate270(img), nil
|
|
default:
|
|
return nil, fmt.Errorf("attachments: Rotate: only multiples of 90 supported, got %d", deg)
|
|
}
|
|
}
|
|
|
|
// Crop intersects rect with the image bounds and returns the sub-image.
|
|
// An empty intersection is rejected — encoding a 0x0 image produces
|
|
// invalid output that the next decode would fail on.
|
|
func (p *pureGoProcessor) Crop(img image.Image, rect image.Rectangle) (image.Image, error) {
|
|
clipped := rect.Intersect(img.Bounds())
|
|
if clipped.Empty() {
|
|
return nil, fmt.Errorf("attachments: Crop: rect %v is outside image bounds %v",
|
|
rect, img.Bounds())
|
|
}
|
|
return imaging.Crop(img, clipped), nil
|
|
}
|
|
|
|
// Encode writes img to w in the requested format. Only "png" and "jpeg"
|
|
// are supported — these are the two formats the thumbnail pipeline
|
|
// emits, and the only ones every browser renders without question.
|
|
// Other formats fall through to ErrUnsupportedFormat so callers can
|
|
// see the limitation explicitly.
|
|
func (p *pureGoProcessor) Encode(img image.Image, format string, w io.Writer) error {
|
|
switch format {
|
|
case "png":
|
|
return png.Encode(w, img)
|
|
case "jpeg", "jpg":
|
|
// Quality 85 is the standard sweet spot — visibly identical
|
|
// to 100% on screen, ~3x smaller. Higher would balloon
|
|
// thumbnail storage; lower introduces visible blocking.
|
|
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
|
|
default:
|
|
return fmt.Errorf("%w: encode target %q", ErrUnsupportedFormat, format)
|
|
}
|
|
}
|
|
|
|
func (p *pureGoProcessor) formatSupported(format string) bool {
|
|
for _, f := range p.caps.ImageFormats {
|
|
if f == format {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|