bingo: fix photo rebuild on UTF-8 tagged S3 bodies
All checks were successful
Build and Deploy to Production / test (push) Successful in 22s
Build and Deploy to Production / build (push) Successful in 1m3s
Build and Deploy to Production / deploy (push) Successful in 39s

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
This commit is contained in:
Sergei Poljanski 2026-10-10 06:32:06 +04:00
commit 61bb1393fe
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
4 changed files with 33 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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', '<html>') }
end