The card mixes facilities in service with construction projects (Sber Balakovo at #1, MOS7, MOS11, Yandex Vladimir-2), which is fine for a bingo but not when you want to compare what actually runs today. Add a toggle that builds the card from operational rows only, in the same ranking, with bingo lines recomputed for that card. The filtered view is noindex with canonical to the plain locale URL, so it does not compete with the main page in search. Assisted-by: Claude:opus-5
365 lines
16 KiB
Ruby
365 lines
16 KiB
Ruby
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[Б И Н Г О],
|
||
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать'
|
||
},
|
||
'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],
|
||
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show'
|
||
},
|
||
'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[Б І Н Г О],
|
||
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати'
|
||
}
|
||
}.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.
|
||
VIEWS = %w[all operational].freeze
|
||
|
||
# The card: the first SIZE*SIZE datacenters by position, optionally only
|
||
# operational ones (same ranking, construction projects skipped).
|
||
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view) do
|
||
def self.load(view = 'all')
|
||
scope = Datacenter.order(:position, :id)
|
||
scope = scope.where(status: 'operational') if view == 'operational'
|
||
cells = scope.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, view)
|
||
end
|
||
|
||
def operational_only? = view == 'operational'
|
||
|
||
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
|
||
|
||
# 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'
|
||
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)
|
||
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
|
||
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'
|
||
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:
|
||
# 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 = +''.b
|
||
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
|