check / check (push) Canceled after 0s
The fetcher accepts image/svg+xml again and internal/magic detects SVG. A raster format renders the SVG at the least size that covers the output, held to 8192 on a side, through libvips' thumbnail, which renders at any scale, so an enlarged SVG stays sharp. An SVG's own size is not limited; one with a side more than 8192 times the other is ErrSVGTooThin, answered 415. orig of an SVG at the orig size is the SVG unchanged, cached as a variant; at any other size it is ErrSVGNotOriginalSize, answered 400. The cache reads up to 1024 first bytes of a file to find its format. Render time and memory grow with what the SVG draws: P1 in TODO.md. Tests that pinned SVG as refused now expect it to be read. Model: opus-5-5
159 lines
4.8 KiB
Go
159 lines
4.8 KiB
Go
// Package magic detects image formats from magic bytes and validates
|
|
// content against declared MIME types.
|
|
package magic
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
)
|
|
|
|
// 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")
|
|
)
|
|
|
|
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
|
const MinMagicBytes = 12
|
|
|
|
// MaxMagicBytes is the most bytes DetectFormat looks at. An SVG's root
|
|
// element must start within them, as libvips requires.
|
|
const MaxMagicBytes = 1024
|
|
|
|
// 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,
|
|
}
|
|
)
|
|
|
|
// utf8BOM is the UTF-8 byte order mark, which a text file such as an SVG may
|
|
// start with.
|
|
//
|
|
//nolint:gochecknoglobals // immutable lookup data
|
|
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
|
|
|
// 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. Data in none of
|
|
// the formats pixa reads is ErrUnknownFormat.
|
|
func DetectFormat(data []byte) (imagerequest.Format, error) {
|
|
if len(data) < MinMagicBytes {
|
|
return "", ErrNotEnoughData
|
|
}
|
|
|
|
// Check JPEG (FFD8FF)
|
|
if bytes.HasPrefix(data, magicJPEG) {
|
|
return imagerequest.FormatJPEG, nil
|
|
}
|
|
|
|
// Check PNG (89504E47 0D0A1A0A)
|
|
if bytes.HasPrefix(data, magicPNG) {
|
|
return imagerequest.FormatPNG, nil
|
|
}
|
|
|
|
// Check GIF (GIF87a or GIF89a)
|
|
if bytes.HasPrefix(data, magicGIF) {
|
|
return imagerequest.FormatGIF, nil
|
|
}
|
|
|
|
// Check WebP (RIFF....WEBP)
|
|
if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 {
|
|
if bytes.Equal(data[8:12], webpIdent) {
|
|
return imagerequest.FormatWebP, 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 imagerequest.FormatAVIF, nil
|
|
}
|
|
}
|
|
|
|
// Check JPEG XL (FF0A, or the 12-byte container signature)
|
|
if bytes.HasPrefix(data, magicJXLCodestream) ||
|
|
bytes.HasPrefix(data, magicJXLContainer) {
|
|
return imagerequest.FormatJXL, nil
|
|
}
|
|
|
|
if isSVG(data) {
|
|
return imagerequest.FormatSVG, nil
|
|
}
|
|
|
|
return "", ErrUnknownFormat
|
|
}
|
|
|
|
// isSVG reports whether data starts as an SVG does: with a "<", after an
|
|
// optional UTF-8 byte order mark and white space, and with "<svg" within the
|
|
// first MaxMagicBytes bytes. Before the root element an SVG may have an XML
|
|
// declaration, comments and a DOCTYPE.
|
|
func isSVG(data []byte) bool {
|
|
data = data[:min(len(data), MaxMagicBytes)]
|
|
text := bytes.TrimLeft(bytes.TrimPrefix(data, utf8BOM), " \t\r\n")
|
|
|
|
return bytes.HasPrefix(text, []byte("<")) &&
|
|
bytes.Contains(text, []byte("<svg"))
|
|
}
|
|
|
|
// ValidateMagicBytes detects the format of data from its magic bytes, and
|
|
// returns it when it is the format the declared MIME type names. Data in
|
|
// another format is ErrMagicByteMismatch.
|
|
func ValidateMagicBytes(
|
|
data []byte, declaredType string,
|
|
) (imagerequest.Format, 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 detected.MIMEType() != normalizedDeclared {
|
|
return "", ErrMagicByteMismatch
|
|
}
|
|
|
|
return detected, 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))
|
|
}
|