go-places/api/internal/analysis/exif.go

385 lines
10 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:14:50 +02:00
package analysis
import (
"encoding/binary"
"errors"
"math"
"strings"
"time"
)
// This file implements a deliberately small EXIF reader. Only the fields the
// application actually uses are decoded:
//
// Make, Model IFD0, tags 0x010F / 0x0110
// DateTimeOriginal Exif SubIFD, tag 0x9003
// GPSLatitude / GPSLongitude GPS SubIFD, tags 0x0002 / 0x0004
//
// EXIF is a TIFF structure embedded in a file, so parsing it means walking an
// image file directory. Every offset read from the data is treated as untrusted
// and bounds-checked, because the input is a user upload.
// errNoEXIF is returned when the payload carries no EXIF block at all. That is
// the normal case for pictures exported from messaging apps and screenshots.
var errNoEXIF = errors.New("no EXIF block found")
// exifHeaderLen is the length of the "Exif\x00\x00" identifier that introduces
// the APP1 payload.
const exifHeaderLen = 6
// searchWindow bounds how far into the file the EXIF identifier is searched.
// APP1 comes near the front of a JPEG; a small window avoids scanning a
// multi-megabyte payload.
const searchWindow = 128 << 10
// TIFF field types from the TIFF 6.0 specification.
const (
tiffByte = 1
tiffASCII = 2
tiffShort = 3
tiffLong = 4
tiffRational = 5
tiffUndefined = 7
)
type tiffTag uint16
const (
tagMake tiffTag = 0x010F
tagModel tiffTag = 0x0110
tagExifIFDPointer tiffTag = 0x8769
tagGPSIFDPointer tiffTag = 0x8825
tagDateTimeOriginal tiffTag = 0x9003
tagGPSLatitudeRef tiffTag = 0x0001
tagGPSLatitude tiffTag = 0x0002
tagGPSLongitudeRef tiffTag = 0x0003
tagGPSLongitude tiffTag = 0x0004
)
// exifData holds the decoded fields. The zero value means "not present".
type exifData struct {
Make string
Model string
// TakenAt is the capture time. EXIF records no time zone, so it is
// interpreted as UTC.
TakenAt *time.Time
// Latitude and Longitude are signed decimal degrees.
Latitude float64
Longitude float64
HasGPS bool
}
// readEXIF extracts EXIF from a JPEG or PNG payload. A payload without EXIF
// yields errNoEXIF, which callers treat as a normal outcome.
func readEXIF(data []byte) (exifData, error) {
var out exifData
block, err := findExifBlock(data)
if err != nil {
return out, err
}
if err := parseTIFF(block, &out); err != nil {
return exifData{}, err
}
return out, nil
}
// findExifBlock locates the TIFF structure inside the payload. JPEG stores it
// in an APP1 segment and PNG in an "eXIf" chunk; both are preceded by the
// "Exif\x00\x00" identifier in practice, so scanning for that identifier keeps
// the reader format-agnostic.
func findExifBlock(data []byte) ([]byte, error) {
window := data
if len(window) > searchWindow {
window = window[:searchWindow]
}
const marker = "Exif\x00\x00"
for i := 0; i+exifHeaderLen <= len(window); i++ {
if string(window[i:i+exifHeaderLen]) != marker {
continue
}
start := i + exifHeaderLen
if start >= len(data) {
return nil, errNoEXIF
}
return data[start:], nil
}
return nil, errNoEXIF
}
// parseTIFF walks the directory structure starting at the beginning of block.
func parseTIFF(block []byte, out *exifData) error {
if len(block) < 8 {
return errors.New("EXIF: TIFF header is truncated")
}
var order binary.ByteOrder
switch {
case block[0] == 'I' && block[1] == 'I':
order = binary.LittleEndian
case block[0] == 'M' && block[1] == 'M':
order = binary.BigEndian
default:
return errors.New("EXIF: unknown byte order marker")
}
if magic := order.Uint16(block[2:4]); magic != 42 {
return errors.New("EXIF: bad TIFF magic number")
}
ifd0 := order.Uint32(block[4:8])
entries, next, err := readIFD(block, order, ifd0)
if err != nil {
return err
}
for _, e := range entries {
switch e.tag {
case tagMake:
if s, ok := e.asASCII(block, order); ok {
out.Make = s
}
case tagModel:
if s, ok := e.asASCII(block, order); ok {
out.Model = s
}
case tagExifIFDPointer:
if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil {
readExifSubIFD(block, order, sub, out)
}
case tagGPSIFDPointer:
if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil {
readGPSIFD(block, order, sub, out)
}
}
}
_ = next
return nil
}
// entry is one directory entry.
type entry struct {
tag tiffTag
typ uint16
count uint32
// inline is the 4-byte value field as read, used when the payload fits.
inline [4]byte
}
func readExifSubIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) {
for _, e := range entries {
if e.tag != tagDateTimeOriginal {
continue
}
if s, ok := e.asASCII(block, order); ok {
if t, err := time.Parse(exifTimeLayout, s); err == nil {
utc := t.UTC()
out.TakenAt = &utc
}
}
}
}
func readGPSIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) {
var (
lat, lon float64
latOK, lonOK bool
latRef, lonRef string
)
for _, e := range entries {
switch e.tag {
case tagGPSLatitudeRef:
if s, ok := e.asASCII(block, order); ok {
latRef = s
}
case tagGPSLongitudeRef:
if s, ok := e.asASCII(block, order); ok {
lonRef = s
}
case tagGPSLatitude:
if v, ok := e.asRationals(block, order); ok && len(v) >= 3 {
lat, latOK = dmsToDecimal(v[0], v[1], v[2])
}
case tagGPSLongitude:
if v, ok := e.asRationals(block, order); ok && len(v) >= 3 {
lon, lonOK = dmsToDecimal(v[0], v[1], v[2])
}
}
}
if latOK && lonOK {
// Southern latitudes and western longitudes are stored as magnitudes
// with a separate hemisphere reference.
if strings.EqualFold(latRef, "S") {
lat = -lat
}
if strings.EqualFold(lonRef, "W") {
lon = -lon
}
if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 {
out.Latitude = lat
out.Longitude = lon
out.HasGPS = true
}
}
}
// readIFD reads the directory at the given offset and returns its entries plus
// the offset of the next directory, which is unused in practice.
func readIFD(block []byte, order binary.ByteOrder, offset uint32) ([]entry, uint32, error) {
// Each entry is 12 bytes and a directory of more than a few hundred
// entries indicates a malformed or hostile file.
const maxEntries = 512
if !inRange(block, offset, 2) {
return nil, 0, errors.New("EXIF: directory offset is out of range")
}
count := order.Uint16(block[offset : offset+2])
if int(count) > maxEntries {
return nil, 0, errors.New("EXIF: implausible directory entry count")
}
entries := make([]entry, 0, count)
base := int(offset) + 2
for i := 0; i < int(count); i++ {
at := base + i*12
if at < 0 || !inRange(block, uint32(at), 12) {
return nil, 0, errors.New("EXIF: directory entry is out of range")
}
rec := block[at : at+12]
e := entry{
tag: tiffTag(order.Uint16(rec[0:2])),
typ: order.Uint16(rec[2:4]),
count: order.Uint32(rec[4:8]),
}
copy(e.inline[:], rec[8:12])
entries = append(entries, e)
}
nextAt := base + int(count)*12
var next uint32
if nextAt >= 0 && inRange(block, uint32(nextAt), 4) {
next = order.Uint32(block[nextAt : nextAt+4])
}
return entries, next, nil
}
// exifTimeLayout is the non-conforming timestamp format used by EXIF.
const exifTimeLayout = "2006:01:02 15:04:05"
// typeSize returns the byte width of one element of a TIFF type.
//
// RATIONAL and SRATIONAL are two LONGs, so they are eight bytes rather than
// four. Getting this wrong makes every subsequent offset in the directory wrong,
// which is why it is worth stating explicitly.
func typeSize(typ uint16) (int, bool) {
switch typ {
case tiffByte, tiffASCII, tiffUndefined:
return 1, true
case tiffShort:
return 2, true
case tiffLong:
return 4, true
case tiffRational:
return 8, true
default:
return 0, false
}
}
// payload locates the entry's value, which is either inline in the 4-byte value
// field or at a pointer stored in that field.
func (e entry) payload(block []byte, order binary.ByteOrder) ([]byte, bool) {
size, ok := typeSize(e.typ)
if !ok {
return nil, false
}
total := uint64(e.count) * uint64(size)
if total == 0 {
return nil, false
}
// More than a few kilobytes of "value" means a malformed file; the fields
// this application reads are all short.
if total > 4096 {
return nil, false
}
if total <= 4 {
return e.inline[:total], true
}
offset := e.firstValue(order)
if !inRange(block, offset, uint32(total)) {
return nil, false
}
return block[int(offset) : int(offset)+int(total)], true
}
func (e entry) firstValue(order binary.ByteOrder) uint32 {
return order.Uint32(e.inline[:4])
}
func (e entry) asASCII(block []byte, order binary.ByteOrder) (string, bool) {
if e.typ != tiffASCII {
return "", false
}
raw, ok := e.payload(block, order)
if !ok {
return "", false
}
s := string(raw)
if i := strings.IndexByte(s, 0); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s), true
}
// asRationals decodes an array of unsigned rationals. Each is a numerator and
// denominator pair, and a zero denominator makes the entry unusable.
func (e entry) asRationals(block []byte, order binary.ByteOrder) ([]float64, bool) {
if e.typ != tiffRational {
return nil, false
}
raw, ok := e.payload(block, order)
if !ok || len(raw)%8 != 0 {
return nil, false
}
n := len(raw) / 8
out := make([]float64, 0, n)
for i := 0; i < n; i++ {
num := order.Uint32(raw[i*8 : i*8+4])
den := order.Uint32(raw[i*8+4 : i*8+8])
if den == 0 {
return nil, false
}
out = append(out, float64(num)/float64(den))
}
return out, true
}
// dmsToDecimal converts EXIF's degrees/minutes/seconds triple to decimal
// degrees. It reports false for out-of-range or non-finite values.
func dmsToDecimal(degrees, minutes, seconds float64) (float64, bool) {
if math.IsNaN(degrees) || math.IsNaN(minutes) || math.IsNaN(seconds) {
return 0, false
}
if degrees < 0 || degrees > 180 {
return 0, false
}
if minutes < 0 || minutes >= 60 {
return 0, false
}
if seconds < 0 || seconds >= 60 {
return 0, false
}
v := degrees + minutes/60 + seconds/3600
return v, true
}
func inRange(block []byte, offset, length uint32) bool {
end := uint64(offset) + uint64(length)
return end <= uint64(len(block))
}
func math_isNaN(v float64) bool { return v != v }