remove contact form
This commit is contained in:
parent
a7dbe55694
commit
1f059dc2f6
7 changed files with 26 additions and 250 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -60,16 +60,17 @@ The `asxpio` Postgres role + `asxpio-invoices` MinIO bucket + scoped MinIO user
|
||||||
|
|
||||||
The public site is multi-page: `/` (hero + intro links + services grid), `/keys`, `/contact` (form + direct contacts + the demoted legal/invoice-details block). Shared top nav in `views/partials/_site_nav.erb` (included by each page view, not the layout, so invoice/admin pages stay nav-free); it highlights the active page and links to `blog.asxp.io` — **the blog repo (`../blog`) must be deployed or that nav link 404s**. Per-page `@page_title` is set in the routes.
|
The public site is multi-page: `/` (hero + intro links + services grid), `/keys`, `/contact` (form + direct contacts + the demoted legal/invoice-details block). Shared top nav in `views/partials/_site_nav.erb` (included by each page view, not the layout, so invoice/admin pages stay nav-free); it highlights the active page and links to `blog.asxp.io` — **the blog repo (`../blog`) must be deployed or that nav link 404s**. Per-page `@page_title` is set in the routes.
|
||||||
|
|
||||||
## Contact form behavior
|
## Contact (form removed)
|
||||||
|
|
||||||
The form lives on `GET /contact`; validation errors re-render `:contact` (422/429/500). `POST /contact` does, in order:
|
**There is no contact form.** `/contact` is a static page: direct contacts (email, Telegram, phone) plus the legal/invoice-details block. `POST /contact` and `GET /thanks` were removed, along with `views/partials/_contact_form.erb`, `views/thanks.erb`, the honeypot, and the `.contact-form` CSS.
|
||||||
1. Honeypot check — if `website` field non-empty, silently 302 to `/thanks`.
|
|
||||||
2. Validate name, email, subject, message.
|
|
||||||
3. Rate-limit by client IP.
|
|
||||||
4. `Mailer.notify_owner` → message to `MAIL_TO` (`ie@asxp.io`), `Reply-To: <visitor email>`.
|
|
||||||
5. `Mailer.confirm_visitor` → receipt to visitor, `Reply-To: ie@asxp.io`. Failure here is logged but not surfaced to the user.
|
|
||||||
|
|
||||||
`MAIL_FROM` must use a Fastmail-verified send-as address (currently `me@asxp.io`). The friendly name reads "IE Sergei Poljanski Contact Form".
|
Removed 2026-08-04 after persistent spam: ~12 submissions/48h reaching the inbox, overwhelmingly from `80.94.95.173` (with neighbours in `80.94.95.0/24` and `141.98.11.0/24`). The traffic was scripted, not browser-driven — `HTTP/1.0` requests with a User-Agent randomised per request across Chrome 129/130/131 + Avast/CCleaner/Edg variants. The in-app rate limiter was absorbing ~75% of attempts but the remainder still landed. Options weighed and rejected: proof-of-work captcha (would have worked against these no-JS scripts, but not worth the code for a form whose only output is an email), Cloudflare Turnstile (external dependency, conflicts with the self-hosted posture), Traefik IP ban (v3 has no built-in deny-list — only `IPAllowList`; would need a third-party plugin or an all-except-CIDR allow-list with an IPv6 footgun).
|
||||||
|
|
||||||
|
**Still present but now unused by the public site:** `lib/mailer.rb`, `lib/rate_limit.rb`, and the `SMTP_*` / `MAIL_*` env vars. Left in place deliberately so the form can be restored, and because `Mailer.configure!` still runs at boot. If the form is never coming back, these and their Forgejo secrets can be retired.
|
||||||
|
|
||||||
|
`MAIL_FROM` must use a Fastmail-verified send-as address (currently `me@asxp.io`).
|
||||||
|
|
||||||
|
If a form is ever reinstated, note that Traefik's access log (`/opt/traefik/logs/access.log`, User-Agent retained) is the only place with client IPs — the app itself logs no requests.
|
||||||
|
|
||||||
## Invoicing
|
## Invoicing
|
||||||
|
|
||||||
|
|
|
||||||
61
asxpio.rb
61
asxpio.rb
|
|
@ -100,62 +100,10 @@ class AsxpioWeb < Sinatra::Base
|
||||||
|
|
||||||
get '/contact' do
|
get '/contact' do
|
||||||
@page_title = 'Contact · Sergei Poljanski'
|
@page_title = 'Contact · Sergei Poljanski'
|
||||||
@page_desc = 'Contact Sergei Poljanski: contact form, email, Telegram, phone. Legal entity details for invoices and contracts.'
|
@page_desc = 'Contact Sergei Poljanski: email, Telegram, phone. Legal entity details for invoices and contracts.'
|
||||||
@form_errors = nil
|
|
||||||
@form_values = {}
|
|
||||||
erb :contact
|
erb :contact
|
||||||
end
|
end
|
||||||
|
|
||||||
post '/contact' do
|
|
||||||
@page_title = 'Contact · Sergei Poljanski'
|
|
||||||
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 :contact
|
|
||||||
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 :contact
|
|
||||||
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 :contact
|
|
||||||
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
|
|
||||||
|
|
||||||
# Liveness for the Docker HEALTHCHECK and the deploy pipeline. Pings the DB
|
# Liveness for the Docker HEALTHCHECK and the deploy pipeline. Pings the DB
|
||||||
# when invoicing is configured — a wedged connection pool should read as
|
# when invoicing is configured — a wedged connection pool should read as
|
||||||
# unhealthy, not just "process exists".
|
# unhealthy, not just "process exists".
|
||||||
|
|
@ -168,13 +116,6 @@ class AsxpioWeb < Sinatra::Base
|
||||||
'ok'
|
'ok'
|
||||||
end
|
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 ----------------------------------------------------
|
# --- Admin: invoices ----------------------------------------------------
|
||||||
|
|
||||||
before '/admin/*' do
|
before '/admin/*' do
|
||||||
|
|
|
||||||
|
|
@ -277,73 +277,7 @@ a:hover {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Contact form */
|
/* Form errors (admin invoice form) */
|
||||||
.contact-form {
|
|
||||||
margin: 0 0 24px;
|
|
||||||
}
|
|
||||||
.contact-form .hp {
|
|
||||||
position: absolute;
|
|
||||||
left: -10000px;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.contact-form .field {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.contact-form .form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0 14px;
|
|
||||||
}
|
|
||||||
.contact-form label {
|
|
||||||
display: block;
|
|
||||||
color: var(--text-dimmer);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
.contact-form .optional {
|
|
||||||
color: var(--text-ghost);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.contact-form input[type="text"],
|
|
||||||
.contact-form input[type="email"],
|
|
||||||
.contact-form textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-strong);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.contact-form textarea {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.contact-form input:focus,
|
|
||||||
.contact-form textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--text-ghost);
|
|
||||||
background: var(--border-subtle);
|
|
||||||
}
|
|
||||||
.contact-form button {
|
|
||||||
background: var(--border);
|
|
||||||
color: var(--text-strong);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 8px 20px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.contact-form button:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border-color: var(--text-ghost);
|
|
||||||
}
|
|
||||||
.form-error {
|
.form-error {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|
@ -357,10 +291,12 @@ a:hover {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
.contact-intro {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
.contact-direct {
|
.contact-direct {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keys */
|
/* Keys */
|
||||||
|
|
@ -568,9 +504,6 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.contact-form .form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
dl.kv {
|
dl.kv {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 2px 0;
|
gap: 2px 0;
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,6 @@ class AppTest < Minitest::Test
|
||||||
TestDb.clean! if TestDb.available?
|
TestDb.clean! if TestDb.available?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Each test that passes contact validation must use a fresh IP: the app-level
|
|
||||||
# rate limiter (5/hour) is shared process state, so the counter must be
|
|
||||||
# unique across the whole run, not per test.
|
|
||||||
@@ip_counter = 0
|
|
||||||
def fresh_ip
|
|
||||||
@@ip_counter += 1
|
|
||||||
"10.9.#{@@ip_counter / 250}.#{@@ip_counter % 250}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def csrf_token_from(path, env = {})
|
def csrf_token_from(path, env = {})
|
||||||
get path, {}, env
|
get path, {}, env
|
||||||
assert last_response.ok?, "GET #{path} failed: #{last_response.status}"
|
assert last_response.ok?, "GET #{path} failed: #{last_response.status}"
|
||||||
|
|
@ -46,55 +37,22 @@ class AppTest < Minitest::Test
|
||||||
assert_equal 'ok', last_response.body
|
assert_equal 'ok', last_response.body
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- contact form ---------------------------------------------------------
|
# --- contact page ---------------------------------------------------------
|
||||||
|
# The form was removed (persistent spam); the page keeps the direct contacts.
|
||||||
|
|
||||||
def contact_params(over = {})
|
def test_contact_page_renders_without_form
|
||||||
{ name: 'Visitor', email: 'visitor@example.com', subject: 'Hello',
|
get '/contact'
|
||||||
message: 'A message.', website: '' }.merge(over)
|
assert last_response.ok?
|
||||||
|
assert_includes last_response.body, 'ie@asxp.io'
|
||||||
|
refute_match %r{<form[^>]*action="/contact"}, last_response.body
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_contact_without_csrf_token_forbidden
|
def test_contact_post_is_gone
|
||||||
post '/contact', contact_params
|
post '/contact', { name: 'Spam', message: 'Spam' }
|
||||||
assert_equal 403, last_response.status
|
refute_equal 302, last_response.status
|
||||||
end
|
|
||||||
|
|
||||||
def test_contact_honeypot_pretends_success_and_sends_nothing
|
|
||||||
token = csrf_token_from('/contact')
|
|
||||||
post '/contact', contact_params(website: 'spam', authenticity_token: token)
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
assert_match %r{/thanks}, last_response.location
|
|
||||||
assert_empty Mail::TestMailer.deliveries
|
assert_empty Mail::TestMailer.deliveries
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_contact_invalid_email_rejected
|
|
||||||
token = csrf_token_from('/contact')
|
|
||||||
post '/contact', contact_params(email: 'not-an-email', authenticity_token: token)
|
|
||||||
assert_equal 422, last_response.status
|
|
||||||
assert_empty Mail::TestMailer.deliveries
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_contact_valid_submission_sends_two_mails
|
|
||||||
token = csrf_token_from('/contact')
|
|
||||||
post '/contact', contact_params(authenticity_token: token),
|
|
||||||
'HTTP_X_FORWARDED_FOR' => fresh_ip
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
assert_equal 2, Mail::TestMailer.deliveries.size
|
|
||||||
to_owner, to_visitor = Mail::TestMailer.deliveries
|
|
||||||
assert_includes to_owner.to, ENV['MAIL_TO']
|
|
||||||
assert_includes to_visitor.to, 'visitor@example.com'
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_contact_rate_limited_after_five
|
|
||||||
ip = fresh_ip
|
|
||||||
token = csrf_token_from('/contact')
|
|
||||||
5.times do
|
|
||||||
post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
|
|
||||||
assert_equal 429, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- admin ------------------------------------------------------------
|
# --- admin ------------------------------------------------------------
|
||||||
|
|
||||||
def test_admin_requires_auth
|
def test_admin_requires_auth
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
|
|
||||||
<%== erb :'partials/_contact_form', layout: false %>
|
<p class="contact-intro">Reach me directly by email or Telegram.</p>
|
||||||
|
|
||||||
<dl class="kv contact-direct">
|
<dl class="kv contact-direct">
|
||||||
<dt>Email</dt><dd><a href="mailto:ie@asxp.io">ie@asxp.io</a></dd>
|
<dt>Email</dt><dd><a href="mailto:ie@asxp.io">ie@asxp.io</a></dd>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<% errors = (defined?(@form_errors) && @form_errors) || {} %>
|
|
||||||
<% values = (defined?(@form_values) && @form_values) || {} %>
|
|
||||||
|
|
||||||
<form class="contact-form" method="post" action="/contact" autocomplete="on">
|
|
||||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
|
||||||
|
|
||||||
<div class="hp" aria-hidden="true">
|
|
||||||
<label>Website<input type="text" name="website" tabindex="-1" autocomplete="off" /></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if errors[:base] %>
|
|
||||||
<div class="form-error form-error-base"><%= errors[:base] %></div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="field">
|
|
||||||
<label for="cf-name">Name</label>
|
|
||||||
<input id="cf-name" type="text" name="name" maxlength="100" required value="<%= values[:name] %>" />
|
|
||||||
<% if errors[:name] %><span class="form-error"><%= errors[:name] %></span><% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="cf-email">Email</label>
|
|
||||||
<input id="cf-email" type="email" name="email" maxlength="200" required value="<%= values[:email] %>" />
|
|
||||||
<% if errors[:email] %><span class="form-error"><%= errors[:email] %></span><% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="cf-subject">Subject <span class="optional">(optional)</span></label>
|
|
||||||
<input id="cf-subject" type="text" name="subject" maxlength="200" value="<%= values[:subject] %>" />
|
|
||||||
<% if errors[:subject] %><span class="form-error"><%= errors[:subject] %></span><% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="cf-message">Message</label>
|
|
||||||
<textarea id="cf-message" name="message" rows="6" maxlength="5000" required><%= values[:message] %></textarea>
|
|
||||||
<% if errors[:message] %><span class="form-error"><%= errors[:message] %></span><% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<div class="header">
|
|
||||||
<h1>Thanks! Your message is on its way.</h1>
|
|
||||||
<span class="ka">A confirmation copy has been sent to your inbox.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<p>I'll get back to you as soon as possible. If you don't hear back within a few days, feel free to reach out directly:</p>
|
|
||||||
<dl class="kv">
|
|
||||||
<dt>Email</dt><dd><a href="mailto:ie@asxp.io">ie@asxp.io</a></dd>
|
|
||||||
<dt>Telegram</dt><dd><a href="https://t.me/ie_asxpi">t.me/ie_asxpi</a></dd>
|
|
||||||
</dl>
|
|
||||||
<p><a href="/">← Back to the front page</a></p>
|
|
||||||
</section>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue