commit 062611fa214c1d5fd1984647bce02ba2507707ea Author: Sergei Poljanski Date: Mon Oct 12 03:48:06 2026 +0400 Initial import of the public datacenter bingo site 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..feb98af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.env +.gems +.bundle +vendor/bundle +*.log +tmp +flake.nix +flake.lock +CLAUDE.md +README.md +test +Rakefile +bin +.forgejo +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81824c8 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml new file mode 100644 index 0000000..03142f2 --- /dev/null +++ b/.forgejo/workflows/deploy.yaml @@ -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 </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 diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..e824167 --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aec92ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.gems/ +.bundle/ +vendor/bundle/ +*.log +tmp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c2040a0 --- /dev/null +++ b/CLAUDE.md @@ -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), `/.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) | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3001380 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..d276b8f --- /dev/null +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..c08c283 --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ddc59e --- /dev/null +++ b/README.md @@ -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`. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c1a67e1 --- /dev/null +++ b/Rakefile @@ -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 diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..56a8fd3 --- /dev/null +++ b/app.rb @@ -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(' '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(' 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 diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png new file mode 100644 index 0000000..4edcee5 Binary files /dev/null and b/assets/apple-touch-icon.png differ diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..a2e3a14 Binary files /dev/null and b/assets/favicon.ico differ diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..39a06b1 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/b/asxpio-invoices/bingo/evil.jpg b/b/asxpio-invoices/bingo/evil.jpg new file mode 100644 index 0000000..15bf459 --- /dev/null +++ b/b/asxpio-invoices/bingo/evil.jpg @@ -0,0 +1 @@ +img diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..6f2555f --- /dev/null +++ b/bin/test @@ -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 "$@" diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..c104976 --- /dev/null +++ b/config.ru @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae01489 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..25b0fc5 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fae4dce --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + }; +} diff --git a/lib/bingo.rb b/lib/bingo.rb new file mode 100644 index 0000000..010b651 --- /dev/null +++ b/lib/bingo.rb @@ -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 /.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 diff --git a/lib/db.rb b/lib/db.rb new file mode 100644 index 0000000..92288cd --- /dev/null +++ b/lib/db.rb @@ -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 diff --git a/lib/s3.rb b/lib/s3.rb new file mode 100644 index 0000000..aa62795 --- /dev/null +++ b/lib/s3.rb @@ -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 diff --git a/test/bingo_test.rb b/test/bingo_test.rb new file mode 100644 index 0000000..98d1604 --- /dev/null +++ b/test/bingo_test.rb @@ -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, 'asxp.io' + 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, '' + 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, '' + 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' # cell: no tag + assert_includes body, '17 MVA' + assert_includes body, '#2 · 17 MVA' # 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&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, 'hackyourmom' + end + get "/en/dc/#{@dcs[0].slug}" + assert_includes last_response.body, 'hackyourmom' + 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, "https://example.org/#{l}" } + %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}/dc/dc-25" } + assert_includes last_response.body, '' + 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{\n \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\n '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, '' + 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, "ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo" + assert_includes body, '' + assert_includes body, '' + refute_includes body, 'x-default' + assert_includes body, '

ЦОД 4

' + 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(/(.*?)}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, 'ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів' + 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, 'https://example.org/ru/dc/dc-26' + 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, 'Datacenter page →' + assert_includes body, '
DC 1' + 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, 'Datacenter Bingo — strikes' + assert_match %r{<h1>Datacenter <span>Bingo</span> <small>Drone strikes, fires and outages at Russia's 25 largest datacenters</small></h1>}, body + assert_includes body, '1 of Russia'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 <b>para</b>.</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 diff --git a/test/schema.sql b/test/schema.sql new file mode 100644 index 0000000..3e68d23 --- /dev/null +++ b/test/schema.sql @@ -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; + + diff --git a/test/templates_test.rb b/test/templates_test.rb new file mode 100644 index 0000000..0169a50 --- /dev/null +++ b/test/templates_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..82b63b3 --- /dev/null +++ b/test/test_helper.rb @@ -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 diff --git a/views/_credits.erb b/views/_credits.erb new file mode 100644 index 0000000..2571b4c --- /dev/null +++ b/views/_credits.erb @@ -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> diff --git a/views/_dc_incidents.erb b/views/_dc_incidents.erb new file mode 100644 index 0000000..63f78f7 --- /dev/null +++ b/views/_dc_incidents.erb @@ -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 %> diff --git a/views/_dc_meta.erb b/views/_dc_meta.erb new file mode 100644 index 0000000..d71b859 --- /dev/null +++ b/views/_dc_meta.erb @@ -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 %> diff --git a/views/dc.erb b/views/dc.erb new file mode 100644 index 0000000..b63d6ba --- /dev/null +++ b/views/dc.erb @@ -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> diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 0000000..099c7f4 --- /dev/null +++ b/views/index.erb @@ -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> diff --git a/views/layout.erb b/views/layout.erb new file mode 100644 index 0000000..29a7cca --- /dev/null +++ b/views/layout.erb @@ -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 %> + + + + +<% Bingo::LOCALES.each do |l| %> + +<% end %> +<% if @x_default %> + +<% end %> + + + + + + + +<% (Bingo::LOCALES - [@locale]).each do |l| %> + +<% end %> +<% if og_img %> + +<% if @og.photo_og %> + + + +<% end %> + + +<% end %> + + + + + + + + + + +<%== yield %> + + diff --git a/views/sitemap.erb b/views/sitemap.erb new file mode 100644 index 0000000..0c3013b --- /dev/null +++ b/views/sitemap.erb @@ -0,0 +1,16 @@ + + +<% @entries.each do |path_for, lastmod, x_default| %> +<% Bingo::LOCALES.each do |l| %> + + <%= base_url %><%= path_for.(l) %> +<% if lastmod %> <%= lastmod.utc.iso8601 %> +<% end %> +<% Bingo::LOCALES.each do |alt| %> +<% end %> +<% if x_default %> +<% end %> + +<% end %> +<% end %> +