From f1ceb4efc7b4d0fd1f6a55c18d8f13aa3463265e Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Mon, 12 Oct 2026 04:09:08 +0400 Subject: [PATCH] bingo: store photos in their own bucket 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 --- .forgejo/workflows/deploy.yaml | 1 + CLAUDE.md | 2 +- lib/bingo.rb | 18 +++++++++++------- lib/s3.rb | 4 ++-- test/bingo_test.rb | 13 +++++++++++++ 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index 00d4209..5ed45a5 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4bc13b5..e08a259 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/-.`, 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/-.`, 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. diff --git a/lib/bingo.rb b/lib/bingo.rb index 7d60303..dc9ad0c 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -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 diff --git a/lib/s3.rb b/lib/s3.rb index ca74d7a..de5a453 100644 --- a/lib/s3.rb +++ b/lib/s3.rb @@ -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 diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 1857e06..03e60fc 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -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', '') } end