diff --git a/.env.example b/.env.example index e7680f1..8b9a630 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ MAIL_TO=ie@asxp.io # CRYPTO_ADDRESSES={"BTC":"bc1...","USDT-TRC20":"T..."} # Legacy: LTC_ADDRESS still fills the LTC default when set. # LTC_ADDRESS=ltc1... + +# Datacenter bingo hostname (same process, routed by Host). Unset = bingo off. +# BINGO_HOST=bingo.localhost diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index d440b94..3cdde54 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -139,6 +139,7 @@ jobs: S3_SECRET_KEY=${ASXPIO_S3_SECRET_KEY} LTC_ADDRESS=${LTC_ADDRESS} CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES} + BINGO_HOST=bingo.asxp.io EOF chmod 600 /opt/asxpio/.env diff --git a/CLAUDE.md b/CLAUDE.md index de3ddc0..c2d9679 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,18 @@ Admin pages use a shared dark palette extended in `public/style.css` (search for - Anyone with the UUID can fetch the PDF. UUIDs are 122-bit random so not enumerable, but they aren't access-controlled. If a stronger gate is ever needed (email confirmation, expiry on paid, etc.) it goes in the `/i/:uuid` and `/i/:uuid/pdf` handlers. - The MinIO bucket is **private**; only the app's scoped service account can put/get. Presigned URLs use `S3_PUBLIC_ENDPOINT=https://s3.asxp.io` so the signed host matches what the browser fetches. +## Datacenter bingo + +A second public site served from the **same process** on `BINGO_HOST` (`bingo.asxp.io`, non-secret config in `deploy.yaml` → `.env` → Traefik router `asxpio-bingo` via compose interpolation). `config.ru` dispatches by Host: `BINGO_HOST` → `BingoWeb` (`bingo_web.rb`), everything else → `AsxpioWeb`. Unset `BINGO_HOST` = bingo unreachable. + +- 5×5 card of the 25 largest Russian datacenters; a cell is crossed out when an incident (with a public source) is recorded. Rows/columns/diagonals → BINGO banner. +- Locales `ru` / `en` / `uk` at `/ru`, `/en`, `/uk`; `/` redirects by `Accept-Language` (fallback `en`). Per-locale DB columns (`name_*`, `city_*`, `title_*`); `ru` is required, others fall back to it. UI strings in `Bingo::T` (`lib/bingo.rb`). +- Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`) in the asxpio DB. First boot seeds from `db/bingo_seed.json` (verified 2026-10-10, sources in `info_url`) only if the table is empty — after that, edit via admin, not the JSON. +- Admin: `/admin/bingo` on asxp.io (`lib/bingo_admin.rb`, registered into `AsxpioWeb`, so HTTP Basic + CSRF apply). Record/delete incidents, edit DCs, upload a photo or import it from a URL, bulk "import all missing photos" from `photo_source_url`. +- Photos: stored in the existing bucket under `bingo/-.` (no storage-repo change), proxied by `BingoWeb` at `/p/:file` with immutable caching (new upload = new key; old objects are kept). Type sniffed from magic bytes (JPEG/PNG/WebP), 8 MB cap. URL import is https-only, admin-only. +- Photos are third-party (operator sites, Habr, Wikimedia Commons CC BY-SA) — keep `photo_credit` filled; renders/illustrations are marked as such. +- No street addresses or coordinates on the page — city level only. + ## Operational notes - `client_ip` reads `X-Forwarded-For` first (Traefik sets it), falling back to `X-Real-IP` and `REMOTE_ADDR`. diff --git a/asxpio.rb b/asxpio.rb index cf4e53d..94c9e04 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -23,6 +23,7 @@ require_relative 'lib/rate_limit' require_relative 'lib/db' require_relative 'lib/admin_auth' require_relative 'lib/s3' +require_relative 'lib/bingo_admin' # Invoice model needs a live Sequel connection at class-definition time. if ENV['DATABASE_URL'] @@ -34,6 +35,8 @@ if ENV['DATABASE_URL'] require_relative 'lib/crypto_qr' require_relative 'lib/gel_rate' require_relative 'lib/invoice_pdf' + require_relative 'lib/bingo' + Bingo.seed! else $logger.warn('DATABASE_URL not set — invoicing disabled') end @@ -61,6 +64,7 @@ class AsxpioWeb < Sinatra::Base use Rack::Protection::AuthenticityToken use AdminAuth + register BingoAdmin Mailer.configure! diff --git a/bingo_web.rb b/bingo_web.rb new file mode 100644 index 0000000..c85cac2 --- /dev/null +++ b/bingo_web.rb @@ -0,0 +1,59 @@ +# Public datacenter bingo, served on BINGO_HOST from the same process as +# AsxpioWeb (config.ru dispatches by Host). Read-only: no session, no CSRF. +# Content is managed from asxp.io/admin/bingo (lib/bingo_admin.rb). +class BingoWeb < Sinatra::Base + configure do + set :root, $root + set :views, File.join($root, 'views', 'bingo') + set :erb, layout: :layout, escape_html: true + set :show_exceptions, $env == 'development' + set :host_authorization, + { permitted_hosts: $env == 'production' && ENV['BINGO_HOST'] ? [ENV['BINGO_HOST']] : [] } + 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) = dc.photo_file && "/p/#{dc.photo_file}" + def fmt_date(d) = d.strftime('%d.%m.%Y') + + def mw(dc) + dc.mw.to_s.strip.empty? ? nil : "#{dc.mw} #{t(:mw)}" + 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 + + before do + halt 503, 'Bingo not configured (DATABASE_URL missing).' unless defined?(Bingo::Datacenter) + end + + get '/' do + redirect "/#{preferred_locale}", 302 + end + + get %r{/(ru|en|uk)/?} do |locale| + @locale = locale + @board = Bingo::Board.load + cache_control :public, max_age: 60 + erb :index + 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/config.ru b/config.ru index e2a968f..38de138 100644 --- a/config.ru +++ b/config.ru @@ -1,3 +1,13 @@ require 'sinatra/base' require './asxpio.rb' -run AsxpioWeb.new +require './bingo_web.rb' + +# One process, two sites: BINGO_HOST gets the bingo, everything else asxp.io. +asxpio = AsxpioWeb.new +bingo = BingoWeb.new +bingo_host = ENV['BINGO_HOST'] + +run lambda { |env| + host = Rack::Request.new(env).host + (bingo_host && host == bingo_host ? bingo : asxpio).call(env) +} diff --git a/db/bingo_seed.json b/db/bingo_seed.json new file mode 100644 index 0000000..5b5a7d7 --- /dev/null +++ b/db/bingo_seed.json @@ -0,0 +1,449 @@ +{ + "datacenters": [ + { + "slug": "ix-mos5", + "name_ru": "IXcellerate MOS5", + "name_en": "IXcellerate MOS5", + "name_uk": "IXcellerate MOS5", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "IXcellerate", + "mw": "64", + "status": "operational", + "year": 2021, + "photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2025/04/mos5_2-scaled.jpg", + "photo_credit": "© IXcellerate", + "info_url": "https://www.ixcellerate.com/data-centers/moscow-south-campus/", + "position": 1 + }, + { + "slug": "yandex-kaluga", + "name_ru": "Яндекс Калуга", + "name_en": "Yandex Kaluga", + "name_uk": "Яндекс Калуга", + "city_ru": "Калуга", + "city_en": "Kaluga", + "city_uk": "Калуга", + "operator": "Яндекс", + "mw": "63", + "status": "operational", + "year": 2023, + "photo_source_url": "https://storage.yandexcloud.net/cloud-www-assets/blog-assets/ru/posts/2022/scale/cloud-data-center-kaluga/kaluga_2.jpg", + "photo_credit": "© Yandex Cloud (визуализация)", + "info_url": "https://yandex.cloud/ru/blog/posts/2022/09/cloud-data-center-kaluga", + "position": 2 + }, + { + "slug": "kalininsky", + "name_ru": "ЦОД «Калининский»", + "name_en": "Kalininsky DC", + "name_uk": "ЦОД «Калінінський»", + "city_ru": "Удомля", + "city_en": "Udomlya", + "city_uk": "Удомля", + "operator": "Атомдата / Ростелеком", + "mw": "48", + "status": "operational", + "year": 2019, + "photo_source_url": "https://upload.wikimedia.org/wikipedia/commons/c/c8/%D0%A6%D0%9E%D0%94_%D0%9A%D0%B0%D0%BB%D0%B8%D0%BD%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9.jpg", + "photo_credit": "Sergei2709 / Wikimedia Commons, CC BY-SA 4.0", + "info_url": "https://www.comnews.ru/content/121093/2019-07-29/rostelekom-vvel-centr-obrabotki-dannyh-udomlya-v-promyshlennuyu-ekspluataciyu", + "position": 3 + }, + { + "slug": "yandex-vladimir-2", + "name_ru": "Яндекс Владимир-2", + "name_en": "Yandex Vladimir-2", + "name_uk": "Яндекс Владимир-2", + "city_ru": "Владимир", + "city_en": "Vladimir", + "city_uk": "Владимир", + "operator": "Яндекс", + "mw": ">40", + "status": "building", + "year": 2027, + "photo_source_url": "https://www.tadviser.ru/images/d/d9/Yan1.jpg", + "photo_credit": "Госстройнадзор Владимирской обл. / TAdviser", + "info_url": "https://www.cableman.ru/content/yandeks-zapuskaet-data-tsentr-vo-vladimirskoi-oblasti-s-moshchnostyu-bolee-40-mvt", + "position": 4 + }, + { + "slug": "ix-mos11", + "name_ru": "IXcellerate MOS11 «Вёшки»", + "name_en": "IXcellerate MOS11 Vyoshki", + "name_uk": "IXcellerate MOS11 «Вьошки»", + "city_ru": "Мытищи", + "city_en": "Mytishchi", + "city_uk": "Митищі", + "operator": "IXcellerate", + "mw": "40", + "status": "building", + "year": 2026, + "photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2025/12/vyoshki-campus5.jpg", + "photo_credit": "© IXcellerate (визуализация)", + "info_url": "https://www.ixcellerate.com/data-centers/moscow-veshki-campus/", + "position": 5 + }, + { + "slug": "rtk-medvedkovo-2", + "name_ru": "РТК-ЦОД Медведково-2", + "name_en": "RTK-DC Medvedkovo-2", + "name_uk": "РТК-ЦОД Медведково-2", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "РТК-ЦОД", + "mw": "36", + "status": "operational", + "year": 2024, + "photo_source_url": "https://upload.wikimedia.org/wikipedia/commons/b/b5/%D0%A6%D0%B5%D0%BD%D1%82%D1%80_%D0%BE%D0%B1%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B8_%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85_%28%D0%9C%D0%B5%D0%B4%D0%B2%D0%B5%D0%B4%D0%BA%D0%BE%D0%B2%D0%BE%29_20260621_121548.jpg", + "photo_credit": "Lesless / Wikimedia Commons, CC BY-SA 4.0", + "info_url": "https://www.interfax.ru/business/976641", + "position": 6 + }, + { + "slug": "atomdata-moskva-2", + "name_ru": "Атомдата «Москва-2»", + "name_en": "Atomdata Moskva-2", + "name_uk": "Атомдата «Москва-2»", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "Атомдата", + "mw": "36", + "status": "operational", + "year": 2024, + "photo_source_url": "https://atomdata.ru/upload/iblock/671/gchj4a2c7gwg0v18v1nqehosu4c8zgtz.jpg", + "photo_credit": "© АО «Атомдата»", + "info_url": "https://habr.com/ru/articles/795381/", + "position": 7 + }, + { + "slug": "3data-hsm", + "name_ru": "3data HyperScale Медведково", + "name_en": "3data HyperScale Medvedkovo", + "name_uk": "3data HyperScale Медведково", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "3data", + "mw": "36", + "status": "operational", + "year": 2023, + "photo_source_url": "https://3data.ru/1024/upload/1691061832_01.jpg", + "photo_credit": "© 3data", + "info_url": "https://3data.ru/dc/hsm", + "position": 8 + }, + { + "slug": "ix-mos3", + "name_ru": "IXcellerate MOS3", + "name_en": "IXcellerate MOS3", + "name_uk": "IXcellerate MOS3", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "IXcellerate", + "mw": "30", + "status": "operational", + "year": 2025, + "photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2025/11/IXC_0111-2.jpg", + "photo_credit": "© IXcellerate", + "info_url": "https://www.ixcellerate.com/data-centers/moscow-north-campus/", + "position": 9 + }, + { + "slug": "datapro-moscow-3", + "name_ru": "DataPro Moscow III", + "name_en": "DataPro Moscow III", + "name_uk": "DataPro Moscow III", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "DataPro", + "mw": "30", + "status": "operational", + "year": 2022, + "photo_source_url": "https://www.datapro.ru/assets/images/pages/data-centers/3/0.png", + "photo_credit": "© DataPro (иллюстрация)", + "info_url": "https://www.datapro.ru/data-centers/datapro-moscow-iii", + "position": 10 + }, + { + "slug": "mws-avantazh", + "name_ru": "MWS «Авантаж»", + "name_en": "MWS Avantazh", + "name_uk": "MWS «Авантаж»", + "city_ru": "Лыткарино", + "city_en": "Lytkarino", + "city_uk": "Литкаріно", + "operator": "MWS / МТС", + "mw": "20", + "status": "operational", + "year": 2018, + "photo_source_url": "https://habrastorage.org/r/w1560/webt/f3/mb/1a/f3mb1avrss4og8wlza4pspv4hzu.jpeg", + "photo_credit": "Т1 / Техносерв, Хабр", + "info_url": "https://www.comnews.ru/content/239118/2025-05-06/2025-w19/1010/mws-attestovala-odin-krupneyshikh-codov-rossii-vysshemu-urovnyu-zaschity-personalnykh-dannykh", + "position": 11 + }, + { + "slug": "oxygen-msk1", + "name_ru": "OXYGEN МСК-1", + "name_en": "OXYGEN MSK-1", + "name_uk": "OXYGEN МСК-1", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "OXYGEN", + "mw": "≈17", + "status": "operational", + "year": 2011, + "photo_source_url": "https://oxygen.cloud/wp-content/uploads/2024/09/msk_1.jpg", + "photo_credit": "© OXYGEN", + "info_url": "https://oxygen.cloud/data-center/", + "position": 12 + }, + { + "slug": "datahouse-m1", + "name_ru": "DataHouse «Магистральный-1»", + "name_en": "DataHouse Magistralny-1", + "name_uk": "DataHouse «Магістральний-1»", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "DataHouse", + "mw": "15", + "status": "operational", + "year": 2024, + "photo_source_url": "https://datahouse.ru/wp-content/themes/datahouse/frontend/build/src/img/dc-page-m1-01-01.jpg", + "photo_credit": "© DataHouse (визуализация)", + "info_url": "https://datahouse.ru/datacenter/magistralnyj-1/", + "position": 13 + }, + { + "slug": "megafon-spb", + "name_ru": "МегаФон Санкт-Петербург", + "name_en": "MegaFon St Petersburg", + "name_uk": "МегаФон Санкт-Петербург", + "city_ru": "Санкт-Петербург", + "city_en": "St Petersburg", + "city_uk": "Санкт-Петербург", + "operator": "МегаФон", + "mw": "до 14", + "status": "operational", + "year": 2025, + "photo_source_url": null, + "photo_credit": null, + "info_url": "https://corp.megafon.ru/press/news/federalnye_novosti/federal/20251009-1139.html", + "position": 14 + }, + { + "slug": "ix-mos1", + "name_ru": "IXcellerate MOS1", + "name_en": "IXcellerate MOS1", + "name_uk": "IXcellerate MOS1", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "IXcellerate", + "mw": "13,7", + "status": "operational", + "year": 2012, + "photo_source_url": "https://www.ixcellerate.com/wp-content/uploads/2022/04/mos1_3-scaled.jpg", + "photo_credit": "© IXcellerate", + "info_url": "https://www.ixcellerate.com/data-centers/moscow-north-campus/", + "position": 15 + }, + { + "slug": "ix-mos2", + "name_ru": "IXcellerate MOS2", + "name_en": "IXcellerate MOS2", + "name_uk": "IXcellerate MOS2", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "IXcellerate", + "mw": "13", + "status": "operational", + "year": 2019, + "photo_source_url": "https://ixcellerate.ru/wp-content/uploads/2025/07/mos2_8.jpg", + "photo_credit": "© IXcellerate", + "info_url": "https://ixcellerate.ru/data-centers/moscow-north-campus/", + "position": 16 + }, + { + "slug": "linx-spb", + "name_ru": "Linx Санкт-Петербург", + "name_en": "Linx St Petersburg", + "name_uk": "Linx Санкт-Петербург", + "city_ru": "Санкт-Петербург", + "city_en": "St Petersburg", + "city_uk": "Санкт-Петербург", + "operator": "Linx", + "mw": "12", + "status": "operational", + "year": null, + "photo_source_url": "https://linx.ru/upload/iblock/684/auw00pdcma1b24pxlgj2b0kewwppnkrl/Sp_10.jpg.webp", + "photo_credit": "© Linx", + "info_url": "https://linx.ru/datacenter/colocation/", + "position": 17 + }, + { + "slug": "keypoint-ekb", + "name_ru": "Key Point Екатеринбург", + "name_en": "Key Point Yekaterinburg", + "name_uk": "Key Point Єкатеринбург", + "city_ru": "Берёзовский", + "city_en": "Beryozovsky", + "city_uk": "Березовський", + "operator": "Key Point", + "mw": "11", + "status": "operational", + "year": 2026, + "photo_source_url": null, + "photo_credit": null, + "info_url": "https://rg.ru/2026/07/15/reg-urfo/cennost-tehnologij.html", + "position": 18 + }, + { + "slug": "datapro-moscow-2", + "name_ru": "DataPro Moscow II", + "name_en": "DataPro Moscow II", + "name_uk": "DataPro Moscow II", + "city_ru": "Химки", + "city_en": "Khimki", + "city_uk": "Хімки", + "operator": "DataPro", + "mw": "11", + "status": "operational", + "year": 2020, + "photo_source_url": "https://www.datapro.ru/assets/images/pages/data-centers/2/0.png", + "photo_credit": "© DataPro (иллюстрация)", + "info_url": "https://www.datapro.ru/data-centers/datapro-moscow-ii", + "position": 19 + }, + { + "slug": "selectel-berzarina", + "name_ru": "Selectel Берзарина", + "name_en": "Selectel Berzarina", + "name_uk": "Selectel Берзаріна", + "city_ru": "Москва", + "city_en": "Moscow", + "city_uk": "Москва", + "operator": "Selectel", + "mw": "10", + "status": "operational", + "year": null, + "photo_source_url": "https://selectel.ru/images/components/content/data-centers/berzarina-2.jpg", + "photo_credit": "© Selectel (машзал)", + "info_url": "https://selectel.ru/about/data-centers/", + "position": 20 + }, + { + "slug": "selectel-tsvetochnaya-2", + "name_ru": "Selectel Цветочная-2", + "name_en": "Selectel Tsvetochnaya-2", + "name_uk": "Selectel Цвєточна-2", + "city_ru": "Санкт-Петербург", + "city_en": "St Petersburg", + "city_uk": "Санкт-Петербург", + "operator": "Selectel", + "mw": "10", + "status": "operational", + "year": 2015, + "photo_source_url": "https://habrastorage.org/r/w1560/getpro/habr/post_images/215/75d/111/21575d1111b364f3112d7f76e9f8fcab.png", + "photo_credit": "Selectel, Хабр", + "info_url": "https://selectel.ru/about/data-centers/", + "position": 21 + }, + { + "slug": "yandex-sasovo", + "name_ru": "Яндекс Сасово", + "name_en": "Yandex Sasovo", + "name_uk": "Яндекс Сасово", + "city_ru": "Сасово", + "city_en": "Sasovo", + "city_uk": "Сасово", + "operator": "Яндекс", + "mw": null, + "status": "operational", + "year": 2014, + "photo_source_url": null, + "photo_credit": null, + "info_url": "https://www.vedomosti.ru/technology/articles/2026/10/08/1235289-chto-izvestno", + "position": 22 + }, + { + "slug": "rtk-ekb-3", + "name_ru": "РТК-ЦОД Екатеринбург-3", + "name_en": "RTK-DC Yekaterinburg-3", + "name_uk": "РТК-ЦОД Єкатеринбург-3", + "city_ru": "Екатеринбург", + "city_en": "Yekaterinburg", + "city_uk": "Єкатеринбург", + "operator": "РТК-ЦОД", + "mw": "6", + "status": "operational", + "year": 2018, + "photo_source_url": null, + "photo_credit": null, + "info_url": "https://www.comnews.ru/content/244980/2026-04-27/2026-w18/1007/bitva-za-ural-kto-zagruzit-novye-cody-ekaterinburga", + "position": 23 + }, + { + "slug": "rtk-nn", + "name_ru": "РТК-ЦОД Нижний Новгород", + "name_en": "RTK-DC Nizhny Novgorod", + "name_uk": "РТК-ЦОД Нижній Новгород", + "city_ru": "Нижний Новгород", + "city_en": "Nizhny Novgorod", + "city_uk": "Нижній Новгород", + "operator": "РТК-ЦОД", + "mw": "5", + "status": "operational", + "year": 2025, + "photo_source_url": "https://habrastorage.org/r/w1560/getpro/habr/upload_files/f8f/6b6/417/f8f6b6417ff5810a2efc00b7f71355c4.jpg", + "photo_credit": "РТК-ЦОД, Хабр", + "info_url": "https://www.comnews.ru/content/239515/2025-06-02/2025-w23/1011/rtk-cod-zapustil-data-centr-nizhnem-novgorode", + "position": 24 + }, + { + "slug": "selectel-dubrovka-1", + "name_ru": "Selectel Дубровка-1", + "name_en": "Selectel Dubrovka-1", + "name_uk": "Selectel Дубровка-1", + "city_ru": "Невская Дубровка", + "city_en": "Nevskaya Dubrovka", + "city_uk": "Невська Дубровка", + "operator": "Selectel", + "mw": "2,5", + "status": "operational", + "year": null, + "photo_source_url": "https://habrastorage.org/r/w1560/webt/c7/ye/v5/c7yev5bghjanyobpg0_mnxoiwlm.jpeg", + "photo_credit": "Selectel, Хабр", + "info_url": "https://selectel.ru/about/data-centers/", + "position": 25 + } + ], + "incidents": [ + { + "slug": "yandex-sasovo", + "occurred_on": "2026-10-08", + "kind": "strike", + "title_ru": "Атака БПЛА и пожар, работа ЦОД полностью остановлена", + "title_en": "Drone strike and fire; the datacenter is fully shut down", + "title_uk": "Атака БПЛА та пожежа, роботу ЦОД повністю зупинено", + "url": "https://www.vedomosti.ru/technology/articles/2026/10/08/1235289-chto-izvestno" + }, + { + "slug": "yandex-kaluga", + "occurred_on": "2026-10-09", + "kind": "strike", + "title_ru": "Удары БПЛА: часть инфраструктуры повреждена, несколько модулей выведены из строя", + "title_en": "Drone strikes: part of the infrastructure damaged, several modules knocked out", + "title_uk": "Удари БПЛА: частину інфраструктури пошкоджено, кілька модулів виведено з ладу", + "url": "https://www.bfm.ru/news/620355" + } + ] +} \ No newline at end of file diff --git a/db/migrations/004_bingo.rb b/db/migrations/004_bingo.rb new file mode 100644 index 0000000..ca452b4 --- /dev/null +++ b/db/migrations/004_bingo.rb @@ -0,0 +1,39 @@ +Sequel.migration do + change do + # Datacenter bingo (served on BINGO_HOST). Localized fields are plain + # per-locale columns; ru is the source of truth, en/uk fall back to it. + create_table(:bingo_datacenters) do + primary_key :id + String :slug, null: false, unique: true + Integer :position, null: false + String :name_ru, null: false + String :name_en + String :name_uk + String :city_ru, null: false + String :city_en + String :city_uk + String :operator, null: false + String :mw # free-form number: "64", "≈36", "25–30" + String :status, null: false, default: 'operational' # | building + Integer :year + String :photo_key # MinIO key under bingo/ + String :photo_source_url, text: true + String :photo_credit + String :info_url, text: true + DateTime :created_at, null: false + end + + create_table(:bingo_incidents) do + primary_key :id + foreign_key :datacenter_id, :bingo_datacenters, null: false, on_delete: :cascade + Date :occurred_on, null: false + String :kind, null: false # strike | fire | outage | flood | cooling | other + String :title_ru, null: false + String :title_en + String :title_uk + String :url, text: true + DateTime :created_at, null: false + index :datacenter_id + end + end +end diff --git a/docker-compose.yml b/docker-compose.yml index d7bd738..34e2d9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,15 @@ services: - "traefik.http.routers.asxpio.entrypoints=websecure" - "traefik.http.routers.asxpio.tls=true" - "traefik.http.routers.asxpio.tls.certresolver=letsencrypt" + - "traefik.http.routers.asxpio.service=asxpio" - "traefik.http.services.asxpio.loadbalancer.server.port=3000" + # Datacenter bingo: same container, routed by Host in config.ru. + # BINGO_HOST is interpolated from /opt/asxpio/.env (written by deploy). + - "traefik.http.routers.asxpio-bingo.rule=Host(`${BINGO_HOST}`)" + - "traefik.http.routers.asxpio-bingo.entrypoints=websecure" + - "traefik.http.routers.asxpio-bingo.tls=true" + - "traefik.http.routers.asxpio-bingo.tls.certresolver=letsencrypt" + - "traefik.http.routers.asxpio-bingo.service=asxpio" networks: traefik: diff --git a/lib/bingo.rb b/lib/bingo.rb new file mode 100644 index 0000000..4d77318 --- /dev/null +++ b/lib/bingo.rb @@ -0,0 +1,197 @@ +require 'sequel' +require 'json' +require 'net/http' +require 'securerandom' +require_relative 's3' + +# Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is +# crossed out once a publicly sourced incident is recorded for it. +module Bingo + LOCALES = %w[ru en uk].freeze + KINDS = %w[strike fire outage flood cooling other].freeze + ICONS = { 'strike' => '💥', 'fire' => '🔥', 'outage' => '⚡', 'flood' => '💧', 'cooling' => '🌡️', 'other' => '⚠️' }.freeze + SIZE = 5 + + # Every row, column and both diagonals, as 0-based cell indices. + LINES = ( + (0...SIZE).map { |r| (0...SIZE).map { |c| r * SIZE + c } } + + (0...SIZE).map { |c| (0...SIZE).map { |r| r * SIZE + c } } + + [(0...SIZE).map { |i| i * SIZE + i }, (0...SIZE).map { |i| i * SIZE + SIZE - 1 - i }] + ).freeze + + T = { + 'ru' => { + title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго', + desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.', + crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено', + log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.', + bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.', + building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник', + close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.', + footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.', + kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' }, + letters: %w[Б И Н Г О] + }, + 'en' => { + title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter', + desc: "Russia's 25 largest datacenters by power. A cell is crossed out after a publicly reported incident.", + crossed: 'Crossed out', lines: 'lines', updated: 'updated', + log: 'Incident log', empty: 'Everything is up. For now.', + bingo: 'BINGO!', bingo_n: 'BINGO ×%d!', full: 'FULL CARD. There is no more internet.', + building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source', + close: 'Close', no_incidents: 'No incidents recorded.', + footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.', + kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' }, + letters: %w[B I N G O] + }, + 'uk' => { + title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго', + desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.', + crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено', + log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.', + bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.', + building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело', + close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.', + footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.', + kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' }, + letters: %w[Б І Н Г О] + } + }.freeze + + # Localized column with fallback to ru. + module Localized + def l(field, locale) + v = self[:"#{field}_#{locale}"].to_s.strip + v.empty? ? self[:"#{field}_ru"] : v + end + end + + class Datacenter < Sequel::Model(:bingo_datacenters) + include Localized + one_to_many :incidents, class: 'Bingo::Incident', key: :datacenter_id, order: Sequel.desc(:occurred_on) + + def before_create + self.created_at ||= Time.now.utc + super + end + + def building? + status == 'building' + end + + def photo_file + 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 + end + + # Board state for rendering: ordered cells, hits and completed lines. + Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on) do + def self.load + cells = Datacenter.order(:position, :id).limit(SIZE * SIZE).eager(:incidents).all + hit = cells.map { |dc| dc.incidents.any? } + wins = LINES.select { |line| line.all? { |i| hit[i] } } + incidents = cells.flat_map(&:incidents).sort_by { |i| [i.occurred_on, i.id] }.reverse + updated = [Datacenter.max(:created_at), Incident.max(:created_at)].compact.max + new(cells, hit, wins, incidents, updated) + end + + def win_cells + @win_cells ||= wins.flatten.to_set + end + end + + # Photos live in the app's MinIO bucket under bingo/ and are proxied by the + # app, so the bucket stays private. + module Photo + PREFIX = 'bingo/'.freeze + MAX_BYTES = 8 * 1024 * 1024 + FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}\.(jpg|png|webp)\z/ + TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze + + class Error < StandardError; end + + module_function + + def sniff(bytes) + return 'jpg' if bytes.start_with?("\xFF\xD8\xFF".b) + return 'png' if bytes.start_with?("\x89PNG\r\n\x1A\n".b) + return 'webp' if bytes[0, 4] == 'RIFF' && bytes[8, 4] == 'WEBP' + nil + end + + # Stores bytes and returns the new key; the old object is left in place + # (cheap, and keeps cached URLs valid until their max-age runs out). + def store(slug, bytes) + bytes = bytes.b + raise Error, 'image too large (max 8 MB)' if bytes.bytesize > MAX_BYTES + ext = sniff(bytes) or raise Error, 'not a JPEG, PNG or WebP image' + key = "#{PREFIX}#{slug}-#{SecureRandom.hex(4)}.#{ext}" + S3.put(key, bytes, content_type: TYPES[ext]) + key + end + + # Admin-only "import from URL". https only, a few redirects, size-capped. + def fetch(url, redirects: 3) + uri = URI(url) + raise Error, 'https URL required' unless uri.is_a?(URI::HTTPS) + Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http| + req = Net::HTTP::Get.new(uri) + req['User-Agent'] = 'Mozilla/5.0 (bingo photo import)' + http.request(req) do |res| + case res + when Net::HTTPRedirection + raise Error, 'too many redirects' if redirects.zero? + return fetch(URI.join(uri, res['location']).to_s, redirects: redirects - 1) + when Net::HTTPSuccess + body = +'' + res.read_body do |chunk| + body << chunk + raise Error, 'image too large (max 8 MB)' if body.bytesize > MAX_BYTES + end + return body + else + raise Error, "HTTP #{res.code} from #{uri.host}" + end + end + end + rescue URI::InvalidURIError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, Errno::ECONNREFUSED => e + raise Error, e.message + end + + def read(file) + raise Error, 'bad name' unless file =~ FILE_RE + [S3.get(PREFIX + file), TYPES[Regexp.last_match(1)]] + end + end + + # First boot: fill the card (and any already-known incidents) from + # db/bingo_seed.json. Never touches rows once any exist; edits happen in + # /admin/bingo. + def self.seed! + return if Datacenter.count.positive? + path = File.join($root, 'db', 'bingo_seed.json') + return unless File.exist?(path) + seed = JSON.parse(File.read(path, encoding: 'UTF-8')) + cols = Datacenter.columns.map(&:to_s) - %w[id created_at] + DB.connection.transaction do + ids = seed.fetch('datacenters').each_with_index.to_h do |row, i| + dc = Datacenter.create(row.slice(*cols).merge('position' => row['position'] || i + 1)) + [dc.slug, dc.id] + end + seed.fetch('incidents', []).each do |row| + Incident.create(row.slice(*(Incident.columns.map(&:to_s) - %w[id created_at datacenter_id])) + .merge('datacenter_id' => ids.fetch(row.fetch('slug')))) + end + end + end +end diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb new file mode 100644 index 0000000..d74c6d4 --- /dev/null +++ b/lib/bingo_admin.rb @@ -0,0 +1,144 @@ +require 'uri' + +# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb. +# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply. +module BingoAdmin + DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw status + photo_credit photo_source_url info_url].freeze + + module Helpers + def bingo_public_url + ENV['BINGO_HOST'] && "https://#{ENV['BINGO_HOST']}/ru" + end + + def http_url?(s) + s.empty? || s.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https])) + end + + def blank_nil(s) + s = s.to_s.strip + s.empty? ? nil : s + end + end + + def self.registered(app) + app.helpers Helpers + + app.get '/admin/bingo' do + @page_title = 'Bingo · admin' + @noindex = true + @dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all + @incidents = Bingo::Incident.order(Sequel.desc(:occurred_on), Sequel.desc(:id)).eager(:datacenter).all + @flash = session.delete(:bingo_flash) + erb :'admin/bingo/index' + end + + app.get '/admin/bingo/dc/:id' do + @dc = Bingo::Datacenter[params[:id].to_i] or halt 404 + @page_title = "#{@dc.name_ru} · bingo admin" + @noindex = true + @flash = session.delete(:bingo_flash) + erb :'admin/bingo/edit' + end + + app.post '/admin/bingo/dc/:id' do + dc = Bingo::Datacenter[params[:id].to_i] or halt 404 + DC_TEXT.each { |f| dc[f.to_sym] = blank_nil(params[f]) } + dc.position = params[:position].to_i if params[:position].to_s =~ /\A\d+\z/ + dc.year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil + dc.status = 'operational' unless %w[operational building].include?(dc.status) + errs = [] + errs << 'Name (ru), city (ru) and operator are required' if [dc.name_ru, dc.city_ru, dc.operator].any?(&:nil?) + errs << 'URLs must be http(s)' unless [dc.photo_source_url, dc.info_url].all? { |u| http_url?(u.to_s) } + if errs.empty? + dc.save_changes + session[:bingo_flash] = 'Saved.' + else + session[:bingo_flash] = errs.join('; ') + end + redirect "/admin/bingo/dc/#{dc.id}" + end + + # Either an uploaded file or "import from URL" (defaults to photo_source_url). + app.post '/admin/bingo/dc/:id/photo' do + dc = Bingo::Datacenter[params[:id].to_i] or halt 404 + begin + bytes = + if params[:photo].is_a?(Hash) && params[:photo][:tempfile] + params[:photo][:tempfile].read(Bingo::Photo::MAX_BYTES + 1) + else + url = blank_nil(params[:url]) || dc.photo_source_url or raise Bingo::Photo::Error, 'no file and no URL' + dc.photo_source_url = url + Bingo::Photo.fetch(url) + end + dc.photo_key = Bingo::Photo.store(dc.slug, bytes) + dc.save_changes + session[:bingo_flash] = 'Photo stored.' + rescue Bingo::Photo::Error => e + session[:bingo_flash] = "Photo failed: #{e.message}" + end + redirect "/admin/bingo/dc/#{dc.id}" + end + + app.post '/admin/bingo/dc/:id/photo/delete' do + dc = Bingo::Datacenter[params[:id].to_i] or halt 404 + dc.update(photo_key: nil) + session[:bingo_flash] = 'Photo removed from card (object kept in bucket).' + redirect "/admin/bingo/dc/#{dc.id}" + end + + # Bulk: import every missing photo from its photo_source_url. + app.post '/admin/bingo/photos/import' do + ok, failed = 0, [] + Bingo::Datacenter.where(photo_key: nil).exclude(photo_source_url: nil).each do |dc| + dc.update(photo_key: Bingo::Photo.store(dc.slug, Bingo::Photo.fetch(dc.photo_source_url))) + ok += 1 + rescue Bingo::Photo::Error => e + failed << "#{dc.slug}: #{e.message}" + end + session[:bingo_flash] = (["Imported #{ok}."] + failed).join(' | ') + redirect '/admin/bingo' + end + + app.get '/admin/bingo/p/:file' do + bytes, type = Bingo::Photo.read(params[:file]) + content_type type + bytes + rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound + halt 404 + end + + app.post '/admin/bingo/incidents' do + dc = Bingo::Datacenter[params[:datacenter_id].to_i] + date = begin + Date.iso8601(params[:occurred_on].to_s) + rescue Date::Error + nil + end + url = params[:url].to_s.strip + errs = [] + errs << 'Pick a datacenter' unless dc + errs << 'Valid date required' unless date + errs << 'Unknown kind' unless Bingo::KINDS.include?(params[:kind]) + errs << 'Title (ru) required' if params[:title_ru].to_s.strip.empty? + errs << 'Source URL must be http(s)' unless http_url?(url) + if errs.empty? + Bingo::Incident.create( + datacenter_id: dc.id, occurred_on: date, kind: params[:kind], + title_ru: params[:title_ru].strip, title_en: blank_nil(params[:title_en]), + title_uk: blank_nil(params[:title_uk]), url: blank_nil(url) + ) + session[:bingo_flash] = "Incident recorded for #{dc.name_ru}." + else + session[:bingo_flash] = errs.join('; ') + end + redirect '/admin/bingo' + end + + app.post '/admin/bingo/incidents/:id/delete' do + Bingo::Incident.where(id: params[:id].to_i).delete + session[:bingo_flash] = 'Incident deleted.' + redirect '/admin/bingo' + end + end +end diff --git a/lib/s3.rb b/lib/s3.rb index 91cacd3..339ed84 100644 --- a/lib/s3.rb +++ b/lib/s3.rb @@ -40,6 +40,10 @@ module S3 key end + def get(key) + client.get_object(bucket: bucket, key: key).body.read + end + def exists?(key) client.head_object(bucket: bucket, key: key) true diff --git a/test/bingo_test.rb b/test/bingo_test.rb new file mode 100644 index 0000000..dcf9f47 --- /dev/null +++ b/test/bingo_test.rb @@ -0,0 +1,145 @@ +require_relative 'test_helper' + +class BingoTest < Minitest::Test + include Rack::Test::Methods + + JPEG = "\xFF\xD8\xFF\xE0fakejpeg".b + + def app + @app || BingoWeb + end + + 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 admin_env + { 'HTTP_AUTHORIZATION' => + "Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" } + 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, 'Бінго' + 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_photo_store_and_serve + Aws.config[:s3] = { stub_responses: { get_object: { body: JPEG } } } + S3.instance_variable_set(:@client, nil) + key = Bingo::Photo.store('dc-1', JPEG) + assert_match %r{\Abingo/dc-1-[0-9a-f]{8}\.jpg\z}, key + @dcs[0].update(photo_key: key) + get "/p/#{@dcs[0].photo_file}" + assert last_response.ok? + assert_equal 'image/jpeg', last_response.content_type + 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_photo_rejects_non_images + assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '') } + end + + # --- admin (served by AsxpioWeb) ------------------------------------------- + + def test_admin_requires_auth + @app = AsxpioWeb + get '/admin/bingo' + assert_equal 401, last_response.status + end + + def test_admin_records_and_deletes_incident + @app = AsxpioWeb + get '/admin/bingo', {}, admin_env + assert last_response.ok? + token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] + + post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dcs[3].id, + occurred_on: '2026-10-01', kind: 'fire', title_ru: 'Пожар', + url: 'https://example.com/news' }, admin_env + assert_equal 302, last_response.status + inc = Bingo::Incident.first + assert_equal 'fire', inc.kind + assert_equal @dcs[3].id, inc.datacenter_id + + post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dcs[3].id, + occurred_on: '2026-10-01', kind: 'fire', title_ru: 'x', + url: 'javascript:alert(1)' }, admin_env + assert_equal 1, Bingo::Incident.count + + post "/admin/bingo/incidents/#{inc.id}/delete", { authenticity_token: token }, admin_env + assert_equal 0, Bingo::Incident.count + end + + def test_admin_edits_datacenter + @app = AsxpioWeb + get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env + assert last_response.ok? + token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] + post "/admin/bingo/dc/#{@dcs[0].id}", { authenticity_token: token, name_ru: 'Новое', city_ru: 'Калуга', + name_uk: 'Нове', operator: 'Yandex', mw: '63', status: 'building', + year: '2027', position: '1' }, admin_env + @dcs[0].refresh + assert_equal 'Нове', @dcs[0].name_uk + assert @dcs[0].building? + assert_equal 2027, @dcs[0].year + end + + def test_upload_photo_via_admin + @app = AsxpioWeb + get "/admin/bingo/dc/#{@dcs[0].id}", {}, admin_env + token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] + file = Rack::Test::UploadedFile.new(StringIO.new(JPEG), 'image/jpeg', original_filename: 'a.jpg') + post "/admin/bingo/dc/#{@dcs[0].id}/photo", { authenticity_token: token, photo: file }, admin_env + assert_equal 302, last_response.status + assert_match %r{\Abingo/dc-1-}, @dcs[0].refresh.photo_key + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index d78c1b2..e25d351 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -27,6 +27,7 @@ else end require_relative '../asxpio' +require_relative '../bingo_web' require 'minitest/autorun' require 'rack/test' diff --git a/views/admin/bingo/edit.erb b/views/admin/bingo/edit.erb new file mode 100644 index 0000000..3582551 --- /dev/null +++ b/views/admin/bingo/edit.erb @@ -0,0 +1,52 @@ +
+

#<%= @dc.position %> <%= @dc.name_ru %>

+

← bingo

+
+ +<% if @flash %>
<%= @flash %>
<% end %> + +
+

Photo

+ <% if @dc.photo_file %> +

+
+ + +
+ <% end %> +
+ +
+
+ +
+
+ +
+

Details

+
+ +
+ <% %w[ru en uk].each do |l| %> +
" <%= 'required' if l == 'ru' %> />
+ <% end %> + <% %w[ru en uk].each do |l| %> +
" <%= 'required' if l == 'ru' %> />
+ <% end %> +
+
+
+ + +
+
+
+
+
+
+
+ +
+
diff --git a/views/admin/bingo/index.erb b/views/admin/bingo/index.erb new file mode 100644 index 0000000..1dff1f4 --- /dev/null +++ b/views/admin/bingo/index.erb @@ -0,0 +1,91 @@ +
+

Bingo

+

+ invoices + <% if bingo_public_url %> · public card ↗<% end %> +

+
+ +<% if @flash %>
<%= @flash %>
<% end %> + +
+

Record incident

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+
+ +
+

Incidents

+ <% if @incidents.empty? %> +

None yet.

+ <% else %> + + + + <% @incidents.each do |i| %> + + + + + + + + <% end %> + +
DateDatacenterKindTitle
<%= i.occurred_on %><%= i.datacenter.name_ru %><%= Bingo::ICONS[i.kind] %> <%= i.kind %><% if i.url %><%= i.title_ru %><% else %><%= i.title_ru %><% end %> +
+ + +
+
+ <% end %> +
+ +
+

Card

+
+ + +
+ + + + <% @dcs.each do |dc| %> + + + + + + + + + + + <% end %> + +
#PhotoNameOperatorMWStatusIncidents
<%= dc.position %><% if dc.photo_file %><% else %>—<% end %><%= dc.name_ru %>
<%= dc.city_ru %>
<%= dc.operator %><%= dc.mw %><%= dc.status %><%= dc.incidents.size %>edit
+
diff --git a/views/admin/invoices/index.erb b/views/admin/invoices/index.erb index 08241c0..51dd766 100644 --- a/views/admin/invoices/index.erb +++ b/views/admin/invoices/index.erb @@ -1,6 +1,6 @@ <% if @invoices.empty? %> diff --git a/views/bingo/index.erb b/views/bingo/index.erb new file mode 100644 index 0000000..681f008 --- /dev/null +++ b/views/bingo/index.erb @@ -0,0 +1,100 @@ +<% b = @board %> +
+
+

<%= t(:lead) %> <%= t(:accent) %>

+ +
+ +

<%= t(:desc) %>

+

+ <%= t(:crossed) %> <%= b.hit.count(true) %>/<%= b.cells.size %> + · <%= t(:lines) %> <%= b.wins.size %> + <% if b.updated_on %>· <%= t(:updated) %> <%= fmt_date(b.updated_on) %><% end %> +

+ + <% if b.wins.any? %> + + <% end %> + + +
+ <% b.cells.each_with_index do |dc, idx| %> + <% photo = photo_path(dc) %> + + <% end %> +
+ +
+

<%= t(:log) %>

+
    + <% if b.incidents.empty? %> +
  • <%= t(:empty) %>
  • + <% end %> + <% b.incidents.each do |i| %> +
  • + + <%= kind_icon(i.kind) %> +
    + <% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %> +
    <%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %>
    +
    +
  • + <% end %> +
+
+ +
<%= t(:footer) %>
+
+ +<% b.cells.each do |dc| %> + + <% if (photo = photo_path(dc)) %><%= dc.l(:name, @locale) %><% end %> +
+

<%= dc.l(:name, @locale) %>

+
+ <%= [dc.operator, mw(dc), dc.l(:city, @locale), dc.building? ? t(:building) : (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> +
+ <% if dc.photo_credit || dc.info_url %> +
+ <% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %> + <% if dc.info_url %> · <%= t(:source) %><% end %> +
+ <% end %> + <% if dc.incidents.empty? %> +

<%= t(:no_incidents) %>

+ <% else %> +
    + <% dc.incidents.each do |i| %> +
  • <%= kind_icon(i.kind) %> <%= fmt_date(i.occurred_on) %> — + <% if i.url %><%= i.l(:title, @locale) %><% else %><%= i.l(:title, @locale) %><% end %>
  • + <% end %> +
+ <% end %> +
+
+
+<% end %> + + diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb new file mode 100644 index 0000000..af36788 --- /dev/null +++ b/views/bingo/layout.erb @@ -0,0 +1,107 @@ +<% base = "https://#{ENV.fetch('BINGO_HOST', request.host)}" %> + + + + + +<%= t(:title) %> + + +<% Bingo::LOCALES.each do |l| %> + +<% end %> + + + + + + + + + +<%== yield %> + +