diff --git a/CLAUDE.md b/CLAUDE.md index 8753da4..dc4ce38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,8 @@ A second public site served from the **same process** on `BINGO_HOST` (`datacent - 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; add, edit, delete DCs (delete cascades to incidents); upload a photo or import it from a URL; bulk import missing / re-import all photos from `photo_source_url`; "Sort by MW". - Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts, `renumber!` after deletes). The card shows positions 1–25; anything beyond is reserve. Slug is fixed at creation (it names photo objects). -- 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: 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, metadata stripped (GPS!). URL import is https-only, admin-only. On store, ImageMagick (`convert`, installed in the Dockerfile) derives `-480.webp` (card cells) and `-1200.webp` (dialog, og:image); `photo_variants` records success and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals. +- SEO: `BingoWeb` has `static` off (public/ is asxp.io's), its own `/robots.txt` and `/sitemap.xml` (hreflang alternates), per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo), JSON-LD WebSite + ItemList. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete. - 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. diff --git a/Dockerfile b/Dockerfile index 0c69314..69bfe4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM ruby:3.4-slim-bookworm RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ + imagemagick \ && rm -rf /var/lib/apt/lists/* RUN groupadd -g 11000 asxpio \ diff --git a/bingo_web.rb b/bingo_web.rb index 600259c..d04cdf4 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -8,6 +8,9 @@ class BingoWeb < Sinatra::Base set :root, $root set :views, File.join($root, 'views', 'bingo') set :erb, layout: :layout, escape_html: true + # public/ belongs to asxp.io (its robots.txt, sitemap, keys, admin JS); + # the card needs none of it. + set :static, false set :show_exceptions, $env == 'development' set :host_authorization, { permitted_hosts: $env == 'production' ? HOSTS : [] } @@ -17,7 +20,36 @@ class BingoWeb < Sinatra::Base 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 photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}" + 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, + 'itemListElement' => board.cells.each_with_index.map do |dc, i| + place = { '@type' => 'Place', 'name' => dc.l(:name, @locale), + 'address' => { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' } } + place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key + place['sameAs'] = dc.info_url if dc.info_url + { '@type' => 'ListItem', 'position' => i + 1, 'item' => place } + end } + ] + ).gsub(' '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 + + get '/sitemap.xml' do + content_type 'application/xml' + cache_control :public, max_age: 3600 + @lastmod = Bingo::Board.load.updated_on + erb :sitemap, layout: false + end + get %r{/(ru|en|uk)/?} do |locale| @locale = locale @board = Bingo::Board.load diff --git a/db/migrations/005_bingo_photo_variants.rb b/db/migrations/005_bingo_photo_variants.rb new file mode 100644 index 0000000..92a20b2 --- /dev/null +++ b/db/migrations/005_bingo_photo_variants.rb @@ -0,0 +1,6 @@ +Sequel.migration do + change do + # true once bingo/--{480,1200}.webp exist next to the original + add_column :bingo_datacenters, :photo_variants, TrueClass, null: false, default: false + end +end diff --git a/lib/bingo.rb b/lib/bingo.rb index 4f170ad..53ba5f5 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -2,6 +2,7 @@ require 'sequel' require 'json' require 'net/http' require 'securerandom' +require 'open3' require_relative 's3' # Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is @@ -21,6 +22,9 @@ module Bingo T = { 'ru' => { + seo_title: 'Бинго ЦОДов — атаки, пожары и аварии дата-центров России', + seo_desc: '%{hit} из %{total} крупнейших дата-центров России уже зачёркнуты. Бинго-карточка ЦОДов IXcellerate, Яндекса, Ростелекома, Росатома и других: атаки БПЛА, пожары и отключения с источниками.', + og_locale: 'ru_RU', title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго', desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.', crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено', @@ -33,6 +37,9 @@ module Bingo letters: %w[Б И Н Г О] }, 'en' => { + seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', + seo_desc: '%{hit} of Russia\'s %{total} largest datacenters are already crossed out. A bingo card of IXcellerate, Yandex, Rostelecom, Rosatom and other datacenters: drone strikes, fires and outages, each with a source.', + og_locale: 'en_US', title: 'Datacenter Bingo', accent: 'Bingo', lead: 'Datacenter', desc: "Russia's 25 largest datacenters by power. A cell is crossed out after a publicly reported incident.", crossed: 'Crossed out', lines: 'lines', updated: 'updated', @@ -45,6 +52,9 @@ module Bingo letters: %w[B I N G O] }, 'uk' => { + seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', + seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.', + og_locale: 'uk_UA', title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго', desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.', crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено', @@ -79,8 +89,12 @@ module Bingo status == 'building' end - def photo_file - photo_key&.delete_prefix(Photo::PREFIX) + # width: a Photo::WIDTHS variant (falls back to the original until the + # variants exist), nil for the original. + def photo_file(width = nil) + return unless photo_key + key = width && photo_variants ? Photo.variant_key(photo_key, width) : photo_key + key.delete_prefix(Photo::PREFIX) end end @@ -115,7 +129,9 @@ module Bingo module Photo PREFIX = 'bingo/'.freeze MAX_BYTES = 8 * 1024 * 1024 - FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}\.(jpg|png|webp)\z/ + FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200))?\.(jpg|png|webp)\z/ + WIDTHS = [480, 1200].freeze # card cell (2x), dialog / og:image + CONVERT = ENV.fetch('BINGO_CONVERT', 'convert') # ImageMagick TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze class Error < StandardError; end @@ -141,6 +157,49 @@ module Bingo key end + # Stores the photo for dc and makes it current. Variants are best effort: + # without them the card falls back to the original. + def attach(dc, bytes) + key = store(dc.slug, bytes) + dc.update(photo_key: key, photo_variants: build_variants(key, bytes)) + key + end + + # Re-derives the variants from the stored original. + def rebuild(dc) + dc.update(photo_variants: build_variants(dc.photo_key, S3.get(dc.photo_key))) + end + + def variant_key(key, width) + key.sub(/\.\w+\z/, "-#{width}.webp") + end + + def build_variants(key, bytes) + ext = sniff(bytes) or raise Error, 'not an image' + WIDTHS.each { |w| S3.put(variant_key(key, w), resize(bytes, ext, w), content_type: TYPES['webp']) } + true + rescue Error => e + $logger.warn("bingo: no variants for #{key}: #{e.message}") + false + end + + # Downscale (never up) to fit width x width, re-encode as WebP; -strip + # drops metadata again. The input coder is pinned to the sniffed type and + # memory/area limits guard against decompression bombs. + def resize(bytes, ext, width) + coder = { 'jpg' => 'jpeg', 'png' => 'png', 'webp' => 'webp' }.fetch(ext) + out, err, st = Open3.capture3( + CONVERT, '-limit', 'memory', '256MiB', '-limit', 'area', '128MP', + "#{coder}:-", '-auto-orient', '-resize', "#{width}x#{width}>", + '-strip', '-quality', '80', 'webp:-', + stdin_data: bytes, binmode: true + ) + raise Error, "convert: #{err.lines.first&.strip}" unless st.success? && sniff(out.b) == 'webp' + out.b + rescue Errno::ENOENT + raise Error, 'ImageMagick (convert) not installed' + end + # Photos are served byte-for-byte, so drop EXIF/XMP/IPTC/text metadata: # phone shots carry GPS coordinates, and the card stays city-level. # Pixel data is untouched; malformed input is rejected rather than diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index c4ffd19..63ac4ac 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -127,9 +127,9 @@ module BingoAdmin 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.' + Bingo::Photo.attach(dc, bytes) + session[:bingo_flash] = dc.photo_variants ? 'Photo stored.' : 'Photo stored (no resized variants; check logs).' rescue Bingo::Photo::Error => e session[:bingo_flash] = "Photo failed: #{e.message}" end @@ -138,7 +138,7 @@ module BingoAdmin app.post '/admin/bingo/dc/:id/photo/delete' do dc = Bingo::Datacenter[params[:id].to_i] or halt 404 - dc.update(photo_key: nil) + dc.update(photo_key: nil, photo_variants: false) session[:bingo_flash] = 'Photo removed from card (object kept in bucket).' redirect "/admin/bingo/dc/#{dc.id}" end @@ -150,7 +150,7 @@ module BingoAdmin scope = Bingo::Datacenter.exclude(photo_source_url: nil) scope = scope.where(photo_key: nil) unless params[:all] == '1' scope.each do |dc| - dc.update(photo_key: Bingo::Photo.store(dc.slug, Bingo::Photo.fetch(dc.photo_source_url))) + Bingo::Photo.attach(dc, Bingo::Photo.fetch(dc.photo_source_url)) ok += 1 rescue Bingo::Photo::Error => e failed << "#{dc.slug}: #{e.message}" @@ -159,6 +159,15 @@ module BingoAdmin redirect '/admin/bingo' end + # Regenerate resized variants from the originals already in the bucket. + app.post '/admin/bingo/photos/rebuild' do + dcs = Bingo::Datacenter.exclude(photo_key: nil).all + dcs.each { |dc| Bingo::Photo.rebuild(dc) } + failed = dcs.reject(&:photo_variants).map(&:slug) + session[:bingo_flash] = "Rebuilt #{dcs.size - failed.size}/#{dcs.size}.#{" Failed: #{failed.join(', ')}" if failed.any?}" + redirect '/admin/bingo' + end + app.get '/admin/bingo/p/:file' do bytes, type = Bingo::Photo.read(params[:file]) content_type type diff --git a/test/bingo_test.rb b/test/bingo_test.rb index a6729d9..87f5578 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -114,6 +114,62 @@ class BingoTest < Minitest::Test assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V') 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}" } + assert_includes last_response.body, 'hreflang="x-default"' + end + + def test_asxpio_static_files_not_served + get '/style.css' + assert_equal 404, last_response.status + get '/pgp.pub' + assert_equal 404, last_response.status + 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) + get '/en' + body = last_response.body + assert_includes body, 'Datacenter Bingo — strikes' + 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"' + 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'] + 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_resize_produces_small_webp + skip 'ImageMagick not available' unless system(Bingo::Photo::CONVERT, '-version', out: File::NULL, err: File::NULL) + png, = Open3.capture2(Bingo::Photo::CONVERT, '-size', '2000x1000', 'gradient:red-blue', 'png:-', binmode: true) + out = Bingo::Photo.resize(png.b, 'png', 480) + assert_equal 'webp', Bingo::Photo.sniff(out) + dims, = Open3.capture2(Bingo::Photo::CONVERT, 'webp:-', '-format', '%wx%h', 'info:', stdin_data: out, binmode: true) + assert_equal '480x240', dims + end + def test_photo_rejects_non_images assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') } end diff --git a/views/admin/bingo/index.erb b/views/admin/bingo/index.erb index fd8ec49..95ecc92 100644 --- a/views/admin/bingo/index.erb +++ b/views/admin/bingo/index.erb @@ -76,6 +76,10 @@ <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" /> <button type="submit" class="link-button">↻ Import all missing photos from their source URLs</button> </form> + <form method="post" action="/admin/bingo/photos/rebuild"> + <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" /> + <button type="submit" class="link-button">⟳ Rebuild resized photos from stored originals</button> + </form> <form method="post" action="/admin/bingo/photos/import" onsubmit="return confirm('Re-download every photo that has a source URL? Manually uploaded photos with a source URL get replaced too.')"> <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" /> <input type="hidden" name="all" value="1" /> @@ -90,7 +94,7 @@ <% end %> <tr> <td class="mono"><%= dc.position %></td> - <td><% if dc.photo_file %><img src="/admin/bingo/p/<%= dc.photo_file %>" alt="" width="64" height="40" style="object-fit: cover" /><% else %>—<% end %></td> + <td><% if dc.photo_file %><img src="/admin/bingo/p/<%= dc.photo_file(480) %>" alt="" width="64" height="40" style="object-fit: cover" /><% else %>—<% end %></td> <td><%= dc.name_ru %><br /><span class="optional"><%= dc.city_ru %></span></td> <td><%= dc.operator %></td> <td class="num mono"><%= dc.mw %></td> diff --git a/views/bingo/index.erb b/views/bingo/index.erb index bf809f1..e8c2e6c 100644 --- a/views/bingo/index.erb +++ b/views/bingo/index.erb @@ -25,11 +25,11 @@ <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) %> + <% photo = photo_path(dc, 480) %> <button type="button" data-dlg="dlg-<%= dc.slug %>" class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>" - <% if photo %>style="background-image: url('<%= photo %>')"<% end %> 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)}" if mw(dc) %></div> <% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %> @@ -70,7 +70,7 @@ <% b.cells.each do |dc| %> <dialog id="dlg-<%= dc.slug %>"> - <% if (photo = photo_path(dc)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>" loading="lazy"><% end %> + <% 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> <div class="meta"> diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb index 5a1d402..c3d3a76 100644 --- a/views/bingo/layout.erb +++ b/views/bingo/layout.erb @@ -1,20 +1,39 @@ -<% base = "https://#{ENV.fetch('BINGO_HOST', request.host)}" %> +<% base = base_url %> +<% desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) %> +<% og_img = og_photo(@board) %> <!doctype html> <html lang="<%= @locale %>"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title><%= t(:title) %> - +<%= t(:seo_title) %> + + + <% Bingo::LOCALES.each do |l| %> <% end %> + - - + + + + +<% (Bingo::LOCALES - [@locale]).each do |l| %> + +<% end %> +<% if og_img %> + + + +<% end %> + + + +