datacenterbingo/app.rb
Sergei Poljanski 062611fa21
Some checks failed
Build and Deploy to Production / test (push) Failing after 21s
Build and Deploy to Production / build (push) Has been skipped
Build and Deploy to Production / deploy (push) Has been skipped
Initial import of the public datacenter bingo site
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
2026-10-12 03:48:06 +04:00

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