asxpio/lib/bingo_admin.rb
Sergei Poljanski 201a70b07b
All checks were successful
Build and Deploy to Production / test (push) Successful in 22s
Build and Deploy to Production / build (push) Successful in 1m1s
Build and Deploy to Production / deploy (push) Successful in 39s
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
2026-10-10 06:26:03 +04:00

212 lines
7.7 KiB
Ruby

require 'uri'
# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb.
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
module BingoAdmin
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw status
photo_credit photo_source_url info_url].freeze
module Helpers
def bingo_public_url
ENV['BINGO_HOST'] && "https://#{ENV['BINGO_HOST']}/ru"
end
def http_url?(s)
s.empty? || s.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https]))
end
def position_param
params[:position].to_s =~ /\A\d+\z/ ? params[:position].to_i : nil
end
# Copies the edit form onto dc (position is handled by Bingo.place!).
def assign_dc(dc)
DC_TEXT.each { |f| dc[f.to_sym] = blank_nil(params[f]) }
dc.year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
dc.status = 'operational' unless %w[operational building].include?(dc.status)
errs = []
errs << 'Name (ru), city (ru) and operator are required' if [dc.name_ru, dc.city_ru, dc.operator].any?(&:nil?)
errs << 'URLs must be http(s)' unless [dc.photo_source_url, dc.info_url].all? { |u| http_url?(u.to_s) }
errs
end
def blank_nil(s)
s = s.to_s.strip
s.empty? ? nil : s
end
end
def self.registered(app)
app.helpers Helpers
app.get '/admin/bingo' do
@page_title = 'Bingo · admin'
@noindex = true
@dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
@incidents = Bingo::Incident.order(Sequel.desc(:occurred_on), Sequel.desc(:id)).eager(:datacenter).all
@flash = session.delete(:bingo_flash)
erb :'admin/bingo/index'
end
app.get '/admin/bingo/dc/new' do
@dc = Bingo::Datacenter.new(status: 'operational')
@page_title = 'New datacenter · bingo admin'
@noindex = true
erb :'admin/bingo/edit'
end
app.post '/admin/bingo/dc' do
@dc = Bingo::Datacenter.new
@dc.slug = params[:slug].to_s.strip.downcase
errs = assign_dc(@dc)
errs.unshift('Slug: lowercase letters, digits and dashes') unless @dc.slug.match?(/\A[a-z0-9]+(-[a-z0-9]+)*\z/)
errs.unshift('Slug already taken') if Bingo::Datacenter.where(slug: @dc.slug).any?
if errs.any?
@flash = errs.join('; ')
@page_title = 'New datacenter · bingo admin'
@noindex = true
status 422
return erb :'admin/bingo/edit'
end
@dc.position = Bingo::Datacenter.count + 1
@dc.save
Bingo.place!(@dc, position_param)
session[:bingo_flash] = "Added #{@dc.name_ru} at ##{@dc.refresh.position}."
redirect "/admin/bingo/dc/#{@dc.id}"
end
app.get '/admin/bingo/dc/:id' do
@dc = Bingo::Datacenter[params[:id].to_i] or halt 404
@page_title = "#{@dc.name_ru} · bingo admin"
@noindex = true
@flash = session.delete(:bingo_flash)
erb :'admin/bingo/edit'
end
app.post '/admin/bingo/dc/:id' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
errs = assign_dc(dc)
if errs.empty?
dc.save_changes
Bingo.place!(dc, position_param) if position_param && position_param != dc.position
session[:bingo_flash] = 'Saved.'
else
session[:bingo_flash] = errs.join('; ')
end
redirect "/admin/bingo/dc/#{dc.id}"
end
# Deletes the row and (FK cascade) its incidents; the photo object stays
# in the bucket.
app.post '/admin/bingo/dc/:id/delete' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
n = dc.incidents.size
DB.connection.transaction do
dc.destroy
Bingo.renumber!
end
session[:bingo_flash] = "Deleted #{dc.name_ru}#{" and #{n} incident(s)" if n.positive?}."
redirect '/admin/bingo'
end
app.post '/admin/bingo/sort' do
Bingo.sort_by_mw!
session[:bingo_flash] = 'Sorted by MW (unknown last).'
redirect '/admin/bingo'
end
# Either an uploaded file or "import from URL" (defaults to photo_source_url).
app.post '/admin/bingo/dc/:id/photo' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
begin
bytes =
if params[:photo].is_a?(Hash) && params[:photo][:tempfile]
params[:photo][:tempfile].read(Bingo::Photo::MAX_BYTES + 1)
else
url = blank_nil(params[:url]) || dc.photo_source_url or raise Bingo::Photo::Error, 'no file and no URL'
dc.photo_source_url = url
Bingo::Photo.fetch(url)
end
dc.save_changes
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
session[:bingo_flash] = "Photo failed: #{e.message}"
end
redirect "/admin/bingo/dc/#{dc.id}"
end
app.post '/admin/bingo/dc/:id/photo/delete' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
dc.update(photo_key: nil, photo_variants: false)
session[:bingo_flash] = 'Photo removed from card (object kept in bucket).'
redirect "/admin/bingo/dc/#{dc.id}"
end
# Bulk: import every missing photo from its photo_source_url, or with
# all=1 re-import every photo that has a source URL.
app.post '/admin/bingo/photos/import' do
ok, failed = 0, []
scope = Bingo::Datacenter.exclude(photo_source_url: nil)
scope = scope.where(photo_key: nil) unless params[:all] == '1'
scope.each do |dc|
Bingo::Photo.attach(dc, Bingo::Photo.fetch(dc.photo_source_url))
ok += 1
rescue Bingo::Photo::Error => e
failed << "#{dc.slug}: #{e.message}"
end
session[:bingo_flash] = (["Imported #{ok}."] + failed).join(' | ')
redirect '/admin/bingo'
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
bytes, type = Bingo::Photo.read(params[:file])
content_type type
bytes
rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
halt 404
end
app.post '/admin/bingo/incidents' do
dc = Bingo::Datacenter[params[:datacenter_id].to_i]
date = begin
Date.iso8601(params[:occurred_on].to_s)
rescue Date::Error
nil
end
url = params[:url].to_s.strip
errs = []
errs << 'Pick a datacenter' unless dc
errs << 'Valid date required' unless date
errs << 'Unknown kind' unless Bingo::KINDS.include?(params[:kind])
errs << 'Title (ru) required' if params[:title_ru].to_s.strip.empty?
errs << 'Source URL must be http(s)' unless http_url?(url)
if errs.empty?
Bingo::Incident.create(
datacenter_id: dc.id, occurred_on: date, kind: params[:kind],
title_ru: params[:title_ru].strip, title_en: blank_nil(params[:title_en]),
title_uk: blank_nil(params[:title_uk]), url: blank_nil(url)
)
session[:bingo_flash] = "Incident recorded for #{dc.name_ru}."
else
session[:bingo_flash] = errs.join('; ')
end
redirect '/admin/bingo'
end
app.post '/admin/bingo/incidents/:id/delete' do
Bingo::Incident.where(id: params[:id].to_i).delete
session[:bingo_flash] = 'Incident deleted.'
redirect '/admin/bingo'
end
end
end