datacenterbingo gets a public page showing whether each datacenter's network answers. The probing lives here, next to the schema and the admin, since the public site only has a read-only role. bin/monitor runs as a second container from the same image. Once a minute it opens one TCP connection to every active target (a handshake or a refusal both count as answered) and folds the results into 5-minute rows per datacenter, plus a per-bucket row for control hosts in Russia, so a lost vantage point isn't mistaken for dark sites. Rows are kept 30 days. Targets are managed at /admin/bingo/monitor; only hosts a public source ties to that one site belong there. Migration 017 also grants the new tables to the bingo role, so the public site can read them without a storage redeploy. Assisted-by: Claude:opus-5-5
487 lines
17 KiB
Ruby
487 lines
17 KiB
Ruby
require 'logger'
|
||
require 'bigdecimal'
|
||
require 'sinatra/base'
|
||
require 'sinatra/contrib'
|
||
require 'erubi'
|
||
require 'uri'
|
||
|
||
begin
|
||
require 'dotenv'
|
||
# Tests control ENV themselves; a developer's .env must not leak in (it may
|
||
# point DATABASE_URL at a real database).
|
||
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/fmt'
|
||
require_relative 'lib/mailer'
|
||
require_relative 'lib/rate_limit'
|
||
require_relative 'lib/db'
|
||
require_relative 'lib/admin_auth'
|
||
require_relative 'lib/s3'
|
||
require_relative 'lib/bingo_admin'
|
||
require_relative 'lib/users_admin'
|
||
|
||
# Invoice model needs a live Sequel connection at class-definition time.
|
||
if ENV['DATABASE_URL']
|
||
DB.connect!
|
||
DB.migrate!
|
||
require_relative 'lib/invoice'
|
||
require_relative 'lib/crypto_asset'
|
||
require_relative 'lib/crypto_rate'
|
||
require_relative 'lib/crypto_qr'
|
||
require_relative 'lib/gel_rate'
|
||
require_relative 'lib/invoice_pdf'
|
||
require_relative 'lib/invoice_stats'
|
||
require_relative 'lib/bingo'
|
||
require_relative 'lib/bingo_monitor'
|
||
Bingo.seed!
|
||
require_relative 'lib/user'
|
||
require_relative 'lib/audit'
|
||
require_relative 'lib/payout_address'
|
||
PayoutAddress.seed_from_env!
|
||
User.bootstrap!(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
||
else
|
||
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
||
end
|
||
|
||
class AsxpioWeb < Sinatra::Base
|
||
RATE_LIMIT = RateLimit.new(limit: 5, window: 3600)
|
||
|
||
configure do
|
||
set :root, $root
|
||
set :erb, layout: :layout, escape_html: true
|
||
set :show_exceptions, $env == 'development'
|
||
# Traefik only routes our hostnames anyway; this is defense in depth.
|
||
# 127.0.0.1 is the Docker HEALTHCHECK. Empty list (= check disabled)
|
||
# outside production so dev and rack-test hosts keep working.
|
||
set :host_authorization,
|
||
{ permitted_hosts: $env == 'production' ? ['asxp.io', 'www.asxp.io', '127.0.0.1'] : [] }
|
||
end
|
||
|
||
use Rack::Session::Cookie,
|
||
key: 'asxpio.session',
|
||
path: '/',
|
||
secret: ENV.fetch('SESSION_SECRET'),
|
||
expire_after: 3600,
|
||
same_site: :lax
|
||
|
||
use Rack::Protection::AuthenticityToken
|
||
register AdminAuth
|
||
register BingoAdmin
|
||
register UsersAdmin
|
||
|
||
Mailer.configure!
|
||
|
||
helpers do
|
||
def csrf_token
|
||
Rack::Protection::AuthenticityToken.token(session)
|
||
end
|
||
|
||
def client_ip
|
||
request.env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip ||
|
||
request.env['HTTP_X_REAL_IP'] ||
|
||
request.ip
|
||
end
|
||
|
||
def fmt_rate(value, min_dp = 4)
|
||
Fmt.rate(value, min_dp: min_dp)
|
||
end
|
||
|
||
def fmt_crypto(value)
|
||
Fmt.crypto(value)
|
||
end
|
||
|
||
# Hidden invoices exist only for superusers in the admin.
|
||
def visible_invoices
|
||
current_user&.superuser ? Invoice.dataset : Invoice.where(hidden: false)
|
||
end
|
||
|
||
def admin_invoice!(uuid)
|
||
visible_invoices.first(uuid: uuid) or halt 404
|
||
end
|
||
end
|
||
|
||
before do
|
||
cache_control :private, :must_revalidate, max_age: 0
|
||
end
|
||
|
||
get '/' do
|
||
erb :index
|
||
end
|
||
|
||
get '/keys' do
|
||
@page_title = 'Keys · Sergei Poljanski'
|
||
@page_desc = 'PGP fingerprint and SSH public key of Sergei Poljanski. Verify or download the key files.'
|
||
erb :keys
|
||
end
|
||
|
||
get '/contact' do
|
||
@page_title = 'Contact · Sergei Poljanski'
|
||
@page_desc = 'Contact Sergei Poljanski: email, Telegram, phone. Legal entity details for invoices and contracts.'
|
||
erb :contact
|
||
end
|
||
|
||
# Liveness for the Docker HEALTHCHECK and the deploy pipeline. Pings the DB
|
||
# when invoicing is configured — a wedged connection pool should read as
|
||
# unhealthy, not just "process exists".
|
||
get '/healthz' do
|
||
begin
|
||
DB.connection['SELECT 1'].get if defined?(Invoice)
|
||
rescue Sequel::Error
|
||
halt 503, 'db unreachable'
|
||
end
|
||
'ok'
|
||
end
|
||
|
||
# --- Admin: invoices ----------------------------------------------------
|
||
|
||
before '/admin/*' do
|
||
halt 503, 'Invoicing not configured (DATABASE_URL missing).' unless defined?(Invoice)
|
||
end
|
||
|
||
before '/i/*' do
|
||
halt 503, 'Invoicing not configured (DATABASE_URL missing).' unless defined?(Invoice)
|
||
end
|
||
|
||
# Overview of every admin area; each tile links into its section.
|
||
get '/admin' do
|
||
@page_title = 'Dashboard · admin'
|
||
@noindex = true
|
||
if can?('invoices', 'read')
|
||
unpaid = visible_invoices.where(paid_at: nil, cancelled_at: nil).all
|
||
@inv = {
|
||
count: visible_invoices.count,
|
||
unpaid: unpaid,
|
||
overdue: unpaid.count { |i| i.due_on < Date.today },
|
||
unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) },
|
||
recent: visible_invoices.order(Sequel.desc(:created_at)).limit(5).all
|
||
}
|
||
end
|
||
if can?('bingo', 'read')
|
||
board = Bingo::Board.load('operational') # same card as the public default
|
||
@bingo = {
|
||
cells: board.cells.size,
|
||
hit: board.hit.count(true),
|
||
lines: board.wins.size,
|
||
reserve: [Bingo::Datacenter.count - board.cells.size, 0].max,
|
||
last_incident: board.incidents.first,
|
||
no_photo: board.cells.reject(&:photo_key)
|
||
}
|
||
end
|
||
erb :'admin/dashboard'
|
||
end
|
||
|
||
get '/admin/invoices' do
|
||
@page_title = 'Invoices · admin'
|
||
@noindex = true
|
||
@invoices = visible_invoices.order(Sequel.desc(:created_at)).all
|
||
erb :'admin/invoices/index'
|
||
end
|
||
|
||
# Live price for the new-invoice form's "Fetch live" button, using the
|
||
# chosen payout address's CoinGecko id. GEL is derived from the gel_rate
|
||
# the operator typed into the form.
|
||
get '/admin/crypto-rate' do
|
||
content_type :json
|
||
payout = PayoutAddress[params[:payout].to_i]
|
||
currency = params[:currency].to_s.upcase
|
||
gel_rate = params[:gel_rate].to_s.strip
|
||
halt 404, { error: 'Unknown payout address' }.to_json unless payout
|
||
begin
|
||
rate = CryptoRate.fetch(payout.coingecko, currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
|
||
{ rate: rate.round(8).to_s('F'), coin: payout.coin, currency: currency }.to_json
|
||
rescue CryptoRate::Error => e
|
||
status 502
|
||
{ error: e.message }.to_json
|
||
end
|
||
end
|
||
|
||
# Official GEL rate from the National Bank of Georgia for the new-invoice
|
||
# form's "Fetch official" button. The rate is GEL per 1 unit of the invoice
|
||
# currency (USD/EUR), so it tracks the form's selected currency. Pass date=
|
||
# for a past invoice (NBG rolls back to the last published rate on
|
||
# weekends/holidays). GEL invoices have a trivial rate of 1 — no fetch needed.
|
||
get '/admin/gel-rate' do
|
||
content_type :json
|
||
currency = params[:currency].to_s.upcase
|
||
currency = 'USD' if currency.empty?
|
||
date = params[:date].to_s.strip
|
||
|
||
if currency == 'GEL'
|
||
return { rate: '1', currency: 'GEL', date: date }.to_json
|
||
end
|
||
|
||
begin
|
||
rate = GelRate.fetch(currency, date: date.empty? ? nil : date)
|
||
{ rate: rate.to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, ''), currency: currency, date: date }.to_json
|
||
rescue GelRate::Error => e
|
||
status 502
|
||
{ error: e.message }.to_json
|
||
end
|
||
end
|
||
|
||
# Defined before /admin/invoices/:uuid, which would otherwise match it.
|
||
get '/admin/invoices/stats' do
|
||
@page_title = 'Invoice statistics · admin'
|
||
@noindex = true
|
||
year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
|
||
@stats = InvoiceStats.new(visible_invoices.all, year: year)
|
||
erb :'admin/invoices/stats'
|
||
end
|
||
|
||
get '/admin/invoices/payout-addresses' do
|
||
@page_title = 'Payout addresses · admin'
|
||
@noindex = true
|
||
@rows = PayoutAddress.listing
|
||
@flash = session.delete(:payout_flash)
|
||
erb :'admin/invoices/payout_addresses'
|
||
end
|
||
|
||
# One form edits every row (payout[id][...]); payout[new][...] adds one,
|
||
# filling empty fields from the built-in template for a known ticker.
|
||
post '/admin/invoices/payout-addresses' do
|
||
errors, changed = [], []
|
||
(params['payout'] || {}).each do |id, raw|
|
||
fresh = id == 'new'
|
||
next if fresh && raw['address'].to_s.strip.empty?
|
||
v = PayoutAddress.clean(raw, fill_from_template: fresh)
|
||
if (e = PayoutAddress.error_for(v)) then errors << e; next end
|
||
stamp = { updated_by: current_user.username, updated_at: Time.now.utc }
|
||
if fresh
|
||
row = PayoutAddress.create(**v, active: true, **stamp)
|
||
changed << "+#{row.coin} #{row.name}"
|
||
else
|
||
row = PayoutAddress[id.to_i] or next
|
||
row.set(**v, active: raw['active'] == '1')
|
||
next unless row.modified?
|
||
row.set(**stamp).save_changes
|
||
changed << "#{row.coin} #{row.name}"
|
||
end
|
||
end
|
||
audit!('invoice.payout_addresses', nil, changed.join('; ')) if changed.any?
|
||
session[:payout_flash] = errors.any? ? errors.join('; ') : (changed.any? ? 'Saved.' : 'Nothing changed.')
|
||
redirect '/admin/invoices/payout-addresses'
|
||
end
|
||
|
||
# Existing invoices keep their own copy, so removal is safe.
|
||
post '/admin/invoices/payout-addresses/:id/delete' do
|
||
row = PayoutAddress[params[:id].to_i] or halt 404
|
||
row.destroy
|
||
audit!('invoice.payout_address_delete', row.coin, "#{row.name} #{row.address}")
|
||
session[:payout_flash] = "Removed #{row.coin} · #{row.name}."
|
||
redirect '/admin/invoices/payout-addresses'
|
||
end
|
||
|
||
get '/admin/invoices/new' do
|
||
@page_title = 'New invoice · admin'
|
||
@noindex = true
|
||
@form_errors = nil
|
||
@form_values = {}
|
||
erb :'admin/invoices/new'
|
||
end
|
||
|
||
# Start a new invoice prefilled from an existing one ("use as template").
|
||
# Reuses the new-invoice form via @form_values. Issue date resets to today and
|
||
# the due date keeps the source's payment term (same issued→due gap, anchored
|
||
# to today). The snapshotted LTC rate/amount are dropped (stale prices) — the
|
||
# address is kept so the operator only re-fetches the live rate.
|
||
get '/admin/invoices/:uuid/duplicate' do
|
||
src = admin_invoice!(params[:uuid])
|
||
term = (src.due_on - src.issued_on).to_i
|
||
@page_title = 'New invoice · admin'
|
||
@noindex = true
|
||
@form_errors = nil
|
||
@form_values = {
|
||
client_name: src.client_name,
|
||
client_email: src.client_email,
|
||
client_address: src.client_address,
|
||
currency: src.currency,
|
||
gel_rate: src.gel_rate.to_s,
|
||
issued_on: Date.today.strftime('%Y-%m-%d'),
|
||
due_on: (Date.today + term).strftime('%Y-%m-%d'),
|
||
notes: src.notes,
|
||
payout_id: PayoutAddress.first(coin: src.crypto_coin, address: src.crypto_address, active: true)&.id.to_s,
|
||
items: src.items_array
|
||
}
|
||
erb :'admin/invoices/new'
|
||
end
|
||
|
||
post '/admin/invoices' do
|
||
@form_values = {
|
||
client_name: params[:client_name].to_s.strip,
|
||
client_email: params[:client_email].to_s.strip,
|
||
client_address: params[:client_address].to_s.strip,
|
||
currency: params[:currency].to_s.upcase,
|
||
gel_rate: params[:gel_rate].to_s.strip,
|
||
issued_on: params[:issued_on].to_s.strip,
|
||
due_on: params[:due_on].to_s.strip,
|
||
notes: params[:notes].to_s.strip,
|
||
payout_id: params[:payout_id].to_s.strip,
|
||
crypto_rate: params[:crypto_rate].to_s.strip,
|
||
crypto_amount: params[:crypto_amount].to_s.strip,
|
||
items: (params[:items] || {}).values
|
||
}
|
||
@form_errors = validate_invoice_params(@form_values)
|
||
|
||
if @form_errors.any?
|
||
@page_title = 'New invoice · admin'
|
||
@noindex = true
|
||
status 422
|
||
return erb :'admin/invoices/new'
|
||
end
|
||
|
||
# The whole build→render→upload→save sequence retries on a duplicate
|
||
# number: two concurrent creates (Puma threads) can both compute MAX+1.
|
||
# The PDF embeds the number, so a retry must re-render, not just re-save.
|
||
# A losing attempt orphans its two S3 objects; harmless and near-impossible
|
||
# with a single operator.
|
||
build_params = @form_values.merge(@payout ? @payout.snapshot : {})
|
||
|
||
attempts = 0
|
||
begin
|
||
invoice = Invoice.build(build_params)
|
||
# Base key (no suffix); the two status variants get -pending/-paid appended.
|
||
invoice.pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf"
|
||
%w[pending paid].each do |st|
|
||
S3.put(invoice.pdf_key_for(st), InvoicePdf.render(invoice, status: st))
|
||
end
|
||
invoice.save_changes
|
||
rescue Sequel::UniqueConstraintViolation
|
||
attempts += 1
|
||
raise if attempts > 2
|
||
retry
|
||
end
|
||
|
||
audit!('invoice.create', invoice.number, "#{invoice.client_name}, #{invoice.currency} #{'%.2f' % invoice.total}")
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
get '/admin/invoices/:uuid' do
|
||
@invoice = admin_invoice!(params[:uuid])
|
||
@page_title = "#{@invoice.number} · admin"
|
||
@noindex = true
|
||
erb :'admin/invoices/show'
|
||
end
|
||
|
||
post '/admin/invoices/:uuid/paid' do
|
||
invoice = admin_invoice!(params[:uuid])
|
||
halt 422, 'Cancelled invoices cannot be marked paid; reinstate it first.' if invoice.cancelled?
|
||
invoice.paid_at = invoice.paid? ? nil : Time.now.utc
|
||
invoice.save_changes
|
||
audit!(invoice.paid? ? 'invoice.paid' : 'invoice.unpaid', invoice.number)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
# Cancel: a third PDF variant with a CANCELLED stamp becomes current, so
|
||
# the client's link and download switch over. Reinstate undoes it.
|
||
post '/admin/invoices/:uuid/cancel' do
|
||
invoice = admin_invoice!(params[:uuid])
|
||
halt 422, 'Already cancelled.' if invoice.cancelled?
|
||
invoice.cancelled_at = Time.now.utc
|
||
invoice.cancel_reason = params[:reason].to_s.strip[0, 500].then { |r| r.empty? ? nil : r }
|
||
S3.put(invoice.pdf_key_for('cancelled'), InvoicePdf.render(invoice, status: 'cancelled'))
|
||
invoice.save_changes
|
||
audit!('invoice.cancel', invoice.number, invoice.cancel_reason)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
post '/admin/invoices/:uuid/reinstate' do
|
||
invoice = admin_invoice!(params[:uuid])
|
||
invoice.update(cancelled_at: nil, cancel_reason: nil)
|
||
audit!('invoice.reinstate', invoice.number)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
# Superuser-only: hidden invoices vanish from the admin for everyone else
|
||
# (the client's public link keeps working).
|
||
post '/admin/invoices/:uuid/hide' do
|
||
halt 403, 'Only superusers can hide invoices.' unless current_user.superuser
|
||
invoice = admin_invoice!(params[:uuid])
|
||
invoice.update(hidden: !invoice.hidden)
|
||
audit!(invoice.hidden ? 'invoice.hide' : 'invoice.unhide', invoice.number)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
# --- Public: invoice landing + PDF download -----------------------------
|
||
|
||
get '/i/:uuid' do
|
||
@invoice = Invoice[uuid: params[:uuid]] or halt 404
|
||
@page_title = "Invoice #{@invoice.number}"
|
||
@page_desc = "Invoice #{@invoice.number} from IE Sergei Poljanski."
|
||
@noindex = true
|
||
erb :invoice_public
|
||
end
|
||
|
||
get '/i/:uuid/pdf' do
|
||
invoice = Invoice[uuid: params[:uuid]] or halt 404
|
||
# Prefer the status variant; fall back to the legacy single-PDF key for
|
||
# invoices created before the pending/paid split.
|
||
key = invoice.current_pdf_key
|
||
key = invoice.pdf_key unless S3.exists?(key)
|
||
url = S3.presigned_url(key,
|
||
expires_in: 300,
|
||
filename: "#{invoice.number}.pdf")
|
||
redirect url, 302
|
||
end
|
||
|
||
not_found do
|
||
status 404
|
||
'Not found'
|
||
end
|
||
|
||
error 403 do
|
||
'Forbidden — likely CSRF token expired. Reload the page and try again.'
|
||
end
|
||
|
||
helpers do
|
||
def validate_invoice_params(v)
|
||
errors = {}
|
||
errors[:client_name] = 'Client name required (1–200 chars)' if v[:client_name].empty? || v[:client_name].length > 200
|
||
errors[:client_email] = 'Valid client email required' if v[:client_email].empty? || v[:client_email] !~ URI::MailTo::EMAIL_REGEXP
|
||
errors[:currency] = 'Unsupported currency' unless Invoice::CURRENCIES.include?(v[:currency])
|
||
begin
|
||
raise ArgumentError if v[:gel_rate].empty? || BigDecimal(v[:gel_rate]) <= 0
|
||
rescue ArgumentError
|
||
errors[:gel_rate] = 'GEL rate must be a positive decimal'
|
||
end
|
||
items = v[:items].select { |i| i.is_a?(Hash) && !i['description'].to_s.strip.empty? }
|
||
errors[:items] = 'At least one line item with a description is required' if items.empty?
|
||
# Blank qty/price default to 0 in Invoice.normalize_items; non-blank must
|
||
# parse, or BigDecimal would raise a 500 out of Invoice.build.
|
||
items.each do |i|
|
||
%w[qty unit_price].each do |k|
|
||
raw = i[k].to_s
|
||
next if raw.empty?
|
||
begin
|
||
BigDecimal(raw)
|
||
rescue ArgumentError
|
||
errors[:items] = 'Qty and unit price must be decimal numbers (use a dot, e.g. 1.5)'
|
||
end
|
||
end
|
||
end
|
||
v[:items] = items
|
||
|
||
# Crypto is optional: a chosen payout address (validated when it was
|
||
# saved) supplies ticker, address and QR metadata.
|
||
@payout = nil
|
||
unless v[:payout_id].to_s.empty?
|
||
@payout = PayoutAddress.first(id: v[:payout_id].to_i, active: true)
|
||
errors[:payout_id] = 'Pick an active payout address' unless @payout
|
||
%i[crypto_rate crypto_amount].each do |k|
|
||
next if v[k].to_s.strip.empty?
|
||
begin
|
||
raise ArgumentError if BigDecimal(v[k]) <= 0
|
||
rescue ArgumentError
|
||
errors[k] = "#{k.to_s.tr('_', ' ').capitalize} must be a positive decimal"
|
||
end
|
||
end
|
||
end
|
||
errors
|
||
end
|
||
end
|
||
end
|