asxpio/bingo_web.rb
Sergei Poljanski 9e885dd8c1
bingo: operational-only view of the card
The card mixes facilities in service with construction projects (Sber
Balakovo at #1, MOS7, MOS11, Yandex Vladimir-2), which is fine for a
bingo but not when you want to compare what actually runs today. Add a
toggle that builds the card from operational rows only, in the same
ranking, with bingo lines recomputed for that card. The filtered view is
noindex with canonical to the plain locale URL, so it does not compete
with the main page in search.

Assisted-by: Claude:opus-5
2026-10-10 06:58:30 +04:00

112 lines
4.2 KiB
Ruby

# 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
HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze
configure do
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 : [] }
end
helpers do
def t(key) = Bingo::T[@locale][key]
def kind_label(kind) = Bingo::T[@locale][:kinds][kind] || Bingo::T[@locale][:kinds]['other']
def kind_icon(kind) = Bingo::ICONS[kind] || Bingo::ICONS['other']
def photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}"
# Path for a locale/view combination; the default view keeps a bare URL.
def card_path(locale = @locale, view = @view)
view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}"
end
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 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
headers 'Vary' => 'Accept-Language'
redirect "/#{preferred_locale}", 302
end
get '/robots.txt' do
content_type 'text/plain'
"User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n"
end
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
@view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all'
@board = Bingo::Board.load(@view)
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