bingo: store photos in their own bucket
All checks were successful
Build and Deploy to Production / test (push) Successful in 23s
Build and Deploy to Production / build (push) Successful in 1m8s
Build and Deploy to Production / deploy (push) Successful in 37s

The bingo photos sat under bingo/ in the invoice bucket. Store them in
BINGO_S3_BUCKET (datacenterbingo-photos in prod, created and pre-filled
by the storage repo with the same keys) and fall back to S3_BUCKET when
unset, so dev and tests need nothing new.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-12 04:09:08 +04:00
commit f1ceb4efc7
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
5 changed files with 28 additions and 10 deletions

View file

@ -141,6 +141,7 @@ jobs:
LTC_ADDRESS=${LTC_ADDRESS}
CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES}
BINGO_HOST=datacenterbingo.xyz
BINGO_S3_BUCKET=datacenterbingo-photos
INDEXNOW_KEY=${INDEXNOW}
EOF
chmod 600 /opt/asxpio/.env

View file

@ -132,7 +132,7 @@ The public site (datacenterbingo.xyz) lives in its own repo and container: `git.
- Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure; mva swaps the unit. The public default card is operational only, and so is the dashboard tile (`Board.load('operational')`).
- Provider: `bingo_datacenters.provider` holds a `Bingo::PROVIDERS` slug (backfilled by migration 016; new rows get it from `Bingo.guess_provider`; editable in the admin). The public site filters cards by it.
- Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts; `renumber!` runs after deletes). Positions 1–25 are the card; the rest are reserve. Slug is fixed at creation because it names the photo objects.
- Photos: stored in the bucket as `bingo/<slug>-<hex>.<ext>`, with the type sniffed from magic bytes (JPEG/PNG/WebP).
- Photos: stored in `BINGO_S3_BUCKET` (`datacenterbingo-photos`, made by the storage repo; unset = `S3_BUCKET`) as `bingo/<slug>-<hex>.<ext>`, with the type sniffed from magic bytes (JPEG/PNG/WebP).
- Limits: 8 MB cap, and metadata is stripped (GPS!). URL import is https-only.
- On store, ImageMagick derives `-480.webp`, `-1200.webp` and `-og.jpg` (1200×630). `photo_variants` / `photo_og` record whether that succeeded.
- "Rebuild resized photos" re-derives the variants from the stored originals.

View file

@ -160,8 +160,8 @@ module Bingo
end
end
# Photos live in the app's MinIO bucket under bingo/ and are proxied by
# the bingo site (and /admin/bingo/p here), so the bucket stays private.
# Photos live under bingo/ in Photo.bucket and are proxied by the bingo
# site (and /admin/bingo/p here), so the bucket stays private.
module Photo
PREFIX = 'bingo/'.freeze
MAX_BYTES = 8 * 1024 * 1024
@ -175,6 +175,10 @@ module Bingo
module_function
# Own bucket in prod (datacenterbingo-photos, shared read-only with the
# bingo site); unset = the app bucket (dev, tests).
def bucket = ENV['BINGO_S3_BUCKET'].to_s.empty? ? S3.bucket : ENV['BINGO_S3_BUCKET']
# 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)
@ -193,7 +197,7 @@ module Bingo
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])
S3.put(key, bytes, content_type: TYPES[ext], bucket: bucket)
key
end
@ -207,7 +211,7 @@ module Bingo
# Re-derives the variants from the stored original.
def rebuild(dc)
dc.update(**build_variants(dc.photo_key, S3.get(dc.photo_key)))
dc.update(**build_variants(dc.photo_key, S3.get(dc.photo_key, bucket: bucket)))
end
def variant_key(key, width)
@ -222,8 +226,8 @@ module Bingo
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']) }
S3.put(og_key(key), og_crop(bytes, ext), content_type: TYPES['jpg'])
WIDTHS.each { |w| S3.put(variant_key(key, w), resize(bytes, ext, w), content_type: TYPES['webp'], bucket: bucket) }
S3.put(og_key(key), og_crop(bytes, ext), content_type: TYPES['jpg'], bucket: bucket)
{ photo_variants: true, photo_og: true }
rescue Error => e
$logger.warn("bingo: no variants for #{key}: #{e.message}")
@ -352,7 +356,7 @@ module Bingo
def read(file)
raise Error, 'bad name' unless file =~ FILE_RE
[S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]]
[S3.get(PREFIX + file, bucket: bucket), TYPES[Regexp.last_match(1)]]
end
end

View file

@ -30,7 +30,7 @@ module S3
ENV.fetch('S3_BUCKET')
end
def put(key, bytes, content_type: 'application/pdf')
def put(key, bytes, content_type: 'application/pdf', bucket: self.bucket)
client.put_object(
bucket: bucket,
key: key,
@ -40,7 +40,7 @@ module S3
key
end
def get(key)
def get(key, bucket: self.bucket)
client.get_object(bucket: bucket, key: key).body.read.b # body is a UTF-8 StringIO
end

View file

@ -213,6 +213,19 @@ class BingoTest < Minitest::Test
S3.instance_variable_set(:@client, nil)
end
def test_photos_use_their_own_bucket
puts_to = []
S3.client.stub(:put_object, ->(**o) { puts_to << o[:bucket] }) do
ENV['BINGO_S3_BUCKET'] = 'photos-bucket'
Bingo::Photo.store('dc-1', JPEG)
ENV.delete('BINGO_S3_BUCKET')
Bingo::Photo.store('dc-1', JPEG)
end
assert_equal ['photos-bucket', S3.bucket], puts_to
ensure
ENV.delete('BINGO_S3_BUCKET')
end
def test_photo_rejects_non_images
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
end