The bingo card ran inside the asxpio container, dispatched by Host. Give the public site its own repo, image and deploy so it can be released and mirrored independently of asxp.io. Editing stays in the asxp.io admin, which keeps owning the schema and the photo pipeline; this app only reads the shared bingo tables and the bingo/ photos, through a read-only DB role and MinIO user. Tests load a snapshot of the asxpio-built schema (test/schema.sql). Assisted-by: Claude:opus-5
264 lines
10 KiB
Ruby
264 lines
10 KiB
Ruby
require 'logger'
|
|
require 'json'
|
|
require 'sinatra/base'
|
|
require 'erubi'
|
|
|
|
begin
|
|
require 'dotenv'
|
|
Dotenv.load unless ENV['RACK_ENV'] == 'test'
|
|
rescue LoadError
|
|
end
|
|
|
|
$root = __dir__
|
|
$logger = Logger.new($stdout)
|
|
$env = ENV.fetch('RACK_ENV', 'development')
|
|
|
|
require_relative 'lib/db'
|
|
if ENV['DATABASE_URL']
|
|
DB.connect!
|
|
require_relative 'lib/bingo'
|
|
else
|
|
$logger.warn('DATABASE_URL not set — every page answers 503')
|
|
end
|
|
|
|
# Public datacenter bingo on BINGO_HOST. Read-only: no session, no CSRF.
|
|
# Content is managed from asxp.io/admin/bingo (asxpio repo), which shares the
|
|
# database and photo bucket.
|
|
class BingoWeb < Sinatra::Base
|
|
HOSTS = [ENV['BINGO_HOST'], ENV['BINGO_ALT_HOST']].compact.reject(&:empty?).freeze
|
|
|
|
configure do
|
|
set :root, $root
|
|
set :erb, layout: :layout, escape_html: true
|
|
set :static, false # icons have explicit routes below
|
|
set :show_exceptions, $env == 'development'
|
|
# HSTS (1 year, this host only); http:// already 308s at the edge.
|
|
set :protection, use: %i[strict_transport]
|
|
set :host_authorization,
|
|
{ permitted_hosts: $env == 'production' ? [*HOSTS, '127.0.0.1'] : [] } # 127.0.0.1: HEALTHCHECK
|
|
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 (operational)
|
|
# keeps a bare URL, the full list is ?view=all.
|
|
# Path for a locale/view/provider selection; the default (operational,
|
|
# every provider) keeps a bare URL.
|
|
def card_path(locale = @locale, view = @view, providers = @providers)
|
|
q = []
|
|
q << 'view=all' if view == 'all'
|
|
Array(providers).each { |p| q << "p[]=#{Rack::Utils.escape(p)}" }
|
|
q.empty? ? "/#{locale}" : "/#{locale}?#{q.join('&')}"
|
|
end
|
|
|
|
def selected_providers
|
|
Array(params[:p]).flat_map { |v| v.to_s.split(',') }.select { |p| Bingo::PROVIDERS.key?(p) }.uniq
|
|
end
|
|
|
|
def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}"
|
|
|
|
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,
|
|
'dateModified' => board.updated_on&.utc&.iso8601,
|
|
'itemListElement' => board.cells.each_with_index.map do |dc, i|
|
|
{ '@type' => 'ListItem', 'position' => i + 1, 'item' => ld_place(dc) }
|
|
end }.compact
|
|
]
|
|
).gsub('</', '<\/')
|
|
end
|
|
|
|
# The info_url describes the datacenter (operator page, news), it isn't
|
|
# the datacenter's own identity, so subjectOf rather than sameAs.
|
|
# Incidents are Events at the place.
|
|
def ld_place(dc)
|
|
address = { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' }
|
|
url = "#{base_url}#{dc_path(dc)}"
|
|
place = { '@type' => 'Place', '@id' => "#{url}#place", 'url' => url, 'name' => dc.l(:name, @locale), 'address' => address }
|
|
place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key
|
|
place['subjectOf'] = { '@type' => 'CreativeWork', 'url' => dc.info_url } if dc.info_url
|
|
events = dc.incidents.map do |i|
|
|
{ '@type' => 'Event', 'name' => "#{kind_label(i.kind)}: #{i.l(:title, @locale)}",
|
|
'startDate' => i.occurred_on.iso8601, 'url' => i.url,
|
|
'location' => { '@type' => 'Place', 'name' => place['name'], 'address' => address } }.compact
|
|
end
|
|
place['event'] = events if events.any?
|
|
place
|
|
end
|
|
|
|
# Datacenter page: the Place plus a breadcrumb back to the card.
|
|
def dc_json_ld(dc)
|
|
JSON.generate(
|
|
'@context' => 'https://schema.org',
|
|
'@graph' => [
|
|
ld_place(dc),
|
|
{ '@type' => 'BreadcrumbList', 'itemListElement' => [
|
|
{ '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" },
|
|
{ '@type' => 'ListItem', 'position' => 2, 'name' => dc.l(:name, @locale), 'item' => "#{base_url}#{dc_path(dc)}" }
|
|
] }
|
|
]
|
|
).gsub('</', '<\/')
|
|
end
|
|
|
|
# Plain-language facts for the datacenter page and its meta description.
|
|
def dc_facts(dc)
|
|
[format(t(:dc_intro), name: dc.l(:name, @locale), city: dc.l(:city, @locale), operator: dc.operator),
|
|
(format(t(:dc_capacity), mw: mw_prose(dc)) if mw(dc)),
|
|
{ 'building' => t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)),
|
|
format(t(:dc_rank), pos: dc.position)].compact
|
|
end
|
|
|
|
def dc_latest(dc)
|
|
i = dc.incidents.first or return t(:no_incidents)
|
|
format(t(:dc_latest), date: fmt_date(i.occurred_on), title: i.l(:title, @locale))
|
|
end
|
|
|
|
# Cut at a word boundary to fit a search snippet.
|
|
def snippet(text, max = 160)
|
|
text.length <= max ? text : "#{text[0, max - 1][/\A.*(?=\s)/m] || text[0, max - 1]}…"
|
|
end
|
|
|
|
# Preview image: the most recent hit with a photo, else the first photo.
|
|
def og_dc(board)
|
|
board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
|
|
end
|
|
def fmt_date(d) = d.strftime('%d.%m.%Y')
|
|
|
|
# "64 MW", "64 MW · design", "17 MVA" (MVA is a unit, not a tag).
|
|
def mw(dc, tag: true)
|
|
return if dc.mw.to_s.strip.empty?
|
|
return "#{dc.mw} #{t(:mva)}" if dc.mw_type == 'mva'
|
|
tag &&= t(:mw_types)[dc.mw_type]
|
|
["#{dc.mw} #{t(:mw)}", tag || nil].compact.join(' · ')
|
|
end
|
|
|
|
# "63 MW (design)": the dialog's " · design" tag reads badly in a sentence.
|
|
def mw_prose(dc)
|
|
tag = dc.mw_type != 'mva' && t(:mw_types)[dc.mw_type]
|
|
tag ? "#{mw(dc, tag: false)} (#{tag})" : mw(dc)
|
|
end
|
|
|
|
# Badge for anything not yet in service.
|
|
def status_label(dc)
|
|
{ 'building' => t(:building), 'planned' => t(:planned) }[dc.status]
|
|
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
|
|
|
|
get '/healthz' do
|
|
halt 503, 'no database' unless defined?(Bingo::Datacenter)
|
|
DB.connection.test_connection
|
|
'ok'
|
|
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
|
|
|
|
# The card and every datacenter page (reserve included), each with its
|
|
# language alternates.
|
|
get '/sitemap.xml' do
|
|
content_type 'application/xml'
|
|
cache_control :public, max_age: 3600
|
|
dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
|
|
@entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] +
|
|
dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] }
|
|
erb :sitemap, layout: false
|
|
end
|
|
|
|
# One URL per page: /en/ is a duplicate of /en.
|
|
get %r{/((?:ru|en|uk)(?:/dc/[a-z0-9-]+)?)/} do |path|
|
|
qs = request.query_string
|
|
redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301
|
|
end
|
|
|
|
get %r{/(ru|en|uk)} do |locale|
|
|
@locale = locale
|
|
@view = params[:view] == 'all' ? 'all' : Bingo::VIEWS.first
|
|
@providers = selected_providers
|
|
@board = Bingo::Board.load(@view, providers: @providers)
|
|
@title = t(:seo_title)
|
|
@desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size)
|
|
@noindex = !@board.operational_only? || @board.filtered? # variants of the bare page
|
|
@path_for = ->(l) { "/#{l}" }
|
|
@x_default = '/'
|
|
@og = og_dc(@board)
|
|
@ld = json_ld(@board, @desc)
|
|
cache_control :public, max_age: 60
|
|
erb :index
|
|
end
|
|
|
|
get %r{/(ru|en|uk)/dc/([a-z0-9-]+)} do |locale, slug|
|
|
@locale = locale
|
|
@dc = Bingo::Datacenter.where(slug: slug).eager(:incidents).first or halt 404
|
|
@prev, @next = [-1, 1].map { |d| Bingo::Datacenter.first(position: @dc.position + d) }
|
|
name, city = @dc.l(:name, locale), @dc.l(:city, locale)
|
|
hit = @dc.incidents.first
|
|
@title = hit ? format(t(:dc_title_hit), name: name, city: city, kind: kind_label(hit.kind), date: fmt_date(hit.occurred_on))
|
|
: format(t(:dc_title), name: name, city: city)
|
|
@desc = snippet([*dc_facts(@dc).first(2), dc_latest(@dc)].join(' '))
|
|
@path_for = ->(l) { dc_path(@dc, l) }
|
|
@og = @dc if @dc.photo_key
|
|
@ld = dc_json_ld(@dc)
|
|
cache_control :public, max_age: 60
|
|
erb :dc
|
|
end
|
|
|
|
# IndexNow key file: proves to the engines that pings come from us.
|
|
get %r{/([A-Za-z0-9-]{8,128})\.txt} do |name|
|
|
halt 404 unless name == Bingo::IndexNow.key
|
|
content_type 'text/plain'
|
|
name
|
|
end
|
|
|
|
# Icons from assets/. Search engines want
|
|
# a crawlable favicon URL; a data: URI doesn't count.
|
|
ICONS = { 'favicon.svg' => 'image/svg+xml', 'favicon.ico' => 'image/x-icon',
|
|
'apple-touch-icon.png' => 'image/png' }.freeze
|
|
|
|
get %r{/(favicon\.svg|favicon\.ico|apple-touch-icon\.png)} do |name|
|
|
cache_control :public, max_age: 86_400
|
|
send_file File.join(settings.root, 'assets', name), type: ICONS[name]
|
|
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
|