Files
pixa/internal/magic/magic.go
T
clawbot c465049e67
check / check (push) Waiting to run
Serve and accept JPEG XL as an image format (part of #222)
A JPEG XL source, a bare codestream or one in its container, is
fetched, detected and decoded, and orig of one is JPEG XL. The format
jxl works in plain and encrypted URLs and on the generator page,
served as image/jxl, and auto chooses it first when Accept names
image/jxl.

govips sends libvips a JPEG XL distance with every save, and libvips
then ignores the quality, so q is turned into a distance with libvips'
formula, keeping 100 lossy. govips cannot have libvips strip metadata
from JPEG XL, so it is removed from the image before saving; libvips
8.16 and later still add an EXIF block of their own, with nothing from
the source. The conversion to sRGB moved ahead of the format switch.

Model: opus-5-5
2026-10-08 05:26:00 +00:00

275 lines
7.4 KiB
Go

// Package magic detects image formats from magic bytes and validates
// content against declared MIME types.
package magic
import (
"bytes"
"errors"
"io"
"strings"
)
// Magic byte errors.
var (
ErrUnknownFormat = errors.New("unknown image format")
ErrMagicByteMismatch = errors.New("content does not match declared Content-Type")
ErrNotEnoughData = errors.New("not enough data to detect format")
)
// MIMEType represents a supported MIME type for input images.
type MIMEType string
// Supported input MIME types.
const (
MIMETypeJPEG = MIMEType("image/jpeg")
MIMETypePNG = MIMEType("image/png")
MIMETypeWebP = MIMEType("image/webp")
MIMETypeGIF = MIMEType("image/gif")
MIMETypeAVIF = MIMEType("image/avif")
MIMETypeJXL = MIMEType("image/jxl")
MIMETypeSVG = MIMEType("image/svg+xml")
)
// ImageFormat represents supported output image formats.
// This mirrors the type in imgcache to avoid circular imports.
type ImageFormat string
// Supported image output formats.
const (
FormatOriginal ImageFormat = "orig"
FormatJPEG ImageFormat = "jpeg"
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatJXL ImageFormat = "jxl"
FormatGIF ImageFormat = "gif"
)
// MinMagicBytes is the minimum number of bytes needed to detect format.
const MinMagicBytes = 12
// mimeOctetStream is the fallback MIME type for formats without a
// specific MIME type.
const mimeOctetStream = "application/octet-stream"
// Magic byte signatures for supported formats.
// These are effectively constants but Go doesn't support const slices.
//
//nolint:gochecknoglobals // immutable lookup data
var (
magicJPEG = []byte{0xFF, 0xD8, 0xFF}
magicPNG = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
magicGIF = []byte{0x47, 0x49, 0x46, 0x38} // GIF8 (GIF87a or GIF89a)
magicWebP = []byte{0x52, 0x49, 0x46, 0x46} // RIFF (WebP starts with RIFF....WEBP)
// AVIF uses the ftyp box with brand "avif" or "avis"
// Format: size(4 bytes) + "ftyp" + brand(4 bytes)
magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp"
// JPEG XL is a bare codestream or a codestream in a container
magicJXLCodestream = []byte{0xFF, 0x0A}
magicJXLContainer = []byte{
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
}
)
// WebP identifier appears at offset 8 after RIFF header.
//
//nolint:gochecknoglobals // immutable lookup data
var webpIdent = []byte{0x57, 0x45, 0x42, 0x50} // "WEBP"
// AVIF brand identifiers.
//
//nolint:gochecknoglobals // immutable lookup data
var (
avifBrand = []byte{0x61, 0x76, 0x69, 0x66} // "avif"
avisBrand = []byte{0x61, 0x76, 0x69, 0x73} // "avis" (AVIF sequence)
)
// DetectFormat detects the image format from magic bytes.
// Returns the MIME type and nil error on success.
func DetectFormat(data []byte) (MIMEType, error) {
if len(data) < MinMagicBytes {
return "", ErrNotEnoughData
}
// Check JPEG (FFD8FF)
if bytes.HasPrefix(data, magicJPEG) {
return MIMETypeJPEG, nil
}
// Check PNG (89504E47 0D0A1A0A)
if bytes.HasPrefix(data, magicPNG) {
return MIMETypePNG, nil
}
// Check GIF (GIF87a or GIF89a)
if bytes.HasPrefix(data, magicGIF) {
return MIMETypeGIF, nil
}
// Check WebP (RIFF....WEBP)
if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 {
if bytes.Equal(data[8:12], webpIdent) {
return MIMETypeWebP, nil
}
}
// Check AVIF (....ftypavif or ....ftypavis)
// The ftyp box can start at offset 4 (after size bytes)
if len(data) >= 12 && bytes.Equal(data[4:8], magicFtyp) {
brand := data[8:12]
if bytes.Equal(brand, avifBrand) || bytes.Equal(brand, avisBrand) {
return MIMETypeAVIF, nil
}
}
// Check JPEG XL (FF0A, or the 12-byte container signature)
if bytes.HasPrefix(data, magicJXLCodestream) ||
bytes.HasPrefix(data, magicJXLContainer) {
return MIMETypeJXL, nil
}
// Check SVG - look for XML declaration or SVG tag
if detectSVG(data) {
return MIMETypeSVG, nil
}
return "", ErrUnknownFormat
}
// detectSVG checks if data appears to be SVG content.
func detectSVG(data []byte) bool {
// Skip BOM if present
content := skipBOM(data)
// Convert to string for easier pattern matching
s := strings.ToLower(string(content))
// Skip leading whitespace
s = strings.TrimSpace(s)
// Check for XML declaration or SVG element
return strings.HasPrefix(s, "<?xml") ||
strings.HasPrefix(s, "<svg") ||
strings.HasPrefix(s, "<!doctype svg")
}
// skipBOM removes UTF-8 BOM if present.
func skipBOM(data []byte) []byte {
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
return data[3:]
}
return data
}
// ValidateMagicBytes validates that the content matches the declared MIME type.
func ValidateMagicBytes(data []byte, declaredType string) error {
detected, err := DetectFormat(data)
if err != nil {
return err
}
// Normalize the declared type (remove parameters like charset)
normalizedDeclared := normalizeMIMEType(declaredType)
// Check if they match
if string(detected) != normalizedDeclared {
return ErrMagicByteMismatch
}
return nil
}
// normalizeMIMEType extracts just the media type, removing parameters.
func normalizeMIMEType(mimeType string) string {
// Handle "image/jpeg; charset=utf-8" -> "image/jpeg"
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = mimeType[:idx]
}
return strings.TrimSpace(strings.ToLower(mimeType))
}
// IsSupportedMIMEType checks if a MIME type is supported for input.
func IsSupportedMIMEType(mimeType string) bool {
normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) {
case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF,
MIMETypeJXL, MIMETypeSVG:
return true
default:
return false
}
}
// PeekAndValidate reads the minimum bytes needed for format detection,
// validates against the declared type, and returns a reader that includes
// those bytes for subsequent reading.
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
// Read minimum bytes for detection
buf := make([]byte, MinMagicBytes)
n, err := io.ReadFull(r, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return nil, err
}
buf = buf[:n]
// Validate magic bytes
err = ValidateMagicBytes(buf, declaredType)
if err != nil {
return nil, err
}
// Return a reader that includes the peeked bytes
return io.MultiReader(bytes.NewReader(buf), r), nil
}
// MIMEToImageFormat converts a MIME type to an ImageFormat.
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) {
case MIMETypeJPEG:
return FormatJPEG, true
case MIMETypePNG:
return FormatPNG, true
case MIMETypeWebP:
return FormatWebP, true
case MIMETypeGIF:
return FormatGIF, true
case MIMETypeAVIF:
return FormatAVIF, true
case MIMETypeJXL:
return FormatJXL, true
case MIMETypeSVG:
// SVG has no corresponding output format.
return "", false
default:
return "", false
}
}
// ImageFormatToMIME converts an ImageFormat to a MIME type string.
func ImageFormatToMIME(format ImageFormat) string {
switch format {
case FormatJPEG:
return string(MIMETypeJPEG)
case FormatPNG:
return string(MIMETypePNG)
case FormatWebP:
return string(MIMETypeWebP)
case FormatGIF:
return string(MIMETypeGIF)
case FormatAVIF:
return string(MIMETypeAVIF)
case FormatJXL:
return string(MIMETypeJXL)
case FormatOriginal:
// Original format passes content through unchanged.
return mimeOctetStream
default:
return mimeOctetStream
}
}