Files
pad/internal/attachments/processor.go
T
xarmian 6a5eb3dee0 fix(attachments): HEIC, HEIF and AVIF are recognised from their bytes, not refused (BUG-2961) (#1291)
fix(attachments): HEIC, HEIF and AVIF are recognised from their bytes, not refused (BUG-2961)

image/heic, image/heif and image/avif have been on the upload allowlist all
along and every real file of those types was rejected 415 mime_not_allowed:
http.DetectContentType implements the WHATWG mimesniff table, which has no
signature for ISO base media file format still images, so they sniffed as
application/octet-stream and ValidateUpload's first rule refused them before
the extension cross-check was ever consulted. HEIC is the iPhone camera
default, so that was every photo shared straight from an Apple device, through
the web UI today and the mobile share sheet being built.

The fix stays inside rule 1 rather than around it. sniffISOBMFFImage reads the
ftyp box and returns the allowlisted MIME, so the sniffed type is still what
the allowlist is consulted with; it returns "" for everything else, so it can
only add detections. The alternative was to trust the filename for these three,
the way ValidateUpload already trusts it for zip-based Office documents, and
that was refused in triage because a renamed .heic would then pass on a name
alone. Both tests layers pin that: an executable named .heic is still refused,
and real HEIC bytes named .jpg are still stored as image/heic.

Four decisions, each argued at its site:

  - Every brand in the box, not just the major one. The measured Apple HEIC
    carries major heic with compatible mif1 MiPr miaf MiHB heic; the measured
    Apple AVIF carries major avif with compatible MiPr avif miaf mif1; libheif
    writes major mif1. A check on bytes 8..12 alone is right for one encoder
    and wrong for the next. A specific brand wins wherever it appears; mif1
    alone means image/heif.
  - Sequence brands (hevc/hevx/hevm/hevs, avis, msf1) are deliberately absent:
    they name image/*-sequence types, none of which is on the allowlist, so
    recognising them would produce a sniff ValidateUpload then refuses with a
    stranger message.
  - A container carrying an mp4 brand stays a video and is left to the stdlib.
  - A size-1 (64-bit largesize) ftyp box is declined, because the brands shift
    eight bytes and the fixed offsets would be reading the size field.

Fixtures are real encoder output with provenance recorded in testdata/README.md
(sips on macOS 26 for the Apple pair, libheif 1.20.2 here for the other two);
hand-built byte vectors appear only for shapes no available encoder produces.
Verified by negative control — with the pre-check disabled all four new
acceptance tests fail — and by a nine-mutant matrix in which the major-only
scan, the dropped mp4 yield, generic-over-specific preference, the minor
version read as a brand, the missing box bound, the removed largesize bail, a
dropped brand table entry and an unconsulted sniffer are each killed by a named
test. The one survivor is equivalent: an 8..11-byte buffer yields no whole
brand either way.

Codex found two more refusals this owed, both now guarded and mutation-checked:
a declared box size of 2..15 is impossible for an ftyp box, and a buffer that
stops inside the 16-byte header is a truncated file — in either case the bytes
at offset 8 are not brands of a box that exists, and classifying from them let
a caller-controlled payload be stored and served as an image.

Also corrects processor.go's claim that "display always works (browsers handle
WebP / AVIF / HEIC natively)", which the client's own viewer table contradicts
and which this change would have made load-bearing (CONVE-23). What it means
for rendering on builds that cannot derive a thumbnail is filed as BUG-2964;
the wider class of allowlist entries the sniffer cannot produce is BUG-2963.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 13:30:42 -04:00

175 lines
7.8 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). Self-hosters with the pure-Go build see their image formats
// list shrink, never an upload rejection.
//
// This comment used to end "display always works (browsers handle
// WebP / AVIF / HEIC natively)". It doesn't: the client's own table
// (web/src/lib/attachments/display.ts) excludes image/heic from the
// viewer precisely because a browser may not decode it, and where no
// derived variant exists the read path serves the original. The
// sentence was harmless while http.DetectContentType made those three
// types unreachable at the door (BUG-2961); it stopped being harmless
// when the ISO-BMFF sniff made them uploadable, so it is corrected
// here rather than left as a claim the fix would have made load-bearing.
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"
}
}