asxpio/lib/bingo.rb
Sergei Poljanski 201a70b07b
All checks were successful
Build and Deploy to Production / test (push) Successful in 22s
Build and Deploy to Production / build (push) Successful in 1m1s
Build and Deploy to Production / deploy (push) Successful in 39s
bingo: SEO pass and resized photos
The bingo host fell through to asxp.io's public/, so crawlers got
asxp.io's robots.txt pointing at asxp.io's sitemap (and the keys and
invoice JS were served too). Turn static files off for BingoWeb and give
it its own robots.txt and a sitemap with hreflang alternates.

The card loaded 15.6 MB of full-size photos as cell backgrounds. Derive
480/1200 px WebP variants with ImageMagick on store (memory/area limits,
input coder pinned to the sniffed type), render cells as <img> with lazy
loading past the first two rows, and add an admin action that rebuilds
variants from the stored originals.

Head gets per-locale titles and descriptions carrying the live score,
og:image from the latest hit, Twitter card, og:locale alternates,
JSON-LD WebSite + ItemList, and Vary: Accept-Language on the redirect.

Assisted-by: Claude:opus-5
2026-10-10 06:26:03 +04:00

348 lines
15 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require 'sequel'
require 'json'
require 'net/http'
require 'securerandom'
require 'open3'
require_relative 's3'
# Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is
# crossed out once a publicly sourced incident is recorded for it.
module Bingo
LOCALES = %w[ru en uk].freeze
KINDS = %w[strike fire outage flood cooling other].freeze
ICONS = { 'strike' => '💥', 'fire' => '🔥', 'outage' => '⚡', 'flood' => '💧', 'cooling' => '🌡️', 'other' => '⚠️' }.freeze
SIZE = 5
# Every row, column and both diagonals, as 0-based cell indices.
LINES = (
(0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } +
(0...SIZE).map { |c| (0...SIZE).map { |r| r * SIZE + c } } +
[(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }]
).freeze
T = {
'ru' => {
seo_title: 'Бинго ЦОДов — атаки, пожары и аварии дата-центров России',
seo_desc: '%{hit} из %{total} крупнейших дата-центров России уже зачёркнуты. Бинго-карточка ЦОДов IXcellerate, Яндекса, Ростелекома, Росатома и других: атаки БПЛА, пожары и отключения с источниками.',
og_locale: 'ru_RU',
title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго',
desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.',
bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.',
building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник',
close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.',
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
letters: %w[Б И Н Г О]
},
'en' => {
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
seo_desc: '%{hit} of Russia\'s %{total} largest datacenters are already crossed out. A bingo card of IXcellerate, Yandex, Rostelecom, Rosatom and other datacenters: drone strikes, fires and outages, each with a source.',
og_locale: 'en_US',
title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter',
desc: "Russia's 25 largest datacenters by power. A cell is crossed out after a publicly reported incident.",
crossed: 'Crossed out', lines: 'lines', updated: 'updated',
log: 'Incident log', empty: 'Everything is up. For now.',
bingo: 'BINGO!', bingo_n: 'BINGO ×%d!', full: 'FULL CARD. There is no more internet.',
building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source',
close: 'Close', no_incidents: 'No incidents recorded.',
footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.',
kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' },
letters: %w[B I N G O]
},
'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.',
og_locale: 'uk_UA',
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.',
bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.',
building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело',
close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.',
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
letters: %w[Б І Н Г О]
}
}.freeze
# Localized column with fallback to ru.
module Localized
def l(field, locale)
v = self[:"#{field}_#{locale}"].to_s.strip
v.empty? ? self[:"#{field}_ru"] : v
end
end
class Datacenter < Sequel::Model(:bingo_datacenters)
include Localized
one_to_many :incidents, class: 'Bingo::Incident', key: :datacenter_id, order: Sequel.desc(:occurred_on)
def before_create
self.created_at ||= Time.now.utc
super
end
def building?
status == 'building'
end
# width: a Photo::WIDTHS variant (falls back to the original until the
# variants exist), nil for the original.
def photo_file(width = nil)
return unless photo_key
key = width && photo_variants ? Photo.variant_key(photo_key, width) : photo_key
key.delete_prefix(Photo::PREFIX)
end
end
class Incident < Sequel::Model(:bingo_incidents)
include Localized
many_to_one :datacenter, class: 'Bingo::Datacenter'
def before_create
self.created_at ||= Time.now.utc
super
end
end
# Board state for rendering: ordered cells, hits and completed lines.
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on) do
def self.load
cells = Datacenter.order(:position, :id).limit(SIZE * SIZE).eager(:incidents).all
hit = cells.map { |dc| dc.incidents.any? }
wins = LINES.select { |line| line.all? { |i| hit[i] } }
incidents = cells.flat_map(&:incidents).sort_by { |i| [i.occurred_on, i.id] }.reverse
updated = [Datacenter.max(:created_at), Incident.max(:created_at)].compact.max
new(cells, hit, wins, incidents, updated)
end
def win_cells
@win_cells ||= wins.flatten.to_set
end
end
# Photos live in the app's MinIO bucket under bingo/ and are proxied by the
# app, so the bucket stays private.
module Photo
PREFIX = 'bingo/'.freeze
MAX_BYTES = 8 * 1024 * 1024
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200))?\.(jpg|png|webp)\z/
WIDTHS = [480, 1200].freeze # card cell (2x), dialog / og:image
CONVERT = ENV.fetch('BINGO_CONVERT', 'convert') # ImageMagick
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
class Error < StandardError; end
module_function
def sniff(bytes)
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'
nil
end
# Stores bytes and returns the new key; the old object is left in place
# (cheap, and keeps cached URLs valid until their max-age runs out).
def store(slug, bytes)
bytes = bytes.b
raise Error, 'image too large (max 8 MB)' if bytes.bytesize > MAX_BYTES
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])
key
end
# Stores the photo for dc and makes it current. Variants are best effort:
# without them the card falls back to the original.
def attach(dc, bytes)
key = store(dc.slug, bytes)
dc.update(photo_key: key, photo_variants: build_variants(key, bytes))
key
end
# Re-derives the variants from the stored original.
def rebuild(dc)
dc.update(photo_variants: build_variants(dc.photo_key, S3.get(dc.photo_key)))
end
def variant_key(key, width)
key.sub(/\.\w+\z/, "-#{width}.webp")
end
def build_variants(key, bytes)
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
rescue Error => e
$logger.warn("bingo: no variants for #{key}: #{e.message}")
false
end
# Downscale (never up) to fit width x width, re-encode as WebP; -strip
# drops metadata again. The input coder is pinned to the sniffed type and
# memory/area limits guard against decompression bombs.
def resize(bytes, ext, width)
coder = { 'jpg' => 'jpeg', 'png' => 'png', 'webp' => 'webp' }.fetch(ext)
out, err, st = Open3.capture3(
CONVERT, '-limit', 'memory', '256MiB', '-limit', 'area', '128MP',
"#{coder}:-", '-auto-orient', '-resize', "#{width}x#{width}>",
'-strip', '-quality', '80', 'webp:-',
stdin_data: bytes, binmode: true
)
raise Error, "convert: #{err.lines.first&.strip}" unless st.success? && sniff(out.b) == 'webp'
out.b
rescue Errno::ENOENT
raise Error, 'ImageMagick (convert) not installed'
end
# Photos are served byte-for-byte, so drop EXIF/XMP/IPTC/text metadata:
# phone shots carry GPS coordinates, and the card stays city-level.
# Pixel data is untouched; malformed input is rejected rather than
# passed through.
def strip_metadata(bytes, ext)
case ext
when 'jpg' then strip_jpeg(bytes)
when 'png' then strip_png(bytes)
when 'webp' then strip_webp(bytes)
end
end
JPEG_DROP = [0xE1, *0xE3..0xEF, 0xFE].freeze # APP1 (Exif/XMP), APP3-15 (IPTC...), COM
def strip_jpeg(b)
out = +"\xFF\xD8".b
i = 2
while i < b.bytesize
raise Error, 'corrupt JPEG' unless b.getbyte(i) == 0xFF
marker = b.getbyte(i + 1)
if marker == 0xFF then i += 1; next end # fill byte
if marker == 0xDA || marker == 0xD9 # start of scan: rest is image data
return out << b.byteslice(i..)
end
len = b.byteslice(i + 2, 2)&.unpack1('n') or raise Error, 'corrupt JPEG'
out << b.byteslice(i, len + 2) unless JPEG_DROP.include?(marker)
i += len + 2
end
raise Error, 'corrupt JPEG'
end
PNG_DROP = %w[eXIf tEXt iTXt zTXt tIME].freeze
def strip_png(b)
out = b.byteslice(0, 8)
i = 8
while i + 8 <= b.bytesize
len, type = b.byteslice(i, 8).unpack('Na4')
out << b.byteslice(i, len + 12) unless PNG_DROP.include?(type)
i += len + 12
return out if type == 'IEND'
end
raise Error, 'corrupt PNG'
end
def strip_webp(b)
body = +''.b
i = 12
while i + 8 <= b.bytesize
type, len = b.byteslice(i, 8).unpack('a4V')
chunk = b.byteslice(i, 8 + len + (len & 1))
if type == 'VP8X'
chunk = chunk.dup
chunk.setbyte(8, chunk.getbyte(8) & ~0x0C) # clear EXIF + XMP flags
end
body << chunk unless %w[EXIF XMP\ ].include?(type)
i += 8 + len + (len & 1)
end
raise Error, 'corrupt WebP' if body.empty?
'RIFF'.b + [body.bytesize + 4].pack('V') + 'WEBP'.b + body
end
# Admin-only "import from URL". https only, a few redirects, size-capped.
def fetch(url, redirects: 3)
uri = URI(url)
raise Error, 'https URL required' unless uri.is_a?(URI::HTTPS)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
req = Net::HTTP::Get.new(uri)
# Wikimedia rate-limits generic agents; its policy wants name + contact.
req['User-Agent'] = 'asxpio-bingo/1.0 (https://asxp.io; ie@asxp.io)'
http.request(req) do |res|
case res
when Net::HTTPRedirection
raise Error, 'too many redirects' if redirects.zero?
return fetch(URI.join(uri, res['location']).to_s, redirects: redirects - 1)
when Net::HTTPSuccess
body = +''
res.read_body do |chunk|
body << chunk
raise Error, 'image too large (max 8 MB)' if body.bytesize > MAX_BYTES
end
return body
else
raise Error, "HTTP #{res.code} from #{uri.host}"
end
end
end
rescue URI::InvalidURIError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, Errno::ECONNREFUSED => e
raise Error, e.message
end
def read(file)
raise Error, 'bad name' unless file =~ FILE_RE
[S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]]
end
end
# Positions stay dense 1..N; the card shows the first SIZE*SIZE, the rest
# wait in reserve. Placing a datacenter inserts it at pos (default: last)
# and shifts everyone after it.
def self.place!(dc, pos = nil)
DB.connection.transaction do
ids = Datacenter.exclude(id: dc.id).order(:position, :id).select_map(:id)
pos = (pos || ids.size + 1).clamp(1, ids.size + 1)
renumber!(ids.insert(pos - 1, dc.id))
end
end
def self.renumber!(ids = Datacenter.order(:position, :id).select_map(:id))
ids.each_with_index { |id, i| Datacenter.where(id: id).update(position: i + 1) }
end
# "64" → 64.0, "25–30" → 30.0, ">40" → 40.0, "13,7" → 13.7, "" → nil
def self.mw_value(mw)
mw.to_s.tr(',', '.').scan(/\d+(?:\.\d+)?/).map(&:to_f).max
end
# Rank by power, unknown last; ties keep their current order.
def self.sort_by_mw!
DB.connection.transaction do
renumber!(Datacenter.order(:position, :id).all
.sort_by.with_index { |dc, i| [-(mw_value(dc.mw) || -1), i] }.map(&:id))
end
end
# First boot: fill the card (and any already-known incidents) from
# db/bingo_seed.json. Never touches rows once any exist; edits happen in
# /admin/bingo.
def self.seed!
return if Datacenter.count.positive?
path = File.join($root, 'db', 'bingo_seed.json')
return unless File.exist?(path)
seed = JSON.parse(File.read(path, encoding: 'UTF-8'))
cols = Datacenter.columns.map(&:to_s) - %w[id created_at]
DB.connection.transaction do
ids = seed.fetch('datacenters').each_with_index.to_h do |row, i|
dc = Datacenter.create(row.slice(*cols).merge('position' => row['position'] || i + 1))
[dc.slug, dc.id]
end
seed.fetch('incidents', []).each do |row|
Incident.create(row.slice(*(Incident.columns.map(&:to_s) - %w[id created_at datacenter_id]))
.merge('datacenter_id' => ids.fetch(row.fetch('slug'))))
end
end
end
end