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
112 lines
4.2 KiB
Ruby
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
|