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 <img> 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
This commit is contained in:
parent
70f1734c5d
commit
201a70b07b
11 changed files with 236 additions and 23 deletions
|
|
@ -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.
|
- 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".
|
- 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).
|
- 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/<slug>-<hex>.<ext>` (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/<slug>-<hex>.<ext>` (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.
|
- 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.
|
- No street addresses or coordinates on the page — city level only.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ FROM ruby:3.4-slim-bookworm
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
imagemagick \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN groupadd -g 11000 asxpio \
|
RUN groupadd -g 11000 asxpio \
|
||||||
|
|
|
||||||
47
bingo_web.rb
47
bingo_web.rb
|
|
@ -8,6 +8,9 @@ class BingoWeb < Sinatra::Base
|
||||||
set :root, $root
|
set :root, $root
|
||||||
set :views, File.join($root, 'views', 'bingo')
|
set :views, File.join($root, 'views', 'bingo')
|
||||||
set :erb, layout: :layout, escape_html: true
|
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 :show_exceptions, $env == 'development'
|
||||||
set :host_authorization,
|
set :host_authorization,
|
||||||
{ permitted_hosts: $env == 'production' ? HOSTS : [] }
|
{ permitted_hosts: $env == 'production' ? HOSTS : [] }
|
||||||
|
|
@ -17,7 +20,36 @@ class BingoWeb < Sinatra::Base
|
||||||
def t(key) = Bingo::T[@locale][key]
|
def t(key) = Bingo::T[@locale][key]
|
||||||
def kind_label(kind) = Bingo::T[@locale][:kinds][kind] || Bingo::T[@locale][:kinds]['other']
|
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 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('</', '<\/')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Preview image: the most recent hit with a photo, else the first photo.
|
||||||
|
def og_photo(board)
|
||||||
|
dc = board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
|
||||||
|
dc && "#{base_url}#{photo_path(dc, 1200)}"
|
||||||
|
end
|
||||||
def fmt_date(d) = d.strftime('%d.%m.%Y')
|
def fmt_date(d) = d.strftime('%d.%m.%Y')
|
||||||
|
|
||||||
def mw(dc)
|
def mw(dc)
|
||||||
|
|
@ -35,9 +67,22 @@ class BingoWeb < Sinatra::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
get '/' do
|
get '/' do
|
||||||
|
headers 'Vary' => 'Accept-Language'
|
||||||
redirect "/#{preferred_locale}", 302
|
redirect "/#{preferred_locale}", 302
|
||||||
end
|
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|
|
get %r{/(ru|en|uk)/?} do |locale|
|
||||||
@locale = locale
|
@locale = locale
|
||||||
@board = Bingo::Board.load
|
@board = Bingo::Board.load
|
||||||
|
|
|
||||||
6
db/migrations/005_bingo_photo_variants.rb
Normal file
6
db/migrations/005_bingo_photo_variants.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Sequel.migration do
|
||||||
|
change do
|
||||||
|
# true once bingo/<slug>-<hex>-{480,1200}.webp exist next to the original
|
||||||
|
add_column :bingo_datacenters, :photo_variants, TrueClass, null: false, default: false
|
||||||
|
end
|
||||||
|
end
|
||||||
65
lib/bingo.rb
65
lib/bingo.rb
|
|
@ -2,6 +2,7 @@ require 'sequel'
|
||||||
require 'json'
|
require 'json'
|
||||||
require 'net/http'
|
require 'net/http'
|
||||||
require 'securerandom'
|
require 'securerandom'
|
||||||
|
require 'open3'
|
||||||
require_relative 's3'
|
require_relative 's3'
|
||||||
|
|
||||||
# Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is
|
# Datacenter bingo: a 5x5 card of the largest Russian datacenters; a cell is
|
||||||
|
|
@ -21,6 +22,9 @@ module Bingo
|
||||||
|
|
||||||
T = {
|
T = {
|
||||||
'ru' => {
|
'ru' => {
|
||||||
|
seo_title: 'Бинго ЦОДов — атаки, пожары и аварии дата-центров России',
|
||||||
|
seo_desc: '%{hit} из %{total} крупнейших дата-центров России уже зачёркнуты. Бинго-карточка ЦОДов IXcellerate, Яндекса, Ростелекома, Росатома и других: атаки БПЛА, пожары и отключения с источниками.',
|
||||||
|
og_locale: 'ru_RU',
|
||||||
title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго',
|
title: 'Бинго ЦОДов', accent: 'ЦОДов', lead: 'Бинго',
|
||||||
desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
|
desc: 'Топ-25 дата-центров России по мощности. Клетка зачёркивается после публично подтверждённого инцидента.',
|
||||||
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
|
crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено',
|
||||||
|
|
@ -33,6 +37,9 @@ module Bingo
|
||||||
letters: %w[Б И Н Г О]
|
letters: %w[Б И Н Г О]
|
||||||
},
|
},
|
||||||
'en' => {
|
'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',
|
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.",
|
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',
|
crossed: 'Crossed out', lines: 'lines', updated: 'updated',
|
||||||
|
|
@ -45,6 +52,9 @@ module Bingo
|
||||||
letters: %w[B I N G O]
|
letters: %w[B I N G O]
|
||||||
},
|
},
|
||||||
'uk' => {
|
'uk' => {
|
||||||
|
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
|
||||||
|
seo_desc: '%{hit} з %{total} найбільших дата-центрів Росії вже закреслено. Бінго-картка ЦОДів IXcellerate, Яндекса, Ростелекому, Росатому та інших: атаки БПЛА, пожежі й відключення з джерелами.',
|
||||||
|
og_locale: 'uk_UA',
|
||||||
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
|
title: 'Бінго ЦОДів', accent: 'ЦОДів', lead: 'Бінго',
|
||||||
desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
|
desc: 'Топ-25 дата-центрів Росії за потужністю. Клітинка закреслюється після публічно підтвердженого інциденту.',
|
||||||
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
|
crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено',
|
||||||
|
|
@ -79,8 +89,12 @@ module Bingo
|
||||||
status == 'building'
|
status == 'building'
|
||||||
end
|
end
|
||||||
|
|
||||||
def photo_file
|
# width: a Photo::WIDTHS variant (falls back to the original until the
|
||||||
photo_key&.delete_prefix(Photo::PREFIX)
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -115,7 +129,9 @@ module Bingo
|
||||||
module Photo
|
module Photo
|
||||||
PREFIX = 'bingo/'.freeze
|
PREFIX = 'bingo/'.freeze
|
||||||
MAX_BYTES = 8 * 1024 * 1024
|
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
|
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
|
||||||
|
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
@ -141,6 +157,49 @@ module Bingo
|
||||||
key
|
key
|
||||||
end
|
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:
|
# Photos are served byte-for-byte, so drop EXIF/XMP/IPTC/text metadata:
|
||||||
# phone shots carry GPS coordinates, and the card stays city-level.
|
# phone shots carry GPS coordinates, and the card stays city-level.
|
||||||
# Pixel data is untouched; malformed input is rejected rather than
|
# Pixel data is untouched; malformed input is rejected rather than
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,9 @@ module BingoAdmin
|
||||||
dc.photo_source_url = url
|
dc.photo_source_url = url
|
||||||
Bingo::Photo.fetch(url)
|
Bingo::Photo.fetch(url)
|
||||||
end
|
end
|
||||||
dc.photo_key = Bingo::Photo.store(dc.slug, bytes)
|
|
||||||
dc.save_changes
|
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
|
rescue Bingo::Photo::Error => e
|
||||||
session[:bingo_flash] = "Photo failed: #{e.message}"
|
session[:bingo_flash] = "Photo failed: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
@ -138,7 +138,7 @@ module BingoAdmin
|
||||||
|
|
||||||
app.post '/admin/bingo/dc/:id/photo/delete' do
|
app.post '/admin/bingo/dc/:id/photo/delete' do
|
||||||
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
|
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).'
|
session[:bingo_flash] = 'Photo removed from card (object kept in bucket).'
|
||||||
redirect "/admin/bingo/dc/#{dc.id}"
|
redirect "/admin/bingo/dc/#{dc.id}"
|
||||||
end
|
end
|
||||||
|
|
@ -150,7 +150,7 @@ module BingoAdmin
|
||||||
scope = Bingo::Datacenter.exclude(photo_source_url: nil)
|
scope = Bingo::Datacenter.exclude(photo_source_url: nil)
|
||||||
scope = scope.where(photo_key: nil) unless params[:all] == '1'
|
scope = scope.where(photo_key: nil) unless params[:all] == '1'
|
||||||
scope.each do |dc|
|
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
|
ok += 1
|
||||||
rescue Bingo::Photo::Error => e
|
rescue Bingo::Photo::Error => e
|
||||||
failed << "#{dc.slug}: #{e.message}"
|
failed << "#{dc.slug}: #{e.message}"
|
||||||
|
|
@ -159,6 +159,15 @@ module BingoAdmin
|
||||||
redirect '/admin/bingo'
|
redirect '/admin/bingo'
|
||||||
end
|
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
|
app.get '/admin/bingo/p/:file' do
|
||||||
bytes, type = Bingo::Photo.read(params[:file])
|
bytes, type = Bingo::Photo.read(params[:file])
|
||||||
content_type type
|
content_type type
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,62 @@ class BingoTest < Minitest::Test
|
||||||
assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V')
|
assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_robots_and_sitemap_are_the_bingos_own
|
||||||
|
get '/robots.txt'
|
||||||
|
assert last_response.ok?
|
||||||
|
assert_includes last_response.body, 'Sitemap: https://example.org/sitemap.xml'
|
||||||
|
refute_includes last_response.body, 'asxp.io'
|
||||||
|
get '/sitemap.xml'
|
||||||
|
assert last_response.ok?
|
||||||
|
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
|
||||||
|
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, '<title>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
|
def test_photo_rejects_non_images
|
||||||
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
|
assert_raises(Bingo::Photo::Error) { Bingo::Photo.store('dc-1', '<html>') }
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@
|
||||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||||
<button type="submit" class="link-button">↻ Import all missing photos from their source URLs</button>
|
<button type="submit" class="link-button">↻ Import all missing photos from their source URLs</button>
|
||||||
</form>
|
</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.')">
|
<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="authenticity_token" value="<%= csrf_token %>" />
|
||||||
<input type="hidden" name="all" value="1" />
|
<input type="hidden" name="all" value="1" />
|
||||||
|
|
@ -90,7 +94,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono"><%= dc.position %></td>
|
<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.name_ru %><br /><span class="optional"><%= dc.city_ru %></span></td>
|
||||||
<td><%= dc.operator %></td>
|
<td><%= dc.operator %></td>
|
||||||
<td class="num mono"><%= dc.mw %></td>
|
<td class="num mono"><%= dc.mw %></td>
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@
|
||||||
<div class="letters" aria-hidden="true"><% t(:letters).each do |ch| %><div><%= ch %></div><% end %></div>
|
<div class="letters" aria-hidden="true"><% t(:letters).each do |ch| %><div><%= ch %></div><% end %></div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<% b.cells.each_with_index do |dc, idx| %>
|
<% 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 %>"
|
<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) %>"
|
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] %>">
|
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>
|
||||||
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc)}" if mw(dc) %></div>
|
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc)}" if mw(dc) %></div>
|
||||||
<% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %>
|
<% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %>
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
|
|
||||||
<% b.cells.each do |dc| %>
|
<% b.cells.each do |dc| %>
|
||||||
<dialog id="dlg-<%= dc.slug %>">
|
<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">
|
<div class="body">
|
||||||
<h3><%= dc.l(:name, @locale) %></h3>
|
<h3><%= dc.l(:name, @locale) %></h3>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<!doctype html>
|
||||||
<html lang="<%= @locale %>">
|
<html lang="<%= @locale %>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><%= t(:title) %></title>
|
<title><%= t(:seo_title) %></title>
|
||||||
<meta name="description" content="<%= t(:desc) %>">
|
<meta name="description" content="<%= desc %>">
|
||||||
|
<meta name="robots" content="index, follow, max-image-preview:large">
|
||||||
|
<meta name="theme-color" content="#121212">
|
||||||
<link rel="canonical" href="<%= base %>/<%= @locale %>">
|
<link rel="canonical" href="<%= base %>/<%= @locale %>">
|
||||||
<% Bingo::LOCALES.each do |l| %>
|
<% Bingo::LOCALES.each do |l| %>
|
||||||
<link rel="alternate" hreflang="<%= l %>" href="<%= base %>/<%= l %>">
|
<link rel="alternate" hreflang="<%= l %>" href="<%= base %>/<%= l %>">
|
||||||
<% end %>
|
<% end %>
|
||||||
<link rel="alternate" hreflang="x-default" href="<%= base %>/">
|
<link rel="alternate" hreflang="x-default" href="<%= base %>/">
|
||||||
|
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:title" content="<%= t(:title) %>">
|
<meta property="og:site_name" content="<%= t(:title) %>">
|
||||||
<meta property="og:description" content="<%= t(:desc) %>">
|
<meta property="og:title" content="<%= t(:seo_title) %>">
|
||||||
|
<meta property="og:description" content="<%= desc %>">
|
||||||
<meta property="og:url" content="<%= base %>/<%= @locale %>">
|
<meta property="og:url" content="<%= base %>/<%= @locale %>">
|
||||||
|
<meta property="og:locale" content="<%= t(:og_locale) %>">
|
||||||
|
<% (Bingo::LOCALES - [@locale]).each do |l| %>
|
||||||
|
<meta property="og:locale:alternate" content="<%= Bingo::T[l][:og_locale] %>">
|
||||||
|
<% end %>
|
||||||
|
<% if og_img %>
|
||||||
|
<meta property="og:image" content="<%= og_img %>">
|
||||||
|
<meta property="og:image:alt" content="<%= t(:title) %>">
|
||||||
|
<meta name="twitter:image" content="<%= og_img %>">
|
||||||
|
<% end %>
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="<%= t(:seo_title) %>">
|
||||||
|
<meta name="twitter:description" content="<%= desc %>">
|
||||||
|
<script type="application/ld+json"><%== json_ld(@board, desc) %></script>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎱</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎱</text></svg>">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -52,13 +71,14 @@
|
||||||
.grid { margin-top: 6px; }
|
.grid { margin-top: 6px; }
|
||||||
.cell {
|
.cell {
|
||||||
position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%;
|
position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%;
|
||||||
background: var(--card) center / cover no-repeat; border: 1px solid var(--line); border-radius: 8px;
|
background: var(--card); border: 1px solid var(--line); border-radius: 8px;
|
||||||
display: flex; flex-direction: column; justify-content: space-between;
|
display: flex; flex-direction: column; justify-content: space-between;
|
||||||
text-align: left; color: inherit; font: inherit; cursor: pointer;
|
text-align: left; color: inherit; font: inherit; cursor: pointer;
|
||||||
}
|
}
|
||||||
.cell.photo { color: var(--on-photo); border-color: transparent; }
|
.cell.photo { color: var(--on-photo); border-color: transparent; }
|
||||||
.cell.photo::before { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
|
.cell .bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
|
||||||
.cell > * { position: relative; z-index: 1; }
|
.cell.photo::before { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
|
||||||
|
.cell > :not(.bg) { position: relative; z-index: 2; }
|
||||||
.cell .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
.cell .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
.cell.photo .n, .cell.photo .sub { color: var(--on-photo-muted); }
|
.cell.photo .n, .cell.photo .sub { color: var(--on-photo-muted); }
|
||||||
.cell .name { font-weight: 700; font-size: clamp(11px, 1.9vw, 15px); line-height: 1.15; }
|
.cell .name { font-weight: 700; font-size: clamp(11px, 1.9vw, 15px); line-height: 1.15; }
|
||||||
|
|
@ -67,12 +87,12 @@
|
||||||
.cell.hit { border-color: var(--hit); }
|
.cell.hit { border-color: var(--hit); }
|
||||||
.cell.hit:not(.photo) { background-color: var(--hit-bg); }
|
.cell.hit:not(.photo) { background-color: var(--hit-bg); }
|
||||||
.cell.hit::after {
|
.cell.hit::after {
|
||||||
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; opacity: .8;
|
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: .8;
|
||||||
background:
|
background:
|
||||||
linear-gradient(to top right, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px)),
|
linear-gradient(to top right, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px)),
|
||||||
linear-gradient(to top left, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px));
|
linear-gradient(to top left, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px));
|
||||||
}
|
}
|
||||||
.cell .stamp { position: absolute; right: 6px; top: 4px; font-size: clamp(16px, 3vw, 24px); z-index: 3; }
|
.cell .stamp { position: absolute; right: 6px; top: 4px; font-size: clamp(16px, 3vw, 24px); z-index: 4; }
|
||||||
.cell.win { box-shadow: 0 0 0 3px var(--win); }
|
.cell.win { box-shadow: 0 0 0 3px var(--win); }
|
||||||
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
|
||||||
|
|
|
||||||
12
views/bingo/sitemap.erb
Normal file
12
views/bingo/sitemap.erb
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
<% Bingo::LOCALES.each do |l| %>
|
||||||
|
<url>
|
||||||
|
<loc><%= base_url %>/<%= l %></loc>
|
||||||
|
<% if @lastmod %> <lastmod><%= @lastmod.utc.strftime('%Y-%m-%d') %></lastmod>
|
||||||
|
<% end %> <changefreq>daily</changefreq>
|
||||||
|
<% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %>/<%= alt %>"/>
|
||||||
|
<% end %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %>/"/>
|
||||||
|
</url>
|
||||||
|
<% end %>
|
||||||
|
</urlset>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue