From 61bb1393fe4af7d314b539faa799f61a49e4a074 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 06:32:06 +0400 Subject: [PATCH] bingo: fix photo rebuild on UTF-8 tagged S3 bodies aws-sdk returns object bodies from a plain StringIO, so S3.get handed back a UTF-8 tagged string and Photo.sniff's binary prefix comparison raised Encoding::CompatibilityError: "Rebuild resized photos" died on the first photo with a 500. URL imports had the same flaw via a UTF-8 read buffer. Treat image data as binary at the source and in sniff, and let one failing photo no longer abort the batch (errors are reported in the admin flash and logged). Assisted-by: Claude:opus-5 --- lib/bingo.rb | 8 +++++++- lib/bingo_admin.rb | 10 ++++++++-- lib/s3.rb | 2 +- test/bingo_test.rb | 17 +++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) 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