asxpio/asxpio.rb
Sergei Poljanski 77724e1ee2
invoicing: admin and public routes
Mounts AdminAuth (HTTP Basic on /admin/*) and adds two route groups:

- Admin (/admin/invoices): list, new form, create, show, toggle-paid.
  POST /admin/invoices builds the Invoice, renders the PDF, uploads to
  MinIO, then persists the row with the resulting pdf_key.

- Public (/i/:uuid): an HTML landing page showing client name, number,
  total, and status badge, plus /i/:uuid/pdf which 302s to a short-lived
  MinIO presigned URL. Anyone with the UUID can fetch the PDF — that's
  the whole point of the link-based delivery.

The Invoice model is required only when DATABASE_URL is set, so the
contact-form site still boots in dev without Postgres. Invoicing routes
return 503 in that mode rather than crashing on first request.
2026-05-26 17:54:31 +03:00

253 lines
7.3 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require 'logger'
require 'bigdecimal'
require 'sinatra/base'
require 'sinatra/contrib'
require 'erubi'
require 'uri'
begin
require 'dotenv'
Dotenv.load
rescue LoadError
end
$root = __dir__
$logger = Logger.new($stdout)
$env = ENV.fetch('RACK_ENV', 'development')
require_relative 'lib/mailer'
require_relative 'lib/rate_limit'
require_relative 'lib/db'
require_relative 'lib/admin_auth'
require_relative 'lib/s3'
# 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/invoice_pdf'
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'
set :host_authorization, { permitted_hosts: [] }
end
use Rack::Session::Cookie,
key: 'asxpio.session',
path: '/',
secret: ENV.fetch('SESSION_SECRET'),
expire_after: 3600,
same_site: :lax
use Rack::Protection::AuthenticityToken
use AdminAuth
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
end
before do
cache_control :private, :must_revalidate, max_age: 0
end
get '/' do
@form_errors = nil
@form_values = {}
erb :index
end
post '/contact' do
name = params[:name].to_s.strip
email = params[:email].to_s.strip
subject = params[:subject].to_s.strip
message = params[:message].to_s.strip
honey = params[:website].to_s
# Honeypot — silently pretend success
redirect '/thanks' unless honey.empty?
@form_values = { name: name, email: email, subject: subject, message: message }
@form_errors = {}
@form_errors[:name] = 'Required (1100 chars)' if name.empty? || name.length > 100
@form_errors[:email] = 'Valid email required' if email.empty? || email !~ URI::MailTo::EMAIL_REGEXP || email.length > 200
@form_errors[:subject] = 'Max 200 chars' if subject.length > 200
@form_errors[:message] = 'Required (15000 chars)' if message.empty? || message.length > 5000
if @form_errors.any?
status 422
return erb :index
end
unless RATE_LIMIT.allow?(client_ip)
@form_errors[:base] = 'Too many submissions. Try again later or email ie@asxp.io directly.'
status 429
return erb :index
end
begin
Mailer.notify_owner(
name: name, email: email, subject: subject, message: message, ip: client_ip
)
rescue StandardError => e
$logger.error("notify_owner failed: #{e.class}: #{e.message}")
@form_errors[:base] = 'Could not send message right now. Please email ie@asxp.io directly.'
status 500
return erb :index
end
begin
Mailer.confirm_visitor(name: name, email: email, subject: subject, message: message)
rescue StandardError => e
$logger.warn("confirm_visitor failed: #{e.class}: #{e.message}")
end
redirect '/thanks'
end
get '/thanks' do
@page_title = 'Thanks — IE Sergei Poljanski'
@page_desc = 'Your message has been received. A confirmation copy has been sent to your inbox.'
@noindex = true
erb :thanks
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
get '/admin' do
redirect '/admin/invoices'
end
get '/admin/invoices' do
@page_title = 'Invoices — admin'
@noindex = true
@invoices = Invoice.order(Sequel.desc(:created_at)).all
erb :'admin/invoices/index'
end
get '/admin/invoices/new' do
@page_title = 'New invoice — admin'
@noindex = true
@form_errors = nil
@form_values = {}
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,
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
invoice = Invoice.build(@form_values)
pdf_bytes = InvoicePdf.render(invoice)
pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf"
S3.put(pdf_key, pdf_bytes)
invoice.pdf_key = pdf_key
invoice.save_changes
redirect "/admin/invoices/#{invoice.uuid}"
end
get '/admin/invoices/:uuid' do
@invoice = Invoice[uuid: params[:uuid]] or halt 404
@page_title = "#{@invoice.number} — admin"
@noindex = true
erb :'admin/invoices/show'
end
post '/admin/invoices/:uuid/paid' do
invoice = Invoice[uuid: params[:uuid]] or halt 404
invoice.paid_at = invoice.paid? ? nil : Time.now.utc
invoice.save_changes
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
url = S3.presigned_url(invoice.pdf_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 (1200 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])
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?
v[:items] = items
errors
end
end
end