// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package imagefile identifies the image formats volumen accepts, from // the bytes rather than from a name or a declared type. // // Only the three formats below are stored, and the media route serves // nothing else: a browser is never handed a document from a directory // that an archive or an upload can write to. An SVG is a document of // sorts, so the media route serves it sandboxed and the signature test // here demands the root element really be . package imagefile import ( "bytes" "encoding/binary" "path/filepath" "strconv" "strings" ) // The canonical extensions and the Content-Type each is served with. const ( ExtWebP = ".webp" ExtAVIF = ".avif" ExtSVG = ".svg" MIMEWebP = "image/webp" MIMEAVIF = "image/avif" MIMESVG = "image/svg+xml" ) var mimeTypes = map[string]string{ ExtWebP: MIMEWebP, ExtAVIF: MIMEAVIF, ExtSVG: MIMESVG, } // ContentType returns the Content-Type for an allowed image name, or "" // when the name does not carry an allowed extension. func ContentType(name string) string { return mimeTypes[strings.ToLower(filepath.Ext(filepath.Base(name)))] } // Allowed reports whether name carries an allowed extension. func Allowed(name string) bool { return ContentType(name) != "" } // SignatureMatches reports whether data carries the signature of the // format the extension names. func SignatureMatches(data []byte, ext string) bool { switch strings.ToLower(ext) { case ExtWebP: // RIFF....WEBP, twelve bytes of magic. return len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) case ExtAVIF: // ISO BMFF: bytes 4..7 are the box size, 8..11 are "ftyp", // followed by the major brand. if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) { return false } brand := data[8:12] return bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis")) case ExtSVG: // The root element must be : XML text that opens with // another document (an XHTML page, an SVGZ masquerading as a // plain .svg) is not an accepted image. return svgRootTag(data) != nil } return false } // Detect returns the canonical extension for data, or "" when the bytes // carry no accepted signature. func Detect(data []byte) string { if SignatureMatches(data, ExtWebP) { return ExtWebP } if SignatureMatches(data, ExtAVIF) { return ExtAVIF } if SignatureMatches(data, ExtSVG) { return ExtSVG } return "" } // Dimensions reports the pixel size of a WebP, AVIF or SVG image, reading // only the container headers or the root element, and ok=false when the // bytes carry no size it can trust. A caller that holds a whole file can // pass it whole; a 64 KiB prefix of a media file carries every header // this reads. func Dimensions(data []byte) (width, height int, ok bool) { if w, h, ok := webpDimensions(data); ok { return w, h, true } if w, h, ok := avifDimensions(data); ok { return w, h, true } return svgDimensions(data) } // webpDimensions reads the size out of the three chunk shapes WebP // uses: VP8 (lossy), VP8L (lossless) and VP8X (extended canvas). func webpDimensions(data []byte) (int, int, bool) { if len(data) < 20 || !bytes.Equal(data[:4], []byte("RIFF")) || !bytes.Equal(data[8:12], []byte("WEBP")) { return 0, 0, false } switch string(data[12:16]) { case "VP8 ": // After the frame tag sit the three sync bytes 0x9d 0x01 0x2a, // then the width and the height as 16-bit little-endian values // whose top two bits carry a scale code. if len(data) < 30 || data[23] != 0x9d || data[24] != 0x01 || data[25] != 0x2a { return 0, 0, false } w := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3fff) h := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3fff) return w, h, w > 0 && h > 0 case "VP8L": // The payload opens with 0x2f and packs width-1 into 14 bits // followed by height-1 into 14 more, least significant first. if len(data) < 25 || data[20] != 0x2f { return 0, 0, false } bits := uint32(data[21]) | uint32(data[22])<<8 | uint32(data[23])<<16 | uint32(data[24])<<24 w := int(bits&0x3fff) + 1 h := int((bits>>14)&0x3fff) + 1 return w, h, true case "VP8X": // The canvas size sits as two 24-bit little-endian minus-one // values after the flags and three reserved bytes. if len(data) < 30 { return 0, 0, false } w := int(uint32(data[24])|uint32(data[25])<<8|uint32(data[26])<<16) + 1 h := int(uint32(data[27])|uint32(data[28])<<8|uint32(data[29])<<16) + 1 return w, h, true } return 0, 0, false } // avifDimensions walks the ISO-BMFF boxes of an AVIF: the meta box // names the primary item (pitm) and associates it with properties // (ipma inside iprp); the ispe property it points at carries the image // extent. Anything the walk cannot certify is reported as unknown // rather than guessed. func avifDimensions(data []byte) (int, int, bool) { if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) { return 0, 0, false } var meta []byte for _, b := range readBoxes(data) { if b.typ == "meta" { meta = b.body break } } if meta == nil || len(meta) < 4 { return 0, 0, false } // meta is a full box: four bytes of version and flags precede the // children. children := readBoxes(meta[4:]) var primary uint64 var properties []box var associations []box for _, b := range children { switch b.typ { case "pitm": if len(b.body) < 1 { return 0, 0, false } // The bounds name the whole item id: a box whose body stops // short of it is refused rather than read past, because a // truncated box at the end of the buffer has no bytes left // to read and the slice would run out of range. if b.body[0] == 0 { if len(b.body) < 6 { return 0, 0, false } primary = uint64(binary.BigEndian.Uint16(b.body[4:6])) } else { if len(b.body) < 8 { return 0, 0, false } primary = uint64(binary.BigEndian.Uint32(b.body[4:8])) } case "iprp": for _, inner := range readBoxes(b.body) { switch inner.typ { case "ipco": properties = readBoxes(inner.body) case "ipma": associations = append(associations, inner) } } } } if primary == 0 || properties == nil { return 0, 0, false } for _, assoc := range associations { for _, propertyIndex := range readAssociations(assoc) { if propertyIndex.item != primary { continue } // Property indices are one-based over ipco's children. if propertyIndex.index == 0 || propertyIndex.index > len(properties) { continue } boxed := properties[propertyIndex.index-1] // ispe is a full box: version and flags, then the width and // the height as big-endian 32-bit values. if boxed.typ != "ispe" || len(boxed.body) < 12 { continue } w := int(binary.BigEndian.Uint32(boxed.body[4:8])) h := int(binary.BigEndian.Uint32(boxed.body[8:12])) return w, h, w > 0 && h > 0 } } return 0, 0, false } // boxAssociation pairs an item id with the one-based property index one // of its associations names. type boxAssociation struct { item uint64 index int } // readAssociations decodes an ipma box's entries into item/property // pairs, honouring both the 16- and 32-bit item id sizes and the // 7- and 15-bit index sizes the flags select. func readAssociations(b box) []boxAssociation { if len(b.body) < 12 { return nil } flags := uint32(b.body[1])<<16 | uint32(b.body[2])<<8 | uint32(b.body[3]) wideItems := flags&0b10 != 0 wideIndexes := flags&0b01 != 0 idSize, indexSize := 2, 1 if wideItems { idSize = 4 } if wideIndexes { indexSize = 2 } count := int(binary.BigEndian.Uint32(b.body[4:8])) out := make([]boxAssociation, 0, count) pos := 8 for range count { if pos+idSize+1 > len(b.body) { return out } var item uint64 if wideItems { item = uint64(binary.BigEndian.Uint32(b.body[pos : pos+4])) } else { item = uint64(binary.BigEndian.Uint16(b.body[pos : pos+2])) } pos += idSize assocCount := int(b.body[pos]) pos++ for range assocCount { if pos+indexSize > len(b.body) { return out } var index int if wideIndexes { // The essential bit rides the top bit of a 15-bit index. index = int(binary.BigEndian.Uint16(b.body[pos:pos+2]) & 0x7fff) } else { index = int(b.body[pos] & 0x7f) } pos += indexSize out = append(out, boxAssociation{item: item, index: index}) } } return out } // box is one ISO-BMFF box: its type and the body after the header. type box struct { typ string body []byte } // readBoxes splits a run of sibling boxes. A size of zero means "to the // end of the input" and a size of one promotes to a 64-bit size; both // are honoured, and a truncated or empty box stops the walk. func readBoxes(data []byte) []box { var out []box pos := 0 for pos+8 <= len(data) { size := uint64(binary.BigEndian.Uint32(data[pos : pos+4])) typ := string(data[pos+4 : pos+8]) header := 8 if size == 1 { if pos+16 > len(data) { break } size = binary.BigEndian.Uint64(data[pos+8 : pos+16]) header = 16 } if size < uint64(header) { break } end := min(uint64(pos)+size, uint64(len(data))) out = append(out, box{typ: typ, body: data[pos+header : end]}) if end <= uint64(pos)+8 { break } pos = int(end) } return out } // svgRootTag returns the start tag of the root element, or nil when // the bytes are not an SVG document. The XML prolog, comments and any // leading declaration are stepped over on the way to it; anything that // opens the document with another root element is refused, so an XHTML // page never takes the .svg extension it is served under. func svgRootTag(data []byte) []byte { s := bytes.TrimPrefix(data, []byte("\ufeff")) for { s = bytes.TrimLeft(s, " \t\r\n") switch { case bytes.HasPrefix(s, []byte("")) if end < 0 { return nil } s = s[end+2:] case bytes.HasPrefix(s, []byte("")) if end < 0 { return nil } s = s[end+3:] case bytes.HasPrefix(s, []byte("') if end < 0 { return nil } s = s[end+1:] case bytes.HasPrefix(s, []byte(" 4 { switch s[4] { case ' ', '\t', '\n', '\r', '>', '/': default: return nil // ') if end < 0 { return nil } return s[:end+1] default: return nil } } } // svgDimensions reads the size off the root element: the width and height // attributes when both are bare lengths, or the viewBox box otherwise. A // percentage length carries no size the media library could show. func svgDimensions(data []byte) (int, int, bool) { tag := svgRootTag(data) if tag == nil { return 0, 0, false } if width, ok := svgLength(tag, "width"); ok { if height, ok := svgLength(tag, "height"); ok { return width, height, width > 0 && height > 0 } } if box, ok := svgAttr(tag, "viewBox"); ok { parts := strings.FieldsFunc(box, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) if len(parts) == 4 { w, errW := strconv.ParseFloat(parts[2], 64) h, errH := strconv.ParseFloat(parts[3], 64) if errW == nil && errH == nil && w >= 1 && h >= 1 { return int(w), int(h), true } } } return 0, 0, false } // svgLength reads one of the root element's size attributes as a pixel // length: a plain number or one written in px. func svgLength(tag []byte, name string) (int, bool) { value, ok := svgAttr(tag, name) if !ok { return 0, false } value = strings.TrimSuffix(strings.TrimSuffix(value, "px"), "PX") if strings.TrimLeftFunc(value, func(r rune) bool { return (r >= '0' && r <= '9') || r == '.' }) != "" { return 0, false // a unit the media library does not convert } n, err := strconv.ParseFloat(value, 64) if err != nil { return 0, false } return int(n), n >= 0 } // svgAttr returns the quoted value of one attribute of a start tag. The // scan is deliberately shallow: it reads the plain attributes the size // of a figure is written with and gives up on anything else. func svgAttr(tag []byte, name string) (string, bool) { s := string(tag) i := strings.IndexByte(s, ' ') // past "= len(s) || s[i] != '=' { return "", false } i++ if i >= len(s) || (s[i] != '"' && s[i] != '\'') { return "", false } quote := s[i] i++ valueStart := i for i < len(s) && s[i] != quote { i++ } if i >= len(s) { return "", false } value := s[valueStart:i] i++ if key == name { return value, true } } return "", false } func isSVGTagSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' }