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.
253 lines
7.3 KiB
Ruby
253 lines
7.3 KiB
Ruby
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 (1–100 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 (1–5000 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 (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])
|
||
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
|