diff --git a/asxpio.rb b/asxpio.rb index e4835b4..335778f 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -89,12 +89,25 @@ class AsxpioWeb < Sinatra::Base end get '/' do - @form_errors = nil - @form_values = {} 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: contact form, email, Telegram, phone. Legal entity details for invoices and contracts.' + @form_errors = nil + @form_values = {} + 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 @@ -114,13 +127,13 @@ class AsxpioWeb < Sinatra::Base if @form_errors.any? status 422 - return erb :index + 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 :index + return erb :contact end begin @@ -131,7 +144,7 @@ class AsxpioWeb < Sinatra::Base $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 + return erb :contact end begin @@ -156,7 +169,7 @@ class AsxpioWeb < Sinatra::Base end get '/thanks' do - @page_title = 'Thanks — IE Sergei Poljanski' + @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 @@ -177,7 +190,7 @@ class AsxpioWeb < Sinatra::Base end get '/admin/invoices' do - @page_title = 'Invoices — admin' + @page_title = 'Invoices · admin' @noindex = true @invoices = Invoice.order(Sequel.desc(:created_at)).all erb :'admin/invoices/index' @@ -225,7 +238,7 @@ class AsxpioWeb < Sinatra::Base end get '/admin/invoices/new' do - @page_title = 'New invoice — admin' + @page_title = 'New invoice · admin' @noindex = true @form_errors = nil @form_values = {} @@ -240,7 +253,7 @@ class AsxpioWeb < Sinatra::Base get '/admin/invoices/:uuid/duplicate' do src = Invoice[uuid: params[:uuid]] or halt 404 term = (src.due_on - src.issued_on).to_i - @page_title = 'New invoice — admin' + @page_title = 'New invoice · admin' @noindex = true @form_errors = nil @form_values = { @@ -278,7 +291,7 @@ class AsxpioWeb < Sinatra::Base @form_errors = validate_invoice_params(@form_values) if @form_errors.any? - @page_title = 'New invoice — admin' + @page_title = 'New invoice · admin' @noindex = true status 422 return erb :'admin/invoices/new' @@ -309,7 +322,7 @@ class AsxpioWeb < Sinatra::Base get '/admin/invoices/:uuid' do @invoice = Invoice[uuid: params[:uuid]] or halt 404 - @page_title = "#{@invoice.number} — admin" + @page_title = "#{@invoice.number} · admin" @noindex = true erb :'admin/invoices/show' end diff --git a/public/copy.js b/public/copy.js new file mode 100644 index 0000000..5b16ed7 --- /dev/null +++ b/public/copy.js @@ -0,0 +1,21 @@ +// One copy button per [data-copy] element. Copies the data-copy value when +// set, otherwise the element's original text. Clipboard API needs a secure +// context — https in prod, localhost in dev; both hold. +document.querySelectorAll('[data-copy]').forEach(function (el) { + var text = el.getAttribute('data-copy') || el.textContent.trim(); + + var btn = document.createElement('span'); + btn.innerText = 'Copy'; + btn.classList.add('copy-button'); + el.appendChild(btn); + + btn.addEventListener('click', function () { + navigator.clipboard.writeText(text).then(function () { + btn.innerText = 'Copied!'; + setTimeout(function () { btn.innerText = 'Copy'; }, 2000); + }, function () { + btn.innerText = 'Failed'; + setTimeout(function () { btn.innerText = 'Copy'; }, 2000); + }); + }); +}); diff --git a/public/copyPGP.js b/public/copyPGP.js deleted file mode 100644 index ff02292..0000000 --- a/public/copyPGP.js +++ /dev/null @@ -1,41 +0,0 @@ -// Get the
element
-const PGP = document.getElementById("myPGP");
-
-//Create a copy button element
-const copyButton = document.createElement("span");
-copyButton.innerText = "Copy";
-copyButton.classList.add("copy-button");
-
-// Append the copy button to the tag
-PGP.appendChild(copyButton);
-
-// Add click event listener to the copy button
-copyButton.addEventListener("click", () => {
- // Hide the copy button temporarily
- copyButton.style.display = "none";
-
- // Create a range and select the text inside the tag
- const range = document.createRange();
- range.selectNode(PGP);
- window.getSelection().removeAllRanges();
- window.getSelection().addRange(range);
-
- try {
- // Copy the selected text to the clipboard
- document.execCommand("copy");
-
- // Alert the user that the text has been copied
- copyButton.innerText = "Copied!";
- setTimeout(function(){
- copyButton.innerText = "Copy";
- }, 2000);
- } catch (err) {
- console.error("Unable to copy text:", err);
- } finally {
- // Show the copy button again
- copyButton.style.display = "inline";
-
- // Deselect the text
- window.getSelection().removeAllRanges();
- }
-});
diff --git a/public/copySSH.js b/public/copySSH.js
deleted file mode 100644
index eb0d8eb..0000000
--- a/public/copySSH.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Get the element
-const SSH = document.getElementById("mySSH");
-
-//Create a copy button element
-const copyButtonSSH = document.createElement("span");
-copyButtonSSH.innerText = "Copy";
-copyButtonSSH.classList.add("copy-button");
-
-// Append the copy button to the tag
-SSH.appendChild(copyButtonSSH);
-
-// Add click event listener to the copy button
-copyButtonSSH.addEventListener("click", () => {
- // Hide the copy button temporarily
- copyButtonSSH.style.display = "none";
-
- // Create a range and select the text inside the tag
- const range = document.createRange();
- range.selectNode(SSH);
- window.getSelection().removeAllRanges();
- window.getSelection().addRange(range);
-
- try {
- // Copy the selected text to the clipboard
- document.execCommand("copy");
-
- // Alert the user that the text has been copied
- copyButtonSSH.innerText = "Copied!";
- setTimeout(function(){
- copyButtonSSH.innerText = "Copy";
- }, 2000);
- } catch (err) {
- console.error("Unable to copy text:", err);
- } finally {
- // Show the copy button again
- copyButtonSSH.style.display = "inline";
-
- // Deselect the text
- window.getSelection().removeAllRanges();
- }
-});
diff --git a/public/sitemap.xml b/public/sitemap.xml
index 993ff75..4c68335 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -5,4 +5,14 @@
monthly
1.0
+
+ https://asxp.io/contact
+ monthly
+ 0.8
+
+
+ https://asxp.io/keys
+ yearly
+ 0.5
+
diff --git a/test/app_test.rb b/test/app_test.rb
index 31fc301..14c3726 100644
--- a/test/app_test.rb
+++ b/test/app_test.rb
@@ -59,7 +59,7 @@ class AppTest < Minitest::Test
end
def test_contact_honeypot_pretends_success_and_sends_nothing
- token = csrf_token_from('/')
+ 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
@@ -67,14 +67,14 @@ class AppTest < Minitest::Test
end
def test_contact_invalid_email_rejected
- token = csrf_token_from('/')
+ 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('/')
+ token = csrf_token_from('/contact')
post '/contact', contact_params(authenticity_token: token),
'HTTP_X_FORWARDED_FOR' => fresh_ip
assert_equal 302, last_response.status
@@ -86,7 +86,7 @@ class AppTest < Minitest::Test
def test_contact_rate_limited_after_five
ip = fresh_ip
- token = csrf_token_from('/')
+ 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
diff --git a/views/contact.erb b/views/contact.erb
new file mode 100644
index 0000000..532ad17
--- /dev/null
+++ b/views/contact.erb
@@ -0,0 +1,27 @@
+<%== erb :'partials/_site_nav', layout: false %>
+
+
+ Contact
+
+ <%== erb :'partials/_contact_form', layout: false %>
+
+
+ - Email
- ie@asxp.io
+ - Telegram
- t.me/ie_asxpi
+ - Phone
- +995 595 026 471
+
+
+
+
+ For invoices and contracts
+ Work is invoiced through my Individual Entrepreneur (ინდ. მეწარმე) registered in Georgia.
+
+ - Legal name
- IE Sergei Poljanski
+ - Latin form
- SERGEI POLJANSKI
+ - Georgian
- ინდ. მეწარმე სერგეი პოლჯანსკი
+ - Legal form
- Individual Entrepreneur (Georgia)
+ - Tax ID
- 304813343
+ - Registered
- 2026-05-04
+ - Address
- Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3, Krtsanisi, Tbilisi, Georgia
+
+
diff --git a/views/index.erb b/views/index.erb
index f97fe4f..5cbb058 100644
--- a/views/index.erb
+++ b/views/index.erb
@@ -1,54 +1,26 @@
+<%== erb :'partials/_site_nav', layout: false %>
+
Sergei Poljanski
- DevOps & infrastructure engineering — Tbilisi, Georgia.
+ DevOps & infrastructure engineering.
+
+ I build and operate infrastructure for clients worldwide.
+ Get in touch, or verify me via keys.
+
+
Services
- - IT infrastructure management
- - System administration
- - DevOps engineering
- - Software deployment & configuration
- - CI/CD pipeline development
- - Container orchestration (Docker, Kubernetes)
- - Infrastructure as Code (Terraform, Ansible)
- - Monitoring & observability (Prometheus, Grafana, Loki)
+ - IT infrastructure management Hetzner Cloud · bare metal
+ - System administration Linux · NixOS · Debian
+ - Software development Lua · Ruby
+ - Networking Traefik · DNS · WireGuard
+ - CI/CD pipeline development GitHub Actions · Forgejo Actions
+ - Container orchestration Docker · Kubernetes
+ - Infrastructure as Code Terraform · Ansible
+ - Monitoring & observability Prometheus · Grafana · Loki
-
-
- Keys
- gpg --recv-keys 85F7AFEDAB7D97BE667F99F24F8851660FA4121B
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io
-
-
-
- Contact
-
- <%== erb :'partials/_contact_form', layout: false %>
-
-
- - Email
- ie@asxp.io
- - Telegram
- t.me/ie_asxpi
- - Phone
- +995 595 026 471
-
-
-
-
- For invoices and contracts
- Work is invoiced through my Individual Entrepreneur (ინდ. მეწარმე) registered in Georgia.
-
- - Legal name
- IE Sergei Poljanski
- - Latin form
- SERGEI POLJANSKI
- - Georgian
- ინდ. მეწარმე სერგეი პოლჯანსკი
- - Legal form
- Individual Entrepreneur (Georgia)
- - Tax ID
- 304813343
- - Registered
- 2026-05-04
- - Address
- Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3, Krtsanisi, Tbilisi, Georgia
-
-
-
-
-
diff --git a/views/keys.erb b/views/keys.erb
new file mode 100644
index 0000000..b2a16f6
--- /dev/null
+++ b/views/keys.erb
@@ -0,0 +1,21 @@
+<%== erb :'partials/_site_nav', layout: false %>
+
+
+
+
+ PGP
+ pgp.pub ↓
+
+ 85F7 AFED AB7D 97BE 667F 99F2 4F88 5166 0FA4 121B
+
+
+
+
+ SSH
+ id_ed25519.pub ↓
+
+ ssh-ed25519 AAAA…R/aVB ie+2026@asxp.io
+
+
+
+
diff --git a/views/partials/_contact_form.erb b/views/partials/_contact_form.erb
index 162bd47..188bfb5 100644
--- a/views/partials/_contact_form.erb
+++ b/views/partials/_contact_form.erb
@@ -12,16 +12,18 @@
<%= errors[:base] %>
<% end %>
-
-
-
- <% if errors[:name] %><%= errors[:name] %><% end %>
-
+
+
+
+
+ <% if errors[:name] %><%= errors[:name] %><% end %>
+
-
-
-
- <% if errors[:email] %><%= errors[:email] %><% end %>
+
+
+
+ <% if errors[:email] %><%= errors[:email] %><% end %>
+
diff --git a/views/partials/_site_nav.erb b/views/partials/_site_nav.erb
new file mode 100644
index 0000000..8d9dcec
--- /dev/null
+++ b/views/partials/_site_nav.erb
@@ -0,0 +1,8 @@
+
+ asxp.io
+
+
diff --git a/views/thanks.erb b/views/thanks.erb
index f1ddeaf..cf74e16 100644
--- a/views/thanks.erb
+++ b/views/thanks.erb
@@ -1,5 +1,5 @@
- Thanks — your message is on its way.
+ Thanks! Your message is on its way.
A confirmation copy has been sent to your inbox.