From 201a70b07b89956111c732719024576786fa456f Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 06:26:03 +0400 Subject: [PATCH] bingo: SEO pass and resized photos The bingo host fell through to asxp.io's public/, so crawlers got asxp.io's robots.txt pointing at asxp.io's sitemap (and the keys and invoice JS were served too). Turn static files off for BingoWeb and give it its own robots.txt and a sitemap with hreflang alternates. The card loaded 15.6 MB of full-size photos as cell backgrounds. Derive 480/1200 px WebP variants with ImageMagick on store (memory/area limits, input coder pinned to the sniffed type), render cells as with lazy loading past the first two rows, and add an admin action that rebuilds variants from the stored originals. Head gets per-locale titles and descriptions carrying the live score, og:image from the latest hit, Twitter card, og:locale alternates, JSON-LD WebSite + ItemList, and Vary: Accept-Language on the redirect. Assisted-by: Claude:opus-5 --- CLAUDE.md | 3 +- Dockerfile | 1 + bingo_web.rb | 47 +++++++++++++++- db/migrations/005_bingo_photo_variants.rb | 6 +++ lib/bingo.rb | 65 +++++++++++++++++++++-- lib/bingo_admin.rb | 17 ++++-- test/bingo_test.rb | 56 +++++++++++++++++++ views/admin/bingo/index.erb | 6 ++- views/bingo/index.erb | 6 +-- views/bingo/layout.erb | 40 ++++++++++---- views/bingo/sitemap.erb | 12 +++++ 11 files changed, 236 insertions(+), 23 deletions(-) create mode 100644 db/migrations/005_bingo_photo_variants.rb create mode 100644 views/bingo/sitemap.erb 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 %> + + + +