diff --git a/lib/bingo.rb b/lib/bingo.rb index 53ba5f5..58367cf 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -138,7 +138,10 @@ module Bingo module_function + # Input may arrive tagged UTF-8 (aws-sdk bodies are plain StringIOs), and + # comparing that with binary literals raises, so look at raw bytes. def sniff(bytes) + bytes = bytes.b return 'jpg' if bytes.start_with?("\xFF\xD8\xFF".b) return 'png' if bytes.start_with?("\x89PNG\r\n\x1A\n".b) return 'webp' if bytes[0, 4] == 'RIFF' && bytes[8, 4] == 'WEBP' @@ -175,6 +178,7 @@ module Bingo end def build_variants(key, bytes) + bytes = bytes.b ext = sniff(bytes) or raise Error, 'not an image' WIDTHS.each { |w| S3.put(variant_key(key, w), resize(bytes, ext, w), content_type: TYPES['webp']) } true @@ -198,6 +202,8 @@ module Bingo out.b rescue Errno::ENOENT raise Error, 'ImageMagick (convert) not installed' + rescue Errno::EPIPE, IOError => e # convert exited before reading all input + raise Error, "convert: #{e.class}" end # Photos are served byte-for-byte, so drop EXIF/XMP/IPTC/text metadata: @@ -276,7 +282,7 @@ module Bingo raise Error, 'too many redirects' if redirects.zero? return fetch(URI.join(uri, res['location']).to_s, redirects: redirects - 1) when Net::HTTPSuccess - body = +'' + body = +''.b res.read_body do |chunk| body << chunk raise Error, 'image too large (max 8 MB)' if body.bytesize > MAX_BYTES diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index 63ac4ac..9a4b93a 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -162,8 +162,14 @@ module BingoAdmin # Regenerate resized variants from the originals already in the bucket. app.post '/admin/bingo/photos/rebuild' do dcs = Bingo::Datacenter.exclude(photo_key: nil).all - dcs.each { |dc| Bingo::Photo.rebuild(dc) } - failed = dcs.reject(&:photo_variants).map(&:slug) + errors = {} + dcs.each do |dc| + Bingo::Photo.rebuild(dc) + rescue StandardError => e # one bad object must not abort the batch + errors[dc.slug] = "#{e.class}: #{e.message.lines.first&.strip}" + $logger.error("bingo: rebuild #{dc.slug}: #{e.class}: #{e.message}") + end + failed = dcs.reject(&:photo_variants).map { |dc| errors[dc.slug] ? "#{dc.slug} (#{errors[dc.slug]})" : dc.slug } session[:bingo_flash] = "Rebuilt #{dcs.size - failed.size}/#{dcs.size}.#{" Failed: #{failed.join(', ')}" if failed.any?}" redirect '/admin/bingo' end diff --git a/lib/s3.rb b/lib/s3.rb index 339ed84..ca74d7a 100644 --- a/lib/s3.rb +++ b/lib/s3.rb @@ -41,7 +41,7 @@ module S3 end def get(key) - client.get_object(bucket: bucket, key: key).body.read + client.get_object(bucket: bucket, key: key).body.read.b # body is a UTF-8 StringIO end def exists?(key) diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 87f5578..bdd5ce2 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -170,6 +170,23 @@ class BingoTest < Minitest::Test assert_equal '480x240', dims end + def test_sniff_accepts_utf8_tagged_bytes + assert_equal 'jpg', Bingo::Photo.sniff(JPEG.dup.force_encoding('UTF-8')) + end + + def test_rebuild_from_utf8_s3_body + skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL) + png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '800x600', 'gradient:red-blue', 'png:-', binmode: true) + Aws.config[:s3] = { stub_responses: { get_object: { body: png.dup.force_encoding('UTF-8') } } } + S3.instance_variable_set(:@client, nil) + @dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.png') + Bingo::Photo.rebuild(@dcs[0]) + assert @dcs[0].refresh.photo_variants + ensure + Aws.config[:s3] = { stub_responses: true } + S3.instance_variable_set(:@client, nil) + end + def test_photo_rejects_non_images assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '') } end