diff --git a/lib/bingo.rb b/lib/bingo.rb index 4d77318..9609b1c 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -135,18 +135,82 @@ module Bingo bytes = bytes.b raise Error, 'image too large (max 8 MB)' if bytes.bytesize > MAX_BYTES ext = sniff(bytes) or raise Error, 'not a JPEG, PNG or WebP image' + bytes = strip_metadata(bytes, ext) key = "#{PREFIX}#{slug}-#{SecureRandom.hex(4)}.#{ext}" S3.put(key, bytes, content_type: TYPES[ext]) key end + # Photos are served byte-for-byte, so drop EXIF/XMP/IPTC/text metadata: + # phone shots carry GPS coordinates, and the card stays city-level. + # Pixel data is untouched; malformed input is rejected rather than + # passed through. + def strip_metadata(bytes, ext) + case ext + when 'jpg' then strip_jpeg(bytes) + when 'png' then strip_png(bytes) + when 'webp' then strip_webp(bytes) + end + end + + JPEG_DROP = [0xE1, *0xE3..0xEF, 0xFE].freeze # APP1 (Exif/XMP), APP3-15 (IPTC...), COM + + def strip_jpeg(b) + out = +"\xFF\xD8".b + i = 2 + while i < b.bytesize + raise Error, 'corrupt JPEG' unless b.getbyte(i) == 0xFF + marker = b.getbyte(i + 1) + if marker == 0xFF then i += 1; next end # fill byte + if marker == 0xDA || marker == 0xD9 # start of scan: rest is image data + return out << b.byteslice(i..) + end + len = b.byteslice(i + 2, 2)&.unpack1('n') or raise Error, 'corrupt JPEG' + out << b.byteslice(i, len + 2) unless JPEG_DROP.include?(marker) + i += len + 2 + end + raise Error, 'corrupt JPEG' + end + + PNG_DROP = %w[eXIf tEXt iTXt zTXt tIME].freeze + + def strip_png(b) + out = b.byteslice(0, 8) + i = 8 + while i + 8 <= b.bytesize + len, type = b.byteslice(i, 8).unpack('Na4') + out << b.byteslice(i, len + 12) unless PNG_DROP.include?(type) + i += len + 12 + return out if type == 'IEND' + end + raise Error, 'corrupt PNG' + end + + def strip_webp(b) + body = +''.b + i = 12 + while i + 8 <= b.bytesize + type, len = b.byteslice(i, 8).unpack('a4V') + chunk = b.byteslice(i, 8 + len + (len & 1)) + if type == 'VP8X' + chunk = chunk.dup + chunk.setbyte(8, chunk.getbyte(8) & ~0x0C) # clear EXIF + XMP flags + end + body << chunk unless %w[EXIF XMP\ ].include?(type) + i += 8 + len + (len & 1) + end + raise Error, 'corrupt WebP' if body.empty? + 'RIFF'.b + [body.bytesize + 4].pack('V') + 'WEBP'.b + body + end + # Admin-only "import from URL". https only, a few redirects, size-capped. def fetch(url, redirects: 3) uri = URI(url) raise Error, 'https URL required' unless uri.is_a?(URI::HTTPS) Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http| req = Net::HTTP::Get.new(uri) - req['User-Agent'] = 'Mozilla/5.0 (bingo photo import)' + # Wikimedia rate-limits generic agents; its policy wants name + contact. + req['User-Agent'] = 'asxpio-bingo/1.0 (https://asxp.io; ie@asxp.io)' http.request(req) do |res| case res when Net::HTTPRedirection diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index d74c6d4..1957c06 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -87,10 +87,13 @@ module BingoAdmin redirect "/admin/bingo/dc/#{dc.id}" end - # Bulk: import every missing photo from its photo_source_url. + # Bulk: import every missing photo from its photo_source_url, or with + # all=1 re-import every photo that has a source URL. app.post '/admin/bingo/photos/import' do ok, failed = 0, [] - Bingo::Datacenter.where(photo_key: nil).exclude(photo_source_url: nil).each do |dc| + scope = Bingo::Datacenter.exclude(photo_source_url: nil) + scope = scope.where(photo_key: nil) unless params[:all] == '1' + scope.each do |dc| dc.update(photo_key: Bingo::Photo.store(dc.slug, Bingo::Photo.fetch(dc.photo_source_url))) ok += 1 rescue Bingo::Photo::Error => e diff --git a/test/bingo_test.rb b/test/bingo_test.rb index dcf9f47..e6fa277 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -3,7 +3,8 @@ require_relative 'test_helper' class BingoTest < Minitest::Test include Rack::Test::Methods - JPEG = "\xFF\xD8\xFF\xE0fakejpeg".b + # Minimal well-formed JPEG: SOI, start-of-scan, data, EOI. + JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze def app @app || BingoWeb @@ -84,6 +85,34 @@ class BingoTest < Minitest::Test S3.instance_variable_set(:@client, nil) end + def test_strips_jpeg_exif + exif = "\xFF\xE1".b + [17].pack('n') + "Exif\0\0GPSDATA!!".b + jfif = "\xFF\xE0".b + [16].pack('n') + "JFIF\0".b + ("\0" * 9).b + scan = "\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6pixels\xFF\xD9".b + out = Bingo::Photo.strip_metadata("\xFF\xD8".b + jfif + exif + scan, 'jpg') + refute_includes out, 'GPSDATA' + assert_equal "\xFF\xD8".b + jfif + scan, out + end + + def test_strips_png_text_chunks + chunk = ->(type, data) { [data.bytesize].pack('N') + type + data + "\0\0\0\0".b } + sig = "\x89PNG\r\n\x1A\n".b + ihdr = chunk.('IHDR', "\0" * 13) + out = Bingo::Photo.strip_metadata(sig + ihdr + chunk.('eXIf', 'GPS') + chunk.('tEXt', 'k\0v') + chunk.('IEND', ''), 'png') + assert_equal sig + ihdr + chunk.('IEND', ''), out + end + + def test_strips_webp_exif + vp8x = 'VP8X'.b + [10].pack('V') + [0x08 | 0x10].pack('C') + ("\0" * 9).b # EXIF + alpha flags + img = 'VP8L'.b + [5].pack('V') + "abcde\0".b + exif = 'EXIF'.b + [3].pack('V') + "GPS\0".b + body = vp8x + img + exif + out = Bingo::Photo.strip_metadata('RIFF'.b + [body.bytesize + 4].pack('V') + 'WEBPh'.b[0, 4] + body, 'webp') + refute_includes out, 'GPS' + assert_equal 0x10, out.getbyte(20) # alpha kept, EXIF flag cleared + assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V') + end + def test_photo_rejects_non_images assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '') } end diff --git a/views/admin/bingo/index.erb b/views/admin/bingo/index.erb index 1dff1f4..9523f19 100644 --- a/views/admin/bingo/index.erb +++ b/views/admin/bingo/index.erb @@ -71,6 +71,11 @@ +
| # | Photo | Name | Operator | MW | Status | Incidents |
|---|