From 1f059dc2f6fb185efa42c430b3759d744ea235b5 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Tue, 4 Aug 2026 01:25:50 +0400 Subject: [PATCH] remove contact form --- CLAUDE.md | 17 +++---- asxpio.rb | 61 +------------------------ public/style.css | 77 +++----------------------------- test/app_test.rb | 62 +++++-------------------- views/contact.erb | 2 +- views/partials/_contact_form.erb | 44 ------------------ views/thanks.erb | 13 ------ 7 files changed, 26 insertions(+), 250 deletions(-) delete mode 100644 views/partials/_contact_form.erb delete mode 100644 views/thanks.erb diff --git a/CLAUDE.md b/CLAUDE.md index eb79ce3..de3ddc0 100644 --- a/CLAUDE.md +++ b/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. -## 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: -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: `. -5. `Mailer.confirm_visitor` → receipt to visitor, `Reply-To: ie@asxp.io`. Failure here is logged but not surfaced to the user. +**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. -`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 diff --git a/asxpio.rb b/asxpio.rb index 335778f..cf4e53d 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -100,62 +100,10 @@ class AsxpioWeb < Sinatra::Base get '/contact' do @page_title = 'Contact · Sergei Poljanski' - @page_desc = 'Contact Sergei Poljanski: contact form, email, Telegram, phone. Legal entity details for invoices and contracts.' - @form_errors = nil - @form_values = {} + @page_desc = 'Contact Sergei Poljanski: email, Telegram, phone. Legal entity details for invoices and contracts.' erb :contact 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 # when invoicing is configured — a wedged connection pool should read as # unhealthy, not just "process exists". @@ -168,13 +116,6 @@ class AsxpioWeb < Sinatra::Base 'ok' 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 diff --git a/public/style.css b/public/style.css index fc0af38..5515617 100644 --- a/public/style.css +++ b/public/style.css @@ -277,73 +277,7 @@ a:hover { display: block; } -/* Contact 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 errors (admin invoice form) */ .form-error { display: block; color: var(--danger); @@ -357,10 +291,12 @@ a:hover { border-radius: 3px; margin-bottom: 16px; } +.contact-intro { + color: var(--text-dim); + margin: 0 0 16px; +} .contact-direct { margin-top: 18px; - padding-top: 14px; - border-top: 1px dashed var(--border); } /* Keys */ @@ -568,9 +504,6 @@ a:hover { } @media (max-width: 560px) { - .contact-form .form-row { - grid-template-columns: 1fr; - } dl.kv { grid-template-columns: 1fr; gap: 2px 0; diff --git a/test/app_test.rb b/test/app_test.rb index 14c3726..729a859 100644 --- a/test/app_test.rb +++ b/test/app_test.rb @@ -12,15 +12,6 @@ class AppTest < Minitest::Test TestDb.clean! if TestDb.available? 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 = {}) get path, {}, env assert last_response.ok?, "GET #{path} failed: #{last_response.status}" @@ -46,55 +37,22 @@ class AppTest < Minitest::Test assert_equal 'ok', last_response.body end - # --- contact form --------------------------------------------------------- + # --- contact page --------------------------------------------------------- + # The form was removed (persistent spam); the page keeps the direct contacts. - def contact_params(over = {}) - { name: 'Visitor', email: 'visitor@example.com', subject: 'Hello', - message: 'A message.', website: '' }.merge(over) + def test_contact_page_renders_without_form + get '/contact' + assert last_response.ok? + assert_includes last_response.body, 'ie@asxp.io' + refute_match %r{]*action="/contact"}, last_response.body end - def test_contact_without_csrf_token_forbidden - post '/contact', contact_params - assert_equal 403, 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 + def test_contact_post_is_gone + post '/contact', { name: 'Spam', message: 'Spam' } + refute_equal 302, last_response.status assert_empty Mail::TestMailer.deliveries 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 ------------------------------------------------------------ def test_admin_requires_auth diff --git a/views/contact.erb b/views/contact.erb index 532ad17..f672f4e 100644 --- a/views/contact.erb +++ b/views/contact.erb @@ -3,7 +3,7 @@

Contact

- <%== erb :'partials/_contact_form', layout: false %> +

Reach me directly by email or Telegram.

Email
ie@asxp.io
diff --git a/views/partials/_contact_form.erb b/views/partials/_contact_form.erb deleted file mode 100644 index 188bfb5..0000000 --- a/views/partials/_contact_form.erb +++ /dev/null @@ -1,44 +0,0 @@ -<% errors = (defined?(@form_errors) && @form_errors) || {} %> -<% values = (defined?(@form_values) && @form_values) || {} %> - -
- - - - - <% if errors[:base] %> -
<%= errors[:base] %>
- <% end %> - -
-
- - - <% if errors[:name] %><%= errors[:name] %><% end %> -
- -
- - - <% if errors[:email] %><%= errors[:email] %><% end %> -
-
- -
- - - <% if errors[:subject] %><%= errors[:subject] %><% end %> -
- -
- - - <% if errors[:message] %><%= errors[:message] %><% end %> -
- -
- -
-
diff --git a/views/thanks.erb b/views/thanks.erb deleted file mode 100644 index cf74e16..0000000 --- a/views/thanks.erb +++ /dev/null @@ -1,13 +0,0 @@ -
-

Thanks! Your message is on its way.

- A confirmation copy has been sent to your inbox. -
- -
-

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:

-
-
Email
ie@asxp.io
-
Telegram
t.me/ie_asxpi
-
-

← Back to the front page

-