Initial import of the public datacenter bingo site
Some checks failed
Build and Deploy to Production / test (push) Failing after 21s
Build and Deploy to Production / build (push) Has been skipped
Build and Deploy to Production / deploy (push) Has been skipped

The bingo card ran inside the asxpio container, dispatched by Host.
Give the public site its own repo, image and deploy so it can be
released and mirrored independently of asxp.io.

Editing stays in the asxp.io admin, which keeps owning the schema and
the photo pipeline; this app only reads the shared bingo tables and the
bingo/ photos, through a read-only DB role and MinIO user. Tests load a
snapshot of the asxpio-built schema (test/schema.sql).

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-12 03:48:06 +04:00
commit 062611fa21
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
35 changed files with 2050 additions and 0 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
.env
.gems
.bundle
vendor/bundle
*.log
tmp
flake.nix
flake.lock
CLAUDE.md
README.md
test
Rakefile
bin
.forgejo
.git

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
RACK_ENV=development
BINGO_HOST=localhost
DATABASE_URL=postgres://bingo:change-me@localhost:5432/asxpio
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=asxpio-invoices
S3_ACCESS_KEY=change-me
S3_SECRET_KEY=change-me
INDEXNOW_KEY=

View file

@ -0,0 +1,159 @@
name: Build and Deploy to Production
on:
push:
branches:
- main
jobs:
# Same job as in test.yaml (which covers non-main branches); duplicated here
# so a red suite blocks the build+deploy — Forgejo has no cross-workflow needs.
test:
runs-on: nix-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: bingo_test
steps:
- name: Prepare container for actions
run: |
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
# node for JS actions (checkout); git-minimal is already in the image
nix-env -iA nixpkgs.nodejs_22
- name: Checkout
uses: actions/checkout@v4
- name: Cache gems
uses: actions/cache@v4
with:
path: .gems
key: gems-${{ hashFiles('Gemfile.lock') }}
- name: Wait for Postgres
run: |
for i in $(seq 1 30); do
nix develop -c pg_isready -h postgres -U postgres && exit 0
sleep 2
done
echo "Postgres service never became ready" >&2
exit 1
- name: Run test suite
env:
TEST_DATABASE_URL: postgres://postgres@postgres:5432/bingo_test
run: nix develop -c bundle exec rake test
build:
runs-on: arch-latest
needs: test
container:
image: gcr.io/kaniko-project/executor:debug
outputs:
image_tag: ${{ steps.build_image.outputs.image_tag }}
steps:
- name: Build and push Docker Image using Kaniko
id: build_image
run: |
# Tags
SHORT_SHA=${GITHUB_SHA::8}
IMAGE_BASE=${{ secrets.FJ_REGISTRY }}/${{ secrets.FJ_USER }}/datacenterbingo
IMAGE_TAG=${IMAGE_BASE}:${SHORT_SHA}
IMAGE_LATEST=${IMAGE_BASE}:latest
# Auth Conf
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"${{ secrets.FJ_REGISTRY }}\":{\"auth\":\"$(echo -n ${{ secrets.FJ_USER }}:${{ secrets.FJ_TOKEN }} | base64)\"}}}" > /kaniko/.docker/config.json
# Build & push
/kaniko/executor \
--context=git://${{ secrets.FJ_REGISTRY }}/${{ github.repository }}.git \
--git=branch=${{ github.ref_name }} \
--destination=$IMAGE_TAG \
--destination=$IMAGE_LATEST
# Output the specific tag for deployment
echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
deploy:
runs-on: arch-latest
needs: build
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm nodejs openssh rsync
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy docker-compose.yml
uses: easingthemes/ssh-deploy@v5.1.0
with:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REMOTE_HOST: ${{ secrets.DEPLOY_IP }}
REMOTE_USER: ${{ secrets.DEPLOY_USER }}
SOURCE: "docker-compose.yml"
TARGET: "/opt/datacenterbingo/"
# Read-only DB role and MinIO user (see CLAUDE.md); the asxp.io admin
# owns writes. BINGO_HOST also feeds the Traefik rule via compose.
- name: Deploy .env from secrets
uses: appleboy/ssh-action@v1.2.3
env:
BINGO_DB_PASSWORD: ${{ secrets.BINGO_DB_PASSWORD }}
BINGO_S3_ACCESS_KEY: ${{ secrets.BINGO_S3_ACCESS_KEY }}
BINGO_S3_SECRET_KEY: ${{ secrets.BINGO_S3_SECRET_KEY }}
INDEXNOW: ${{ secrets.INDEXNOW }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: BINGO_DB_PASSWORD,BINGO_S3_ACCESS_KEY,BINGO_S3_SECRET_KEY,INDEXNOW
script_stop: true
script: |
umask 077
mkdir -p /opt/datacenterbingo
cat > /opt/datacenterbingo/.env <<EOF
RACK_ENV=production
BINGO_HOST=datacenterbingo.xyz
DATABASE_URL=postgres://bingo:${BINGO_DB_PASSWORD}@postgres:5432/asxpio
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_BUCKET=asxpio-invoices
S3_ACCESS_KEY=${BINGO_S3_ACCESS_KEY}
S3_SECRET_KEY=${BINGO_S3_SECRET_KEY}
INDEXNOW_KEY=${INDEXNOW}
EOF
chmod 600 /opt/datacenterbingo/.env
- name: Deploy and update container
uses: appleboy/ssh-action@v1.2.3
env:
NEW_IMAGE: ${{ needs.build.outputs.image_tag }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: NEW_IMAGE
script: |
cd /opt/datacenterbingo
sed -i "s|image:.*|image: $NEW_IMAGE|" docker-compose.yml
docker pull $NEW_IMAGE
docker compose up -d
for i in $(seq 1 18); do
status=$(docker inspect --format '{{.State.Health.Status}}' datacenterbingo 2>/dev/null || echo missing)
if [ "$status" = "healthy" ]; then
echo "Deployment successful!"
exit 0
fi
sleep 5
done
echo "datacenterbingo never became healthy (last status: $status)" >&2
docker logs --tail 50 datacenterbingo
exit 1

View file

@ -0,0 +1,56 @@
name: Tests
# main is covered by the test job inside deploy.yaml (which gates the build);
# this workflow covers every other branch.
on:
push:
branches-ignore:
- main
jobs:
test:
runs-on: nix-latest
services:
# CI stand-in for bin/test's ephemeral Postgres: the nix container runs
# as root and initdb refuses to run as root, so the DB lives in a
# service container instead and the suite connects via TEST_DATABASE_URL.
# Job and service share the per-workflow bridge network (the runner's
# container.network must stay "" — with "host" the service network is
# never created and services fail to start).
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: bingo_test
steps:
- name: Prepare container for actions
run: |
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
# node for JS actions (checkout); git-minimal is already in the image
nix-env -iA nixpkgs.nodejs_22
- name: Checkout
uses: actions/checkout@v4
# The nix closure persists in the runner's nix-store volume; gems live
# in the workspace (.gems via the flake's BUNDLE_PATH), so cache those.
- name: Cache gems
uses: actions/cache@v4
with:
path: .gems
key: gems-${{ hashFiles('Gemfile.lock') }}
- name: Wait for Postgres
run: |
for i in $(seq 1 30); do
nix develop -c pg_isready -h postgres -U postgres && exit 0
sleep 2
done
echo "Postgres service never became ready" >&2
exit 1
- name: Run test suite
env:
TEST_DATABASE_URL: postgres://postgres@postgres:5432/bingo_test
run: nix develop -c bundle exec rake test

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
.gems/
.bundle/
vendor/bundle/
*.log
tmp/

64
CLAUDE.md Normal file
View file

@ -0,0 +1,64 @@
# datacenterbingo
Public site for datacenterbingo.xyz: a 5×5 bingo card of Russia's largest
datacenters, crossed out on publicly sourced incidents. Ruby/Sinatra,
read-only. Primary repo `git.czsk.it/asxpi/datacenterbingo` (Forgejo, CI and
deploy); `github.com/asxpi/datacenterbingo` is a push mirror.
## Shared with asxpio
Content is edited in the asxp.io admin (`/admin/bingo`, repo `asxpio`:
`lib/bingo.rb`, `lib/bingo_admin.rb`). Both apps use the same Postgres tables
(`bingo_datacenters`, `bingo_incidents`) and the photos under `bingo/` in
asxpio's MinIO bucket.
- **asxpio owns the schema.** Migrations live there. After it adds a
bingo migration, refresh `test/schema.sql` (instructions in its header)
and update the models here.
- `lib/bingo.rb` here is the read side of asxpio's: models, `Board`,
`PROVIDERS`, the `T` UI strings, and photo/IndexNow key reading. The
admin side (writes, photo upload and resizing, IndexNow pings, ranking,
seeding, `guess_provider`) stays in asxpio. Keep shared parts in step:
`PROVIDERS`, `Board`, and model methods.
- Prod uses a read-only DB role `bingo` (SELECT on the two tables) and a
MinIO user limited to `s3:GetObject` on `asxpio-invoices/bingo/*`. Both
are provisioned by the `storage` repo (`init/`), from the same
`BINGO_*` secrets set on both repos.
## Layout
- `app.rb`: `BingoWeb`. Routes: `/` (redirect by Accept-Language), `/ru|en|uk`
(card; `?view=all`, `?p[]=provider`), `/:locale/dc/:slug`, `/sitemap.xml`,
`/robots.txt`, `/p/:file` (photo proxy, immutable), `/<indexnow-key>.txt`,
icons, `/healthz`.
- `config.ru`: `www.` → `BINGO_HOST` 301.
- `views/`: `layout.erb` (CSS and SEO head), `index.erb` (card, provider
filter, dialogs), `dc.erb`, partials. `assets/`: icons.
- Card rules: always 5×5. The bare locale URL is the operational card
(indexed). `?view=all` and provider-filtered URLs are noindex, with
canonical pointing to the bare page.
## Dev and tests
`nix develop`, then `bin/test`. That provisions an ephemeral Postgres and
loads `test/schema.sql`. Run `cp .env.example .env` for a local server
(`rackup`), pointing it at a DB that has the bingo tables.
## Deploy
On push to `main`, `.forgejo/workflows/deploy.yaml` tests, builds with kaniko
to `$FJ_REGISTRY/$FJ_USER/datacenterbingo`, then over SSH writes
`/opt/datacenterbingo/{docker-compose.yml,.env}` and runs
`docker compose up -d`. The container is on the `traefik` and `storage`
networks, and the Traefik router `datacenterbingo` takes `BINGO_HOST` and
`www.`.
Secrets:
| Secret | Purpose |
|---|---|
| `FJ_REGISTRY`, `FJ_USER`, `FJ_TOKEN` | kaniko clone + push (`write:package`, `read:repository`); Forgejo reserves the `FORGEJO_` prefix |
| `DEPLOY_IP`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` | SSH to prod |
| `BINGO_DB_PASSWORD` | read-only Postgres role `bingo` |
| `BINGO_S3_ACCESS_KEY`, `BINGO_S3_SECRET_KEY` | read-only MinIO user |
| `INDEXNOW` | same key as asxpio's `INDEXNOW` (served here, pinged from there) |

32
Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM ruby:3.4-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 11000 bingo \
&& useradd -d /app -u 21000 -g 11000 -m -s /bin/bash bingo
WORKDIR /app
USER bingo
ENV GEM_HOME=/app/bundle
ENV PATH="${GEM_HOME}/bin:${PATH}"
COPY --chown=bingo Gemfile Gemfile.lock* ./
ENV BUNDLE_FROZEN=true
RUN bundle install --without development:test
COPY --chown=bingo . .
ENV PUMA_PORT=3000
ENV PUMA_THREADS="4:16"
ENV RACK_ENV=production
EXPOSE 3000
# Slim image has no curl; ruby is right there.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ruby -rnet/http -e 'port = ENV.fetch("PUMA_PORT", "3000"); exit(Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/healthz")).code == "200" ? 0 : 1)'
CMD ["sh", "-c", "exec puma -b tcp://0.0.0.0:${PUMA_PORT} -t ${PUMA_THREADS} --preload"]

27
Gemfile Normal file
View file

@ -0,0 +1,27 @@
source 'https://rubygems.org'
ruby '>= 3.1'
gem 'sinatra', '~> 4.2'
gem 'puma', '~> 6.6'
gem 'rack', '~> 3.2'
gem 'rack-protection', '~> 4.2'
gem 'rackup', '~> 2.3'
gem 'erubi', '~> 1.13'
gem 'dotenv', '~> 3.1'
gem 'pg', '~> 1.5'
gem 'sequel', '~> 5.86'
gem 'aws-sdk-s3', '~> 1.170'
# aws-sdk-core needs an XML parser; rexml stopped being a default gem in
# Ruby 3.4, so it must be declared or S3 calls raise at first use.
gem 'rexml', '~> 3.4'
group :development do
gem 'rerun', '~> 0.14'
end
group :test do
gem 'minitest', '~> 5.25'
gem 'rack-test', '~> 2.2'
gem 'rake', '~> 13.2'
end

120
Gemfile.lock Normal file
View file

@ -0,0 +1,120 @@
GEM
remote: https://rubygems.org/
specs:
aws-eventstream (1.4.0)
aws-partitions (1.1253.0)
aws-sdk-core (3.249.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.128.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.224.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bigdecimal (3.3.1)
dotenv (3.2.0)
erubi (1.13.1)
ffi (1.17.4)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86-linux-gnu)
ffi (1.17.4-x86-linux-musl)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
jmespath (1.6.2)
listen (3.10.0)
logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
minitest (5.27.0)
mustermann (3.1.1)
nio4r (2.7.5)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
puma (6.6.1)
nio4r (~> 2.0)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rake (13.4.2)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rerun (0.14.0)
listen (~> 3.0)
rexml (3.4.4)
sequel (5.104.0)
bigdecimal
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
tilt (2.7.0)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
aws-sdk-s3 (~> 1.170)
dotenv (~> 3.1)
erubi (~> 1.13)
minitest (~> 5.25)
pg (~> 1.5)
puma (~> 6.6)
rack (~> 3.2)
rack-protection (~> 4.2)
rack-test (~> 2.2)
rackup (~> 2.3)
rake (~> 13.2)
rerun (~> 0.14)
rexml (~> 3.4)
sequel (~> 5.86)
sinatra (~> 4.2)
RUBY VERSION
ruby 3.4.9p82
BUNDLED WITH
2.6.9

16
README.md Normal file
View file

@ -0,0 +1,16 @@
# Datacenter Bingo
https://datacenterbingo.xyz: a 5×5 bingo card of Russia's largest
datacenters. A cell is crossed out once a drone strike, fire or outage
there is reported by a public source. Available in Ukrainian, English and
Russian.
This repo is the public site (Ruby/Sinatra). Content is managed from a
separate admin app that shares the database.
Data comes from open sources. It was collected and verified with the help
of Claude (Anthropic). Part of the list came from the
[hackyourmom datacenter map](https://hackyourmom.com/en/karta-data-czentriv-na-rasii/)
(data by Cat Eyes OSINT), and each entry was re-verified.
Development: `nix develop`, then `bin/test`. See `CLAUDE.md`.

9
Rakefile Normal file
View file

@ -0,0 +1,9 @@
require 'rake/testtask'
Rake::TestTask.new(:test) do |t|
t.libs << 'test'
t.pattern = 'test/*_test.rb'
t.warning = false
end
task default: :test

264
app.rb Normal file
View file

@ -0,0 +1,264 @@
require 'logger'
require 'json'
require 'sinatra/base'
require 'erubi'
begin
require 'dotenv'
Dotenv.load unless ENV['RACK_ENV'] == 'test'
rescue LoadError
end
$root = __dir__
$logger = Logger.new($stdout)
$env = ENV.fetch('RACK_ENV', 'development')
require_relative 'lib/db'
if ENV['DATABASE_URL']
DB.connect!
require_relative 'lib/bingo'
else
$logger.warn('DATABASE_URL not set — every page answers 503')
end
# Public datacenter bingo on BINGO_HOST. Read-only: no session, no CSRF.
# Content is managed from asxp.io/admin/bingo (asxpio repo), which shares the
# database and photo bucket.
class BingoWeb < Sinatra::Base
HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze
configure do
set :root, $root
set :erb, layout: :layout, escape_html: true
set :static, false # icons have explicit routes below
set :show_exceptions, $env == 'development'
# HSTS (1 year, this host only); http:// already 308s at the edge.
set :protection, use: %i[strict_transport]
set :host_authorization,
{ permitted_hosts: $env == 'production' ? [*HOSTS, '127.0.0.1'] : [] } # 127.0.0.1: HEALTHCHECK
end
helpers do
def t(key) = Bingo::T[@locale][key]
def kind_label(kind) = Bingo::T[@locale][:kinds][kind] || Bingo::T[@locale][:kinds]['other']
def kind_icon(kind) = Bingo::ICONS[kind] || Bingo::ICONS['other']
def photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}"
# Path for a locale/view combination; the default view (operational)
# keeps a bare URL, the full list is ?view=all.
# Path for a locale/view/provider selection; the default (operational,
# every provider) keeps a bare URL.
def card_path(locale = @locale, view = @view, providers = @providers)
q = []
q << 'view=all' if view == 'all'
Array(providers).each { |p| q << "p[]=#{Rack::Utils.escape(p)}" }
q.empty? ? "/#{locale}" : "/#{locale}?#{q.join('&')}"
end
def selected_providers
Array(params[:p]).flat_map { |v| v.to_s.split(',') }.select { |p| Bingo::PROVIDERS.key?(p) }.uniq
end
def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}"
def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}"
# WebSite + ItemList of the card's datacenters, for rich results.
def json_ld(board, desc)
url = "#{base_url}/#{@locale}"
JSON.generate(
'@context' => 'https://schema.org',
'@graph' => [
{ '@type' => 'WebSite', '@id' => "#{base_url}/#website", 'url' => "#{base_url}/",
'name' => t(:title), 'inLanguage' => Bingo::LOCALES,
'publisher' => { '@type' => 'Person', 'name' => 'asxpi', 'url' => 'https://asxp.io' } },
{ '@type' => 'ItemList', 'url' => url, 'name' => t(:seo_title), 'description' => desc,
'inLanguage' => @locale, 'numberOfItems' => board.cells.size,
'dateModified' => board.updated_on&.utc&.iso8601,
'itemListElement' => board.cells.each_with_index.map do |dc, i|
{ '@type' => 'ListItem', 'position' => i + 1, 'item' => ld_place(dc) }
end }.compact
]
).gsub('</', '<\/')
end
# The info_url describes the datacenter (operator page, news), it isn't
# the datacenter's own identity, so subjectOf rather than sameAs.
# Incidents are Events at the place.
def ld_place(dc)
address = { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' }
url = "#{base_url}#{dc_path(dc)}"
place = { '@type' => 'Place', '@id' => "#{url}#place", 'url' => url, 'name' => dc.l(:name, @locale), 'address' => address }
place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key
place['subjectOf'] = { '@type' => 'CreativeWork', 'url' => dc.info_url } if dc.info_url
events = dc.incidents.map do |i|
{ '@type' => 'Event', 'name' => "#{kind_label(i.kind)}: #{i.l(:title, @locale)}",
'startDate' => i.occurred_on.iso8601, 'url' => i.url,
'location' => { '@type' => 'Place', 'name' => place['name'], 'address' => address } }.compact
end
place['event'] = events if events.any?
place
end
# Datacenter page: the Place plus a breadcrumb back to the card.
def dc_json_ld(dc)
JSON.generate(
'@context' => 'https://schema.org',
'@graph' => [
ld_place(dc),
{ '@type' => 'BreadcrumbList', 'itemListElement' => [
{ '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" },
{ '@type' => 'ListItem', 'position' => 2, 'name' => dc.l(:name, @locale), 'item' => "#{base_url}#{dc_path(dc)}" }
] }
]
).gsub('</', '<\/')
end
# Plain-language facts for the datacenter page and its meta description.
def dc_facts(dc)
[format(t(:dc_intro), name: dc.l(:name, @locale), city: dc.l(:city, @locale), operator: dc.operator),
(format(t(:dc_capacity), mw: mw_prose(dc)) if mw(dc)),
{ 'building' => t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)),
format(t(:dc_rank), pos: dc.position)].compact
end
def dc_latest(dc)
i = dc.incidents.first or return t(:no_incidents)
format(t(:dc_latest), date: fmt_date(i.occurred_on), title: i.l(:title, @locale))
end
# Cut at a word boundary to fit a search snippet.
def snippet(text, max = 160)
text.length <= max ? text : "#{text[0, max - 1][/\A.*(?=\s)/m] || text[0, max - 1]}…"
end
# Preview image: the most recent hit with a photo, else the first photo.
def og_dc(board)
board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
end
def fmt_date(d) = d.strftime('%d.%m.%Y')
# "64 MW", "64 MW · design", "17 MVA" (MVA is a unit, not a tag).
def mw(dc, tag: true)
return if dc.mw.to_s.strip.empty?
return "#{dc.mw} #{t(:mva)}" if dc.mw_type == 'mva'
tag &&= t(:mw_types)[dc.mw_type]
["#{dc.mw} #{t(:mw)}", tag || nil].compact.join(' · ')
end
# "63 MW (design)": the dialog's " · design" tag reads badly in a sentence.
def mw_prose(dc)
tag = dc.mw_type != 'mva' && t(:mw_types)[dc.mw_type]
tag ? "#{mw(dc, tag: false)} (#{tag})" : mw(dc)
end
# Badge for anything not yet in service.
def status_label(dc)
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
end
def preferred_locale
langs = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.downcase.scan(/([a-z]{2})(?:-[a-z]+)?(?:;q=([\d.]+))?/)
langs.sort_by { |_, q| -(q || '1').to_f }.map(&:first).find { |l| Bingo::LOCALES.include?(l) } || 'en'
end
end
get '/healthz' do
halt 503, 'no database' unless defined?(Bingo::Datacenter)
DB.connection.test_connection
'ok'
end
before do
halt 503, 'Bingo not configured (DATABASE_URL missing).' unless defined?(Bingo::Datacenter)
end
get '/' do
headers 'Vary' => 'Accept-Language'
redirect "/#{preferred_locale}", 302
end
get '/robots.txt' do
content_type 'text/plain'
"User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n"
end
# The card and every datacenter page (reserve included), each with its
# language alternates.
get '/sitemap.xml' do
content_type 'application/xml'
cache_control :public, max_age: 3600
dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
@entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] +
dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] }
erb :sitemap, layout: false
end
# One URL per page: /en/ is a duplicate of /en.
get %r{/((?:ru|en|uk)(?:/dc/[a-z0-9-]+)?)/} do |path|
qs = request.query_string
redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301
end
get %r{/(ru|en|uk)} do |locale|
@locale = locale
@view = params[:view] == 'all' ? 'all' : Bingo::VIEWS.first
@providers = selected_providers
@board = Bingo::Board.load(@view, providers: @providers)
@title = t(:seo_title)
@desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size)
@noindex = !@board.operational_only? || @board.filtered? # variants of the bare page
@path_for = ->(l) { "/#{l}" }
@x_default = '/'
@og = og_dc(@board)
@ld = json_ld(@board, @desc)
cache_control :public, max_age: 60
erb :index
end
get %r{/(ru|en|uk)/dc/([a-z0-9-]+)} do |locale, slug|
@locale = locale
@dc = Bingo::Datacenter.where(slug: slug).eager(:incidents).first or halt 404
@prev, @next = [-1, 1].map { |d| Bingo::Datacenter.first(position: @dc.position + d) }
name, city = @dc.l(:name, locale), @dc.l(:city, locale)
hit = @dc.incidents.first
@title = hit ? format(t(:dc_title_hit), name: name, city: city, kind: kind_label(hit.kind), date: fmt_date(hit.occurred_on))
: format(t(:dc_title), name: name, city: city)
@desc = snippet([*dc_facts(@dc).first(2), dc_latest(@dc)].join(' '))
@path_for = ->(l) { dc_path(@dc, l) }
@og = @dc if @dc.photo_key
@ld = dc_json_ld(@dc)
cache_control :public, max_age: 60
erb :dc
end
# IndexNow key file: proves to the engines that pings come from us.
get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name|
halt 404 unless name == Bingo::IndexNow.key
content_type 'text/plain'
name
end
# Icons from assets/. Search engines want
# a crawlable favicon URL; a data: URI doesn't count.
ICONS = { 'favicon.svg' => 'image/svg+xml', 'favicon.ico' => 'image/x-icon',
'apple-touch-icon.png' => 'image/png' }.freeze
get %r{/(favicon\.svg|favicon\.ico|apple-touch-icon\.png)} do |name|
cache_control :public, max_age: 86_400
send_file File.join(settings.root, 'assets', name), type: ICONS[name]
end
# Photos are immutable per key (a new upload gets a new key).
get '/p/:file' do
bytes, type = Bingo::Photo.read(params[:file])
content_type type
cache_control :public, :immutable, max_age: 31_536_000
bytes
rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
halt 404
end
not_found do
'Not found'
end
end

BIN
assets/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
assets/favicon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#121212"/><rect x="5" y="5" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="16" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="5" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="16" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="27" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="16" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="27" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="38" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="27" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="38" width="10" height="10" rx="1.5" fill="#ff6a4d"/><rect x="49" y="38" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="5" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="16" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="27" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="38" y="49" width="10" height="10" rx="1.5" fill="#ece7dc"/><rect x="49" y="49" width="10" height="10" rx="1.5" fill="#ff6a4d"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1 @@
img

19
bin/test Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Full test suite against an ephemeral Postgres. Run inside `nix develop`
# (needs initdb/pg_ctl from the dev shell). Without a database you can still
# run `bundle exec rake test` — DB-backed tests skip.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${TEST_PG_PORT:-54329}"
TMP="$(mktemp -d)"
trap 'pg_ctl -D "$TMP/pg" stop -m immediate >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
initdb -D "$TMP/pg" -A trust -U postgres >/dev/null
printf "unix_socket_directories = ''\nlisten_addresses = '127.0.0.1'\nport = %s\n" "$PORT" \
>> "$TMP/pg/postgresql.conf"
pg_ctl -D "$TMP/pg" -l "$TMP/pg.log" start >/dev/null
createdb -h 127.0.0.1 -p "$PORT" -U postgres bingo_test
TEST_DATABASE_URL="postgres://postgres@127.0.0.1:$PORT/bingo_test" \
bundle exec rake test "$@"

14
config.ru Normal file
View file

@ -0,0 +1,14 @@
require_relative 'app'
# www. redirects to the canonical BINGO_HOST; everything else is the card.
bingo_host = ENV['BINGO_HOST']
bingo = BingoWeb.new
run lambda { |env|
req = Rack::Request.new(env)
if bingo_host && req.host == "www.#{bingo_host}"
[301, { 'location' => "https://#{bingo_host}#{req.fullpath}" }, []]
else
bingo.call(env)
end
}

27
docker-compose.yml Normal file
View file

@ -0,0 +1,27 @@
services:
bingo:
# Image is set by the deploy workflow (registry/user/datacenterbingo:sha).
build: .
image: datacenterbingo:latest
container_name: datacenterbingo
restart: unless-stopped
env_file: .env
networks:
- traefik
- storage # postgres + minio
labels:
- "traefik.enable=true"
# Priority beats the old asxpio-bingo router while both exist.
- "traefik.http.routers.datacenterbingo.rule=Host(`${BINGO_HOST}`) || Host(`www.${BINGO_HOST}`)"
- "traefik.http.routers.datacenterbingo.priority=1000"
- "traefik.http.routers.datacenterbingo.entrypoints=websecure"
- "traefik.http.routers.datacenterbingo.tls=true"
- "traefik.http.routers.datacenterbingo.tls.certresolver=letsencrypt"
- "traefik.http.routers.datacenterbingo.service=datacenterbingo"
- "traefik.http.services.datacenterbingo.loadbalancer.server.port=3000"
networks:
traefik:
external: true
storage:
external: true

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777578337,
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

46
flake.nix Normal file
View file

@ -0,0 +1,46 @@
{
description = "Datacenter bingo (datacenterbingo.xyz)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
pkgs.ruby_3_4
pkgs.bundler
pkgs.openssl
pkgs.zlib
pkgs.libyaml
pkgs.postgresql.lib
pkgs.postgresql
];
shellHook = ''
export GEM_HOME="$PWD/.gems"
export PATH="$GEM_HOME/bin:$PATH"
export BUNDLE_PATH="$GEM_HOME"
if [ -f .env ]; then
set -a
source .env
set +a
fi
if [ ! -f .gems/.bundled ] || [ Gemfile -nt .gems/.bundled ]; then
echo "Installing gems..."
bundle install --quiet && mkdir -p .gems && touch .gems/.bundled
fi
echo "bingo dev shell ready"
echo " bundle exec rerun -- rackup -p 3000 - start with auto-reload"
'';
};
};
}

267
lib/bingo.rb Normal file
View file

@ -0,0 +1,267 @@
require 'sequel'
require 'json'
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.
#
# Read side only. The tables live in the asxpio database and are written by
# the asxp.io admin (asxpio repo: lib/bingo.rb, lib/bingo_admin.rb, which also
# owns the migrations). Keep the models and Board here in step with 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
STATUSES = %w[operational building planned].freeze
# Providers (companies) for the per-provider cards: slug => name per locale.
PROVIDERS = {
'yandex' => %w[Яндекс Yandex Яндекс], 'ixcellerate' => %w[IXcellerate] * 3,
'rtk' => %w[РТК-ЦОД RTK-DC РТК-ЦОД], 'atomdata' => ['Атомдата (Росатом)', 'Atomdata (Rosatom)', 'Атомдата (Росатом)'],
'datapro' => %w[DataPro] * 3, 'selectel' => %w[Selectel] * 3, 'mws' => ['MWS / МТС', 'MWS / MTS', 'MWS / МТС'],
'keypoint' => ['Key Point'] * 3, '3data' => %w[3data] * 3, 'sber' => %w[Сбер Sber Сбер],
'megafon' => %w[МегаФон MegaFon МегаФон], 'datahouse' => %w[DataHouse] * 3, 'oxygen' => %w[OXYGEN] * 3,
'linx' => %w[Linx] * 3, 'cloudx' => ['Cloud X'] * 3, 'nkgroup' => ['NK Group'] * 3, 'goznak' => %w[Гознак Goznak Гознак],
'dataspace' => %w[DataSpace] * 3, 'cloudru' => %w[Cloud.ru] * 3, 'x5' => ['X5 Group'] * 3, 'krok' => %w[КРОК CROC КРОК],
'stack' => ['Stack Group'] * 3, 'beeline' => %w[Билайн Beeline Білайн]
}.transform_values { |names| %w[ru en uk].zip(names).to_h }.freeze
def self.provider_name(slug, locale) = PROVIDERS.dig(slug, locale) || slug
# 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: 'Бинго',
tagline: 'Атаки БПЛА, пожары и аварии в 25 крупнейших дата-центрах России',
desc: 'Рейтинг по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.',
bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.',
planned: 'планируется', mva: 'МВА',
mw_types: { 'it' => 'ИТ', 'connected' => 'подв.', 'total' => 'общ.', 'design' => 'проект' },
building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник',
close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.',
credits_ai: 'Информация собрана и проверена с помощью Claude (Anthropic).',
credits_hym: 'Спасибо %{link} (данные Cat Eyes OSINT) за часть списка: каждая запись перепроверена по сайтам операторов и прессе.',
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
letters: %w[Б И Н Г О],
providers_label: 'Собрать карточку из провайдеров', providers_apply: 'Показать', providers_reset: 'Все провайдеры',
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать',
dc_title: '%{name} (%{city}) — инциденты ЦОДа | Бинго ЦОДов',
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бинго ЦОДов',
dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.',
dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.',
dc_since: 'Работает с %d года.',
dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.',
dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты',
back: '← К карточке', dc_page: 'Страница ЦОДа →'
},
'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',
tagline: "Drone strikes, fires and outages at Russia's 25 largest datacenters",
desc: "Ranked 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.',
planned: 'planned', mva: 'MVA',
mw_types: { 'it' => 'IT', 'connected' => 'connected', 'total' => 'total', 'design' => 'design' },
building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source',
close: 'Close', no_incidents: 'No incidents recorded.',
credits_ai: 'Information collected and verified with the help of Claude (Anthropic).',
credits_hym: 'Thanks to %{link} (data by Cat Eyes OSINT) for part of the list; every entry was re-verified against operator sites and the press.',
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],
providers_label: 'Build a card from providers', providers_apply: 'Show', providers_reset: 'All providers',
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show',
dc_title: '%{name} (%{city}) — datacenter incidents | Datacenter Bingo',
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Datacenter Bingo',
dc_intro: '%{name} is a datacenter in %{city} operated by %{operator}.',
dc_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.',
dc_since: 'In service since %d.',
dc_rank: "No. %{pos} by power among Russia's largest datacenters.",
dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents',
back: '← Back to the card', dc_page: 'Datacenter page →'
},
'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.',
og_locale: 'uk_UA',
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
tagline: 'Атаки БПЛА, пожежі та аварії у 25 найбільших дата-центрах Росії',
desc: 'Рейтинг за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.',
bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.',
planned: 'планується', mva: 'МВА',
mw_types: { 'it' => 'ІТ', 'connected' => 'підв.', 'total' => 'заг.', 'design' => 'проєкт' },
building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело',
close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.',
credits_ai: 'Інформацію зібрано й перевірено за допомогою Claude (Anthropic).',
credits_hym: 'Дякуємо %{link} (дані Cat Eyes OSINT) за частину списку: кожен запис перевірено за сайтами операторів і пресою.',
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
letters: %w[Б І Н Г О],
providers_label: 'Зібрати картку з провайдерів', providers_apply: 'Показати', providers_reset: 'Усі провайдери',
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати',
dc_title: '%{name} (%{city}) — інциденти ЦОДу | Бінго ЦОДів',
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бінго ЦОДів',
dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.',
dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.',
dc_since: 'Працює з %d року.',
dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.',
dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти',
back: '← До картки', dc_page: 'Сторінка ЦОДу →'
}
}.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 before_save
self.updated_at = Time.now.utc
super
end
def operational? = status == 'operational'
def planned? = status == 'planned'
def building?
status == 'building'
end
# width: a resized variant (480, 1200) (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
# Paragraphs of the datacenter page's prose in exactly this locale.
def about(locale)
self[:"about_#{locale}"].to_s.strip.split(/\n\s*\n/).map(&:strip)
end
# 1200x630 JPEG for og:image (every preview client renders it); the
# webp dialog variant until it exists.
def og_file
return photo_file(1200) unless photo_key && photo_og
Photo.og_key(photo_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
def before_save
self.updated_at = Time.now.utc
super
end
end
# Board state for rendering: ordered cells, hits and completed lines.
VIEWS = %w[operational all].freeze # first is the default (bare URL)
# The card is always 5×5: the first SIZE*SIZE datacenters by position,
# optionally only operational ones and/or only the chosen providers. A
# selection with fewer datacenters leaves the remaining cells empty.
Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view, :providers) do
def self.load(view = 'all', providers: nil)
scope = Datacenter.order(:position, :id)
scope = scope.where(status: 'operational') if view == 'operational'
scope = scope.where(provider: providers) if providers&.any?
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(:updated_at), Incident.max(:updated_at)].compact.max
new(cells, hit, wins, incidents, updated, view, providers)
end
def filtered? = providers&.any? || false
# [[slug, total, crossed]] for every provider with datacenters in this view.
def self.providers(view)
scope = Datacenter.exclude(provider: nil)
scope = scope.where(status: 'operational') if view == 'operational'
scope.eager(:incidents).all.group_by(&:provider)
.map { |slug, dcs| [slug, dcs.size, dcs.count { |d| d.incidents.any? }] }
.sort_by { |slug, n, x| [-x, -n, slug] }
end
def operational_only? = view == 'operational'
def win_cells
@win_cells ||= wins.flatten.to_set
end
end
# Photos live in the asxpio MinIO bucket under bingo/ (uploaded and resized
# by the asxp.io admin) and are proxied here, so the bucket stays private.
module Photo
PREFIX = 'bingo/'.freeze
OG_SIZE = [1200, 630].freeze # og:image, cropped to the 1.91:1 card
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200|og))?\.(jpg|png|webp)\z/
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
class Error < StandardError; end
module_function
def variant_key(key, width)
key.sub(/\.\w+\z/, "-#{width}.webp")
end
def og_key(key)
key.sub(/\.\w+\z/, '-og.jpg')
end
def read(file)
raise Error, 'bad name' unless file =~ FILE_RE
[S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]]
end
end
# IndexNow pings are sent by the asxp.io admin; this site only serves the
# key file at /<key>.txt to prove they come from us.
module IndexNow
module_function
def key = ENV['INDEXNOW_KEY'].to_s[/\A[A-Za-z0-9-]{8,128}\z/]
end
end

23
lib/db.rb Normal file
View file

@ -0,0 +1,23 @@
require 'sequel'
# The schema belongs to asxpio (its db/migrations); this app only reads.
module DB
module_function
def connect!
# Every timestamp is written as Time.now.utc; read them back as UTC too
# (otherwise a host in UTC+4 sees a fresh 1 h token as already expired).
Sequel.default_timezone = :utc
@connection ||= Sequel.connect(
ENV.fetch('DATABASE_URL'),
max_connections: 5,
logger: ($env == 'development' ? $logger : nil)
)
end
def connection
@connection or raise 'DB not connected — call DB.connect! first'
end
end

20
lib/s3.rb Normal file
View file

@ -0,0 +1,20 @@
require 'aws-sdk-s3'
# Read-only access to the photos in the asxpio bucket.
module S3
module_function
def client
@client ||= Aws::S3::Client.new(
region: ENV.fetch('S3_REGION', 'us-east-1'),
endpoint: ENV.fetch('S3_ENDPOINT'),
access_key_id: ENV.fetch('S3_ACCESS_KEY'),
secret_access_key: ENV.fetch('S3_SECRET_KEY'),
force_path_style: true
)
end
def get(key)
client.get_object(bucket: ENV.fetch('S3_BUCKET'), key: key).body.read.b # body is a UTF-8 StringIO
end
end

356
test/bingo_test.rb Normal file
View file

@ -0,0 +1,356 @@
require_relative 'test_helper'
class BingoTest < Minitest::Test
include Rack::Test::Methods
# Minimal well-formed JPEG: SOI, start-of-scan, data, EOI.
JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze
def app = BingoWeb
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
DB.connection[:bingo_incidents].delete
DB.connection[:bingo_datacenters].delete
@dcs = (1..25).map do |n|
Bingo::Datacenter.create(slug: "dc-#{n}", position: n, name_ru: "ЦОД #{n}", name_en: ("DC #{n}" if n == 1),
city_ru: 'Москва', city_en: 'Moscow', operator: 'Op', mw: n.to_s)
end
end
def hit!(idx, kind: 'outage')
Bingo::Incident.create(datacenter_id: @dcs[idx].id, occurred_on: Date.today, kind: kind,
title_ru: "Авария #{idx}", title_uk: "Аварія #{idx}")
end
def test_root_redirects_by_accept_language
get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'uk-UA,uk;q=0.9,en;q=0.8'
assert_equal 302, last_response.status
assert_match %r{/uk\z}, last_response.location
get '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'de-DE'
assert_match %r{/en\z}, last_response.location
end
def test_locales_render_with_ru_fallback
get '/en'
assert last_response.ok?
assert_includes last_response.body, 'DC 1' # localized
assert_includes last_response.body, 'ЦОД 2' # falls back to ru
assert_includes last_response.body, 'Moscow'
get '/uk'
assert_includes last_response.body, 'Бінго'
assert_includes last_response.body, '<a href="https://asxp.io">asxp.io</a>'
get '/xx'
assert_equal 404, last_response.status
end
def test_board_detects_lines
(0..4).each { |i| hit!(i) } # top row
[6, 12, 18, 24].each { |i| hit!(i) } # + main diagonal (0 already hit)
board = Bingo::Board.load
assert_equal 2, board.wins.size
get '/ru'
assert_includes last_response.body, 'БИНГО ×2!'
assert_includes last_response.body, 'Авария 24'
get '/uk'
assert_includes last_response.body, 'Аварія 24'
end
def test_no_banner_without_line
hit!(0)
get '/ru'
refute_includes last_response.body, 'class="banner"'
assert_includes last_response.body, 'Авария 0'
end
def test_operational_view_skips_construction_projects
@dcs[0].update(status: 'building')
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op', mw: '1')
all = Bingo::Board.load
op = Bingo::Board.load('operational')
assert_includes all.cells.map(&:slug), 'dc-1'
refute_includes op.cells.map(&:slug), 'dc-1'
assert_equal 'dc-26', op.cells.last.slug # reserve moves up to fill the card
assert_equal 25, op.cells.size
end
def test_operational_view_recomputes_lines
(1..4).each { |i| hit!(i) } # row 1 minus dc-1
@dcs[0].update(status: 'building')
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op')
assert_equal 0, Bingo::Board.load.wins.size # dc-1 not hit
hit!(5) # dc-6 now sits at index 4 in operational view
assert_equal 1, Bingo::Board.load('operational').wins.size
end
def test_default_view_is_operational_and_all_is_a_noindex_variant
@dcs[0].update(status: 'building')
get '/ru'
assert last_response.ok?
refute_includes last_response.body, 'ЦОД 1<' # construction project hidden by default
assert_includes last_response.body, 'index, follow, max-image-preview'
assert_includes last_response.body, '<a href="/ru" aria-current="page">'
get '/ru?view=all'
assert last_response.ok?
assert_includes last_response.body, 'ЦОД 1<'
assert_includes last_response.body, 'noindex, follow'
assert_includes last_response.body, 'href="/en?view=all"' # language links keep the view
assert_includes last_response.body, '<a href="/ru?view=all" aria-current="page">'
assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"'
get '/ru?view=operational' # old links still land on the default
assert_includes last_response.body, 'index, follow, max-image-preview'
end
def test_capacity_type_and_planned_status
@dcs[0].update(mw: '64', mw_type: 'design', status: 'planned')
@dcs[1].update(mw: '17', mw_type: 'mva')
get '/en?view=all' # planned sites only appear in the full list
body = last_response.body
assert_includes body, '64 MW · design' # dialog
assert_includes body, '#1 · 64 MW</div>' # cell: no tag
assert_includes body, '17 MVA'
assert_includes body, '#2 · 17 MVA</div>' # MVA replaces the unit in cells too
assert_includes body, '>planned<'
refute_includes Bingo::Board.load('operational').cells.map(&:slug), 'dc-1'
end
def test_provider_filter_keeps_a_5x5_card
@dcs.first(3).each { |d| d.update(provider: 'yandex') }
@dcs[3].update(provider: 'rtk')
hit!(0)
board = Bingo::Board.load('operational', providers: %w[yandex rtk])
assert_equal %w[dc-1 dc-2 dc-3 dc-4], board.cells.map(&:slug)
get '/ru?p[]=yandex&p[]=rtk&p[]=bogus'
assert last_response.ok?
body = last_response.body
assert_equal 4, body.scan(/class="cell(?! empty)[ "]/).size # the four selected datacenters
assert_equal 21, body.scan('class="cell empty"').size
assert_includes body, 'noindex, follow'
assert_includes body, 'rel="canonical" href="https://example.org/ru"'
assert_includes body, 'href="/en?p[]=yandex&amp;p[]=rtk"' # language links keep the selection
assert_includes body, 'name="p[]" value="yandex" checked'
refute_includes body, 'ЦОД 5<' # unselected provider
get '/ru?p=yandex,rtk' # comma form works too
assert_equal 21, last_response.body.scan('class="cell empty"').size
end
def test_filtered_card_still_scores_lines
@dcs.first(5).each { |d| d.update(provider: 'ixcellerate') }
(0..4).each { |i| hit!(i) }
assert_equal 1, Bingo::Board.load('operational', providers: ['ixcellerate']).wins.size
end
def test_footer_credits_in_every_language
{ 'ru' => 'с помощью Claude', 'en' => 'with the help of Claude', 'uk' => 'за допомогою Claude' }.each do |l, text|
get "/#{l}"
assert_includes last_response.body, text
assert_includes last_response.body, '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>'
end
get "/en/dc/#{@dcs[0].slug}"
assert_includes last_response.body, 'hackyourmom</a>'
end
def test_robots_and_sitemap_are_the_bingos_own
get '/robots.txt'
assert last_response.ok?
assert_includes last_response.body, 'Sitemap: https://example.org/sitemap.xml'
refute_includes last_response.body, 'asxp.io'
get '/sitemap.xml'
assert last_response.ok?
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}/dc/dc-25</loc>" }
assert_includes last_response.body, '<xhtml:link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-7"/>'
assert_equal 3, last_response.body.scan('hreflang="x-default"').size # card URLs only
assert_includes last_response.body, 'hreflang="x-default"'
refute_includes last_response.body, 'changefreq'
assert_match %r{</loc>\n <lastmod>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ</lastmod>\n <xhtml:link}, last_response.body
end
def test_trailing_slash_redirects
get '/en/', view: 'all'
assert_equal 301, last_response.status
assert_equal 'http://example.org/en?view=all', last_response.location
end
def test_icons
{ '/favicon.svg' => 'image/svg+xml', '/favicon.ico' => 'image/x-icon',
'/apple-touch-icon.png' => 'image/png' }.each do |path, type|
get path
assert last_response.ok?, path
assert_equal type, last_response.content_type
end
get '/en'
assert_includes last_response.body, '<link rel="icon" href="/favicon.svg" type="image/svg+xml">'
end
def test_hsts
get '/en'
assert_equal 'max-age=31536000', last_response.headers['strict-transport-security']
assert_equal 'nosniff', last_response.headers['x-content-type-options']
end
def test_dc_page
hit!(3)
get '/en/dc/dc-4'
assert last_response.ok?
body = last_response.body
assert_includes body, "<title>ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo</title>"
assert_includes body, '<link rel="canonical" href="https://example.org/en/dc/dc-4">'
assert_includes body, '<link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-4">'
refute_includes body, 'x-default'
assert_includes body, '<h1>ЦОД 4</h1>'
assert_includes body, 'ЦОД 4 is a datacenter in Moscow operated by Op. Capacity: 4 MW.'
assert_includes body, 'No. 4 by power'
assert_includes body, 'href="/en/dc/dc-3" rel="prev"'
assert_includes body, 'href="/en/dc/dc-5" rel="next"'
assert_match(/<meta name="description" content="[^"]*Latest reported incident/, body)
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1])
place = ld['@graph'].find { |n| n['@type'] == 'Place' }
assert_equal 'https://example.org/en/dc/dc-4', place['url']
assert_equal 'Outage: Авария 3', place['event'][0]['name']
crumbs = ld['@graph'].find { |n| n['@type'] == 'BreadcrumbList' }
assert_equal 'https://example.org/en', crumbs['itemListElement'][0]['item']
@dcs[3].update(mw_type: 'design')
get '/en/dc/dc-4'
assert_includes last_response.body, 'Capacity: 4 MW (design).'
get '/uk/dc/dc-1'
assert_includes last_response.body, '<title>ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів</title>'
refute_includes last_response.body, 'rel="prev"'
end
def test_reserve_dc_has_a_page
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Тверь', operator: 'Op')
get '/ru/dc/dc-26'
assert last_response.ok?
assert_includes last_response.body, 'href="/ru/dc/dc-25" rel="prev"'
get '/sitemap.xml'
assert_includes last_response.body, '<loc>https://example.org/ru/dc/dc-26</loc>'
end
def test_dc_page_unknown_and_trailing_slash
get '/en/dc/nope'
assert_equal 404, last_response.status
get '/en/dc/dc-1/'
assert_equal 301, last_response.status
assert_equal 'http://example.org/en/dc/dc-1', last_response.location
end
def test_card_links_to_dc_pages
hit!(0)
get '/en'
body = last_response.body
assert_includes body, '<a href="/en/dc/dc-1" data-dlg="dlg-dc-1"'
assert_includes body, '<a href="/en/dc/dc-2">Datacenter page →</a>'
assert_includes body, '<div class="dc"><a href="/en/dc/dc-1">DC 1</a>'
end
def with_env(vars)
old = vars.to_h { |k, _| [k, ENV[k]] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
old.each { |k, v| ENV[k] = v }
end
def test_indexnow_key_file
get '/0123456789abcdef.txt'
assert_equal 404, last_response.status
with_env('INDEXNOW_KEY' => '0123456789abcdef') do
get '/0123456789abcdef.txt'
assert last_response.ok?
assert_equal '0123456789abcdef', last_response.body
get '/fedcba9876543210.txt'
assert_equal 404, last_response.status
end
end
def test_root_varies_on_language
get '/'
assert_equal 'Accept-Language', last_response.headers['Vary']
end
def test_seo_head
hit!(3)
@dcs[3].update(photo_key: 'bingo/dc-4-0123abcd.jpg', photo_variants: true)
@dcs[0].update(info_url: 'https://example.com/dc-1')
get '/en'
body = last_response.body
assert_includes body, '<title>Datacenter Bingo — strikes'
assert_match %r{<h1>Datacenter <span>Bingo</span> <small>Drone strikes, fires and outages at Russia&#39;s 25 largest datacenters</small></h1>}, body
assert_includes body, '1 of Russia&#39;s 25 largest'
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-1200.webp">'
assert_includes body, 'src="/p/dc-4-0123abcd-480.webp"'
refute_includes body, 'og:image:width'
@dcs[3].update(photo_og: true)
get '/en'
body = last_response.body
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-og.jpg">'
assert_includes body, '<meta property="og:image:width" content="1200">'
assert_includes body, '<meta property="og:image:alt" content="ЦОД 4, Moscow">'
assert_includes body, 'og:locale:alternate" content="uk_UA"'
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1])
list = ld['@graph'].find { |n| n['@type'] == 'ItemList' }
assert_equal 25, list['itemListElement'].size
assert_equal 'DC 1', list['itemListElement'][0]['item']['name']
hit = list['itemListElement'][3]['item']
assert_equal Date.today.iso8601, hit['event'][0]['startDate']
assert_equal 'Outage: Авария 3', hit['event'][0]['name']
refute list['itemListElement'][0]['item'].key?('event')
assert_equal({ '@type' => 'CreativeWork', 'url' => 'https://example.com/dc-1' }, list['itemListElement'][0]['item']['subjectOf'])
refute_includes body, 'sameAs'
assert list['dateModified']
end
def test_photo_falls_back_to_original_without_variants
@dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.jpg', photo_variants: false)
assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file(480)
@dcs[0].update(photo_variants: true)
assert_equal 'dc-1-0123abcd-480.webp', @dcs[0].photo_file(480)
assert_equal 'dc-1-0123abcd.jpg', @dcs[0].photo_file
end
def test_photo_served_from_bucket
Aws.config[:s3] = { stub_responses: { get_object: { body: JPEG } } }
S3.instance_variable_set(:@client, nil)
@dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.jpg')
get "/p/#{@dcs[0].photo_file}"
assert last_response.ok?
assert_equal 'image/jpeg', last_response.content_type
assert_includes last_response.headers['cache-control'], 'immutable'
get '/p/..%2Finvoices%2Fx.pdf'
assert_equal 404, last_response.status
ensure
Aws.config[:s3] = { stub_responses: true }
S3.instance_variable_set(:@client, nil)
end
def test_dc_about_text
@dcs[1].update(about_en: "First <b>para</b>.\r\n\r\nSecond.")
get '/en/dc/dc-2'
assert_includes last_response.body, '<p class="facts">First &lt;b&gt;para&lt;/b&gt;.</p><p class="facts">Second.</p>'
get '/ru/dc/dc-2'
refute_includes last_response.body, 'Second.' # no fallback across languages
end
def test_static_files_not_served
get '/style.css'
assert_equal 404, last_response.status
end
def test_healthz
get '/healthz'
assert last_response.ok?
assert_equal 'ok', last_response.body
end
def test_www_redirects_to_canonical_host
ENV['BINGO_HOST'] = 'bingo.example'
app = Rack::Builder.parse_file(File.join(__dir__, '..', 'config.ru'))
res = Rack::MockRequest.new(app).get('http://www.bingo.example/en?view=all')
assert_equal 301, res.status
assert_equal 'https://bingo.example/en?view=all', res.location
ensure
ENV.delete('BINGO_HOST')
end
end

78
test/schema.sql Normal file
View file

@ -0,0 +1,78 @@
-- bingo tables as created by the asxpio migrations (004–016), for tests.
-- Refresh when asxpio adds a bingo migration: run them into an empty DB and
-- pg_dump -s -O -x -t bingo_datacenters -t bingo_incidents.
CREATE TABLE public.bingo_datacenters (
id integer NOT NULL,
slug text NOT NULL,
"position" integer NOT NULL,
name_ru text NOT NULL,
name_en text,
name_uk text,
city_ru text NOT NULL,
city_en text,
city_uk text,
operator text NOT NULL,
mw text,
status text DEFAULT 'operational'::text NOT NULL,
year integer,
photo_key text,
photo_source_url text,
photo_credit text,
info_url text,
created_at timestamp without time zone NOT NULL,
photo_variants boolean DEFAULT false NOT NULL,
mw_type text,
photo_og boolean DEFAULT false NOT NULL,
updated_at timestamp without time zone,
about_ru text,
about_en text,
about_uk text,
provider text
);
ALTER TABLE public.bingo_datacenters ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.bingo_datacenters_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE public.bingo_incidents (
id integer NOT NULL,
datacenter_id integer NOT NULL,
occurred_on date NOT NULL,
kind text NOT NULL,
title_ru text NOT NULL,
title_en text,
title_uk text,
url text,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone
);
ALTER TABLE public.bingo_incidents ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME public.bingo_incidents_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
ALTER TABLE ONLY public.bingo_datacenters
ADD CONSTRAINT bingo_datacenters_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.bingo_datacenters
ADD CONSTRAINT bingo_datacenters_slug_key UNIQUE (slug);
ALTER TABLE ONLY public.bingo_incidents
ADD CONSTRAINT bingo_incidents_pkey PRIMARY KEY (id);
CREATE INDEX bingo_incidents_datacenter_id_index ON public.bingo_incidents USING btree (datacenter_id);
ALTER TABLE ONLY public.bingo_incidents
ADD CONSTRAINT bingo_incidents_datacenter_id_fkey FOREIGN KEY (datacenter_id) REFERENCES public.bingo_datacenters(id) ON DELETE CASCADE;

13
test/templates_test.rb Normal file
View file

@ -0,0 +1,13 @@
require_relative 'test_helper'
# A template with unbalanced <% end %> only fails when that page is
# rendered; compile every view up front so CI catches it regardless.
class TemplatesTest < Minitest::Test
Dir[File.join(__dir__, '..', 'views', '**', '*.erb')].each do |path|
name = path.sub(%r{.*/views/}, '').tr('/.', '__')
define_method("test_compiles_#{name}") do
tmpl = Tilt::ErubiTemplate.new(path, escape_html: true)
tmpl.send(:compiled_method, [], Object) # raises SyntaxError if broken
end
end
end

29
test/test_helper.rb Normal file
View file

@ -0,0 +1,29 @@
ENV['RACK_ENV'] = 'test'
# DB-backed tests run only against an explicit test database (bin/test
# provisions an ephemeral one). Never fall through to a developer DATABASE_URL.
if ENV['TEST_DATABASE_URL']
ENV['DATABASE_URL'] = ENV['TEST_DATABASE_URL']
ENV['S3_ENDPOINT'] ||= 'http://127.0.0.1:1'
ENV['S3_ACCESS_KEY'] ||= 'test'
ENV['S3_SECRET_KEY'] ||= 'test'
ENV['S3_BUCKET'] ||= 'test-bucket'
require 'aws-sdk-s3'
Aws.config[:s3] = { stub_responses: true } # no S3 call leaves the process
# The schema is asxpio's; load the snapshot into the empty test database.
require 'sequel'
db = Sequel.connect(ENV['DATABASE_URL'])
db.run(File.read(File.join(__dir__, 'schema.sql'))) unless db.table_exists?(:bingo_datacenters)
db.disconnect
else
ENV.delete('DATABASE_URL')
end
require_relative '../app'
require 'minitest/autorun'
require 'rack/test'
module TestDb
def self.available? = !ENV['DATABASE_URL'].nil?
end

2
views/_credits.erb Normal file
View file

@ -0,0 +1,2 @@
<p class="credits"><%= t(:credits_ai) %>
<%== format(Rack::Utils.escape_html(t(:credits_hym)), link: '<a href="https://hackyourmom.com/en/karta-data-czentriv-na-rasii/" target="_blank" rel="noopener">hackyourmom</a>') %></p>

10
views/_dc_incidents.erb Normal file
View file

@ -0,0 +1,10 @@
<% if dc.incidents.empty? %>
<p class="empty"><%= t(:no_incidents) %></p>
<% else %>
<ul>
<% dc.incidents.each do |i| %>
<li><%= kind_icon(i.kind) %> <time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time> —
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %></li>
<% end %>
</ul>
<% end %>

9
views/_dc_meta.erb Normal file
View file

@ -0,0 +1,9 @@
<div class="meta">
<%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %>
</div>
<% if dc.photo_credit || dc.info_url %>
<div class="credit">
<% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %>
<% if dc.info_url %> · <a href="<%= dc.info_url %>" target="_blank" rel="noopener nofollow"><%= t(:source) %></a><% end %>
</div>
<% end %>

35
views/dc.erb Normal file
View file

@ -0,0 +1,35 @@
<% dc = @dc %>
<main>
<header>
<nav class="crumbs"><a href="/<%= @locale %>"><%= t(:title) %></a></nav>
<nav class="langs">
<% Bingo::LOCALES.each do |l| %>
<a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
<% end %>
</nav>
</header>
<article class="dc-page<%= ' hit' if dc.incidents.any? %>">
<% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" decoding="async"><% end %>
<h1><%= dc.l(:name, @locale) %></h1>
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
<p class="facts"><%= dc_facts(dc).join(' ') %></p>
<% dc.about(@locale).each do |para| %><p class="facts"><%= para %></p><% end %>
<section>
<h2><%= t(:incidents) %></h2>
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
</section>
</article>
<nav class="pager">
<% if @prev %><a href="<%= dc_path(@prev) %>" rel="prev">← #<%= @prev.position %> <%= @prev.l(:name, @locale) %></a><% else %><span></span><% end %>
<% if @next %><a href="<%= dc_path(@next) %>" rel="next">#<%= @next.position %> <%= @next.l(:name, @locale) %> →</a><% end %>
</nav>
<p><a href="/<%= @locale %>"><%= t(:back) %></a></p>
<footer>
<p><%= t(:footer) %></p>
<%== erb :_credits, layout: false %>
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p>
</footer>
</main>

117
views/index.erb Normal file
View file

@ -0,0 +1,117 @@
<% b = @board %>
<main>
<header>
<h1><%= t(:lead) %> <span><%= t(:accent) %></span> <small><%= t(:tagline) %></small></h1>
<div class="switches">
<nav class="toggle" aria-label="<%= t(:view_label) %>">
<% Bingo::VIEWS.each do |v| %>
<a href="<%= card_path(@locale, v) %>" <%== 'aria-current="page"' if v == @view %>><%= t(:"view_#{v}") %></a>
<% end %>
</nav>
<nav class="langs">
<% Bingo::LOCALES.each do |l| %>
<a href="<%= card_path(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
<% end %>
</nav>
</div>
</header>
<p class="lead"><%= t(:desc) %></p>
<% provs = Bingo::Board.providers(@view) %>
<form class="providers" method="get" action="/<%= @locale %>">
<% if @view == 'all' %><input type="hidden" name="view" value="all"><% end %>
<fieldset>
<legend><%= t(:providers_label) %></legend>
<% provs.each do |slug, total, crossed| %>
<label><input type="checkbox" name="p[]" value="<%= slug %>" <%= 'checked' if @providers.include?(slug) %>> <%= Bingo.provider_name(slug, @locale) %> <span><%= crossed %>/<%= total %></span></label>
<% end %>
</fieldset>
<div class="provider-actions">
<button type="submit"><%= t(:providers_apply) %></button>
<% if @board.filtered? %><a href="<%= card_path(@locale, @view, []) %>"><%= t(:providers_reset) %></a><% end %>
</div>
</form>
<p class="meta">
<%= t(:crossed) %> <b><%= b.hit.count(true) %></b>/<%= b.cells.size %>
· <%= t(:lines) %> <b><%= b.wins.size %></b>
<% if b.updated_on %>· <%= t(:updated) %> <b><%= fmt_date(b.updated_on) %></b><% end %>
</p>
<% if b.wins.any? %>
<div class="banner">
<%= b.wins.size == Bingo::LINES.size ? t(:full) : b.wins.size > 1 ? format(t(:bingo_n), b.wins.size) : t(:bingo) %>
</div>
<% end %>
<div class="letters" aria-hidden="true"><% t(:letters).each do |ch| %><div><%= ch %></div><% end %></div>
<div class="grid">
<% b.cells.each_with_index do |dc, idx| %>
<% photo = photo_path(dc, 480) %>
<a href="<%= dc_path(dc) %>" data-dlg="dlg-<%= dc.slug %>"
class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>"
aria-label="<%= dc.l(:name, @locale) %><%= " — #{kind_label(dc.incidents.first.kind)}" if b.hit[idx] %>">
<% if photo %><img class="bg" src="<%= photo %>" alt="" width="480" height="480" decoding="async"<%== ' loading="lazy"' if idx >= 10 %>><% end %>
<div>
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc, tag: false)}" if mw(dc) %></div>
<% if status_label(dc) %><div class="tag"><%= status_label(dc) %></div><% end %>
</div>
<div>
<div class="name"><%= dc.l(:name, @locale) %></div>
<div class="sub"><%= dc.l(:city, @locale) %></div>
</div>
<% if b.hit[idx] %><div class="stamp"><%= kind_icon(dc.incidents.first.kind) %></div><% end %>
</a>
<% end %>
<% (b.cells.size...Bingo::SIZE**2).each do %><div class="cell empty" aria-hidden="true"></div><% end %>
</div>
<section>
<h2><%= t(:log) %></h2>
<ul class="log">
<% if b.incidents.empty? %>
<li class="empty"><%= t(:empty) %></li>
<% end %>
<% b.incidents.each do |i| %>
<li>
<time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time>
<span><%= kind_icon(i.kind) %></span>
<div>
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %>
<div class="dc"><a href="<%= dc_path(i.datacenter) %>"><%= i.datacenter.l(:name, @locale) %></a> · <%= kind_label(i.kind) %></div>
</div>
</li>
<% end %>
</ul>
</section>
<footer>
<p><%= t(:footer) %></p>
<%== erb :_credits, layout: false %>
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p>
</footer>
</main>
<% b.cells.each do |dc| %>
<dialog id="dlg-<%= dc.slug %>">
<% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" loading="lazy" decoding="async"><% end %>
<div class="body">
<h3><%= dc.l(:name, @locale) %></h3>
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
<div class="actions">
<a href="<%= dc_path(dc) %>"><%= t(:dc_page) %></a>
<form method="dialog"><button><%= t(:close) %></button></form>
</div>
</div>
</dialog>
<% end %>
<script>
// Cells are links to the datacenter pages; a plain click opens the dialog
// instead, modified clicks (new tab etc.) keep the link.
document.querySelectorAll('[data-dlg]').forEach(a => a.addEventListener('click', e => {
if (e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
document.getElementById(a.dataset.dlg).showModal();
}));
</script>

163
views/layout.erb Normal file
View file

@ -0,0 +1,163 @@
<% base = base_url %>
<% og_img = @og && "#{base}/p/#{@og.og_file}" %>
<!doctype html>
<html lang="<%= @locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= @title %></title>
<meta name="description" content="<%= @desc %>">
<meta name="robots" content="<%= @noindex ? 'noindex, follow' : 'index, follow, max-image-preview:large' %>">
<meta name="theme-color" content="#121212">
<link rel="canonical" href="<%= base %><%= @path_for.(@locale) %>">
<% Bingo::LOCALES.each do |l| %>
<link rel="alternate" hreflang="<%= l %>" href="<%= base %><%= @path_for.(l) %>">
<% end %>
<% if @x_default %>
<link rel="alternate" hreflang="x-default" href="<%= base %><%= @x_default %>">
<% end %>
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<%= t(:title) %>">
<meta property="og:title" content="<%= @title %>">
<meta property="og:description" content="<%= @desc %>">
<meta property="og:url" content="<%= base %><%= @path_for.(@locale) %>">
<meta property="og:locale" content="<%= t(:og_locale) %>">
<% (Bingo::LOCALES - [@locale]).each do |l| %>
<meta property="og:locale:alternate" content="<%= Bingo::T[l][:og_locale] %>">
<% end %>
<% if og_img %>
<meta property="og:image" content="<%= og_img %>">
<% if @og.photo_og %>
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="<%= Bingo::Photo::OG_SIZE[0] %>">
<meta property="og:image:height" content="<%= Bingo::Photo::OG_SIZE[1] %>">
<% end %>
<meta property="og:image:alt" content="<%= @og.l(:name, @locale) %>, <%= @og.l(:city, @locale) %>">
<meta name="twitter:image" content="<%= og_img %>">
<% end %>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= @title %>">
<meta name="twitter:description" content="<%= @desc %>">
<script type="application/ld+json"><%== @ld %></script>
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
:root {
--bg: #f4f1ea; --card: #fffdf8; --ink: #1d1b18; --muted: #6f6a60; --line: #d9d3c6;
--hit: #c8361d; --hit-bg: #fbe3dc; --win: #e7b416; --on-win: #1d1b18;
--shade: rgb(0 0 0 / .62); --on-photo: #fff; --on-photo-muted: rgb(255 255 255 / .75);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
}
}
:root[data-theme="dark"] {
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; }
a { color: inherit; }
header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-bottom: 16px; }
h1 { margin: 0; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.02em; }
h1 span { color: var(--hit); }
h1 small { display: block; margin-top: 4px; font-size: 16px; font-weight: 600; letter-spacing: 0; color: var(--muted); }
.switches { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.toggle { display: flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.toggle a { padding: 2px 10px; font-size: 13px; text-decoration: none; }
.toggle a + a { border-left: 1px solid var(--line); }
.toggle a[aria-current] { background: var(--hit); color: var(--bg); }
.langs { display: flex; gap: 4px; }
.langs a { padding: 2px 8px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; font-size: 13px; text-transform: uppercase; }
.langs a[aria-current] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.meta { color: var(--muted); font-size: 14px; }
.meta b { color: var(--ink); font-variant-numeric: tabular-nums; }
.lead { color: var(--muted); margin: 0 0 16px; max-width: 60ch; }
.banner { margin: 0 0 16px; padding: 12px 16px; border-radius: 10px; background: var(--win); color: var(--on-win); font-weight: 700; font-size: 18px; }
.letters, .grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.cell.empty { background: transparent; border-style: dashed; cursor: default; }
.providers { margin: 0 0 16px; }
.providers fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.providers legend { font-size: 13px; color: var(--muted); margin-bottom: 6px; padding: 0; }
.providers label { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 13px; cursor: pointer; white-space: nowrap; }
.providers label:has(input:checked) { border-color: var(--ink); }
.providers input { accent-color: var(--hit); margin: 0; }
.providers label span { color: var(--muted); font-variant-numeric: tabular-nums; }
.provider-actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; font-size: 13px; }
.provider-actions button { font: inherit; padding: 3px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; }
.letters div { text-align: center; font-weight: 900; font-size: clamp(22px, 5vw, 36px); color: var(--hit); }
.grid { margin-top: 6px; }
.cell {
position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%;
background: var(--card); border: 1px solid var(--line); border-radius: 8px;
display: flex; flex-direction: column; justify-content: space-between;
text-align: left; color: inherit; font: inherit; text-decoration: none; cursor: pointer;
}
.cell.photo { color: var(--on-photo); border-color: transparent; }
.cell .bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
.cell.photo::before { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
.cell > :not(.bg) { position: relative; z-index: 2; }
.cell .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.cell.photo .n, .cell.photo .sub { color: var(--on-photo-muted); }
.cell .name { font-weight: 700; font-size: clamp(11px, 1.9vw, 15px); line-height: 1.15; }
.cell .sub { font-size: clamp(10px, 1.4vw, 12px); color: var(--muted); }
.cell .tag { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
.cell.hit { border-color: var(--hit); }
.cell.hit:not(.photo) { background-color: var(--hit-bg); }
.cell.hit::after {
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: .8;
background:
linear-gradient(to top right, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px)),
linear-gradient(to top left, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px));
}
.cell .stamp { position: absolute; right: 6px; top: 4px; font-size: clamp(16px, 3vw, 24px); z-index: 4; }
.cell.win { box-shadow: 0 0 0 3px var(--win); }
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
section { margin-top: 32px; }
h2 { font-size: 18px; margin: 0 0 12px; }
.log { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.log li { display: grid; grid-template-columns: 96px 28px 1fr; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.log time, .log .dc { color: var(--muted); }
.log .dc { font-size: 13px; }
.empty { color: var(--muted); }
dialog { width: min(520px, calc(100vw - 32px)); border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--ink); padding: 0; }
dialog::backdrop { background: rgb(0 0 0 / .55); }
dialog img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
dialog .body { padding: 16px 20px 20px; }
dialog h3 { margin: 0 0 4px; }
dialog .credit { font-size: 12px; color: var(--muted); margin-top: 4px; }
dialog ul { padding-left: 18px; }
dialog .actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; }
dialog form { margin: 0; }
.crumbs a { font-weight: 700; text-decoration: none; }
.crumbs a:hover { text-decoration: underline; }
.dc-page img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 10px; margin-bottom: 16px; }
.dc-page h1 { font-size: clamp(26px, 5vw, 38px); }
.dc-page.hit h1 { color: var(--hit); }
.dc-page .credit { font-size: 12px; color: var(--muted); margin-top: 4px; }
.dc-page .facts { max-width: 65ch; }
.dc-page ul { padding-left: 18px; }
.pager { display: flex; justify-content: space-between; gap: 12px; margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--line); }
dialog button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; }
footer { margin-top: 32px; color: var(--muted); font-size: 13px; }
footer p { margin: 0 0 4px; }
@media (max-width: 560px) {
.cell { padding: 5px; border-radius: 6px; }
.cell .sub, .cell .n, .cell .tag { display: none; }
.letters, .grid { gap: 4px; }
.log li { grid-template-columns: 84px 24px 1fr; }
}
</style>
</head>
<body>
<%== yield %>
</body>
</html>

16
views/sitemap.erb Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<% @entries.each do |path_for, lastmod, x_default| %>
<% Bingo::LOCALES.each do |l| %>
<url>
<loc><%= base_url %><%= path_for.(l) %></loc>
<% if lastmod %> <lastmod><%= lastmod.utc.iso8601 %></lastmod>
<% end %>
<% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %><%= path_for.(alt) %>"/>
<% end %>
<% if x_default %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %><%= x_default %>"/>
<% end %>
</url>
<% end %>
<% end %>
</urlset>