initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
384
api/internal/analysis/exif.go
Normal file
384
api/internal/analysis/exif.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue