ruby rework with contact form
All checks were successful
Build and Deploy to Production / build (push) Successful in 51s
Build and Deploy to Production / deploy (push) Successful in 34s

This commit is contained in:
Sergei Poljanski 2026-05-05 00:45:27 +04:00
commit 1ab09043ee
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
26 changed files with 932 additions and 221 deletions

View file

@ -3,3 +3,12 @@
.DS_Store .DS_Store
README.md README.md
.dockerignore .dockerignore
.env
.gems
.bundle
vendor/bundle
*.log
tmp
flake.nix
flake.lock
CLAUDE.md

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
RACK_ENV=production
# Generate a fresh value: openssl rand -hex 64
SESSION_SECRET=change-me-to-128-hex-chars
SMTP_ADDR=smtp.fastmail.com
SMTP_PORT=587
SMTP_USER=me@asxp.io
SMTP_PASSWORD=change-me
MAIL_FROM="IE Sergei Poljanski Contact Form <me@asxp.io>"
MAIL_TO=ie@asxp.io

View file

@ -56,6 +56,32 @@ jobs:
SOURCE: "docker-compose.yml" SOURCE: "docker-compose.yml"
TARGET: "/opt/asxpio/" TARGET: "/opt/asxpio/"
- name: Deploy .env from secrets
uses: appleboy/ssh-action@v1.2.3
env:
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: SESSION_SECRET,SMTP_PASSWORD
script_stop: true
script: |
umask 077
mkdir -p /opt/asxpio
cat > /opt/asxpio/.env <<EOF
RACK_ENV=production
SESSION_SECRET=${SESSION_SECRET}
SMTP_ADDR=smtp.fastmail.com
SMTP_PORT=587
SMTP_USER=me@asxp.io
SMTP_PASSWORD=${SMTP_PASSWORD}
MAIL_FROM=IE Sergei Poljanski Contact Form <me@asxp.io>
MAIL_TO=ie@asxp.io
EOF
chmod 600 /opt/asxpio/.env
- name: Deploy and update container - name: Deploy and update container
uses: appleboy/ssh-action@v1.2.3 uses: appleboy/ssh-action@v1.2.3
env: env:

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
.gems/
.bundle/
vendor/bundle/
*.log
tmp/

86
CLAUDE.md Normal file
View file

@ -0,0 +1,86 @@
# asxp.io — IE Sergei Poljanski website
## What this is
The public-facing website for the user's Individual Entrepreneur registered in Georgia. Single-purpose: present the legal entity and accept contact-form submissions.
## Rule of thumb for this file
**Same confidentiality bar as the rendered HTML.** If a fact isn't on the public page, it doesn't belong here. Sensitive operational details (banking, registration number, tax-regime reasoning) live in the user's private memory, not in the repo.
## Hard rules
- **Never use the words "consulting" or "advisory"** anywhere on the site or in invoicing copy. Frame work as "services", "engineering", "implementation". This is for tax-classification reasons; ask the user before deviating.
- **Never commit `.env`** or any real credential. Production secrets come from Forgejo secrets at deploy time.
- **Don't write Claude Code as co-author on commits.** (User-global preference.)
- **Don't write commits at all unless asked.** (User-global preference.)
## Entity facts (also visible on the rendered page)
- Legal name: **IE Sergei Poljanski** / `SERGEI POLJANSKI` (Latin) / `ინდ. მეწარმე სერგეი პოლჯანსკი` (Georgian)
- Legal form: Individual Entrepreneur (Georgia)
- Tax ID: `304813343`
- Registered: 2026-05-04
- Activity codes: 62010 (main), 62090 (additional)
- Address: Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3, Krtsanisi, Tbilisi, Georgia
- Public contact: `ie@asxp.io`, `t.me/ie_asxpi`, +995 595 026 471
Banking details and registration number are deliberately not on the public page and not in this file. They go on invoices only.
## Stack
- **Sinatra 4.2** + Puma + Rack 3 + erubi, on Ruby 3.4
- **Mail gem** for SMTP (Fastmail, STARTTLS on 587)
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
- Honeypot field named `website` for spam
- Static assets in `public/`; views in `views/`
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io)
- Dev shell: `flake.nix` (Ruby 3.4, bundler, openssl, zlib, libyaml). Run `nix develop`.
The patterns mirror `~/Code/projects/narayana/www`. Use that repo as a reference for conventions (middleware, helpers, dotenv pattern) — but don't pull in narayana-specific things this site doesn't need (no Redis, no i18n, no Prawn, no API client).
## Contact form behavior
`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: <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".
## Operational notes
- `client_ip` reads `X-Forwarded-For` first (Traefik sets it), falling back to `X-Real-IP` and `REMOTE_ADDR`.
- The rate limiter resets on app restart — acceptable for one-process deploys.
- If the Ruby process is down, the site is down. There's no static fallback. (Trade-off accepted for the contact form.)
## SSH key comment caveat
`public/id_ed25519.pub` is served from the site. The comment field (currently `ie+2026@asxp.io`) is part of the key file itself; editing the website without regenerating the key file means the website and the file disagree. Keep them in sync, or regenerate the key.
## Common tasks
- **Generate a new SESSION_SECRET:** `openssl rand -hex 64` (then update Forgejo secret).
- **Local dev:** `nix develop`, then `bundle exec rerun -- rackup -p 3000`. Visit `http://localhost:3000`.
- **Local image build (sanity check):** `docker compose build`
- **Production deploy:** automatic via `.forgejo/workflows/deploy.yaml` on push to `main`. Pipeline:
1. Kaniko builds the image and pushes it to the Forgejo registry.
2. SSH copies `docker-compose.yml` to the deploy directory on the prod host.
3. SSH writes `.env` (mode 600) from Forgejo secrets — `SESSION_SECRET` and `SMTP_PASSWORD` are interpolated; non-secret config is hardcoded in the workflow.
4. SSH `sed`-substitutes the image tag in `docker-compose.yml` and runs `docker compose up -d`.
## Where the secrets live
| Forgejo secret | Use | Rotate by |
|-------------------|-------------------------------------|-------------------|
| `SESSION_SECRET` | Rack session cookie HMAC | Update secret + push (or re-run last deploy). All sessions invalidated; nobody is logged in to this site so no user impact. |
| `SMTP_PASSWORD` | Fastmail app password | Generate new app password in Fastmail → update secret → push. |
| `DEPLOY_IP/USER/SSH_KEY` | SSH to prod from CI | Standard SSH key rotation. |
| `FORGEJO_REGISTRY/USER/TOKEN` | Kaniko registry auth | Forgejo token rotation. |
**Non-secret config that lives in the workflow** (not in `.env.example`, not in secrets): `SMTP_ADDR`, `SMTP_PORT`, `SMTP_USER`, `MAIL_FROM`, `MAIL_TO`. Change these by editing `.forgejo/workflows/deploy.yaml`.
## What `.env.example` is for
Local development only. Copy to `.env`, fill in dummy/test SMTP creds (or real ones if you want to actually send) and a throwaway `SESSION_SECRET`. The Nix dev shell sources `.env` automatically. **Production never reads `.env.example` and never has a stale `.env`** — every deploy regenerates it.

View file

@ -1,15 +1,26 @@
FROM nginx:alpine FROM ruby:3.4-slim-bookworm
# Copy website files to nginx html directory RUN apt-get update && apt-get install -y --no-install-recommends \
COPY index.html /usr/share/nginx/html/ build-essential \
COPY id_ed25519.pub /usr/share/nginx/html/ && rm -rf /var/lib/apt/lists/*
COPY copyPGP.js /usr/share/nginx/html/
COPY copySSH.js /usr/share/nginx/html/
COPY hedgehog.png /usr/share/nginx/html/
COPY pgp.pub /usr/share/nginx/html/
# Expose port 80 RUN groupadd -g 11000 asxpio \
EXPOSE 80 && useradd -d /app -u 21000 -g 11000 -m -s /bin/bash asxpio
# Start nginx WORKDIR /app
CMD ["nginx", "-g", "daemon off;"] USER asxpio
ENV GEM_HOME=/app/bundle
ENV PATH="${GEM_HOME}/bin:${PATH}"
COPY --chown=asxpio Gemfile Gemfile.lock* ./
RUN bundle install --without development
COPY --chown=asxpio . .
ENV PUMA_PORT=3000
ENV PUMA_THREADS="4:16"
ENV RACK_ENV=production
EXPOSE 3000
CMD ["sh", "-c", "exec puma -b tcp://0.0.0.0:${PUMA_PORT} -t ${PUMA_THREADS} --preload"]

17
Gemfile Normal file
View file

@ -0,0 +1,17 @@
source 'https://rubygems.org'
ruby '>= 3.1'
gem 'sinatra', '~> 4.2'
gem 'sinatra-contrib', '~> 4.2'
gem 'puma', '~> 6.6'
gem 'rack', '~> 3.2'
gem 'rack-protection', '~> 4.2'
gem 'rack-session', '~> 2.1'
gem 'rackup', '~> 2.3'
gem 'erubi', '~> 1.13'
gem 'mail', '~> 2.8'
gem 'dotenv', '~> 3.1'
group :development do
gem 'rerun', '~> 0.14'
end

106
Gemfile.lock Normal file
View file

@ -0,0 +1,106 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.3.0)
date (3.5.1)
dotenv (3.2.0)
erubi (1.13.1)
ffi (1.17.4)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86-linux-gnu)
ffi (1.17.4-x86-linux-musl)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
listen (3.10.0)
logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
mini_mime (1.1.5)
multi_json (1.21.1)
mustermann (3.1.1)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
puma (6.6.1)
nio4r (~> 2.0)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rackup (2.3.1)
rack (>= 3)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rerun (0.14.0)
listen (~> 3.0)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sinatra-contrib (4.2.1)
multi_json (>= 0.0.2)
mustermann (~> 3.0)
rack-protection (= 4.2.1)
sinatra (= 4.2.1)
tilt (~> 2.0)
tilt (2.7.0)
timeout (0.6.1)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
dotenv (~> 3.1)
erubi (~> 1.13)
mail (~> 2.8)
puma (~> 6.6)
rack (~> 3.2)
rack-protection (~> 4.2)
rack-session (~> 2.1)
rackup (~> 2.3)
rerun (~> 0.14)
sinatra (~> 4.2)
sinatra-contrib (~> 4.2)
RUBY VERSION
ruby 3.4.9p82
BUNDLED WITH
2.7.2

119
asxpio.rb Normal file
View file

@ -0,0 +1,119 @@
require 'logger'
require 'sinatra/base'
require 'sinatra/contrib'
require 'erubi'
require 'uri'
begin
require 'dotenv'
Dotenv.load
rescue LoadError
end
require_relative 'lib/mailer'
require_relative 'lib/rate_limit'
$root = __dir__
$logger = Logger.new($stdout)
$env = ENV.fetch('RACK_ENV', 'development')
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
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
erb :thanks
end
error 403 do
'Forbidden — likely CSRF token expired. Reload the page and try again.'
end
end

3
config.ru Normal file
View file

@ -0,0 +1,3 @@
require 'sinatra/base'
require './asxpio.rb'
run AsxpioWeb.new

View file

@ -1,10 +1,12 @@
services: services:
asxpio: asxpio:
# Image will be dynamically updated during deployment # Image will be dynamically updated during deployment
# Format: registry.example.com/user/czsk:sha_tag # Format: registry.example.com/user/asxpio:sha_tag
image: nginx:alpine build: .
image: asxpio:latest
container_name: asxpio container_name: asxpio
restart: unless-stopped restart: unless-stopped
env_file: .env
networks: networks:
- traefik - traefik
labels: labels:
@ -13,7 +15,7 @@ services:
- "traefik.http.routers.asxpio.entrypoints=websecure" - "traefik.http.routers.asxpio.entrypoints=websecure"
- "traefik.http.routers.asxpio.tls=true" - "traefik.http.routers.asxpio.tls=true"
- "traefik.http.routers.asxpio.tls.certresolver=letsencrypt" - "traefik.http.routers.asxpio.tls.certresolver=letsencrypt"
- "traefik.http.services.asxpio.loadbalancer.server.port=80" - "traefik.http.services.asxpio.loadbalancer.server.port=3000"
networks: networks:
traefik: traefik:

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777578337,
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

44
flake.nix Normal file
View file

@ -0,0 +1,44 @@
{
description = "asxp.io IE Sergei Poljanski website";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
pkgs.ruby_3_4
pkgs.bundler
pkgs.openssl
pkgs.zlib
pkgs.libyaml
];
shellHook = ''
export GEM_HOME="$PWD/.gems"
export PATH="$GEM_HOME/bin:$PATH"
export BUNDLE_PATH="$GEM_HOME"
if [ -f .env ]; then
set -a
source .env
set +a
fi
if [ ! -f .gems/.bundled ] || [ Gemfile -nt .gems/.bundled ]; then
echo "Installing gems..."
bundle install --quiet && mkdir -p .gems && touch .gems/.bundled
fi
echo "asxpio dev shell ready"
echo " bundle exec rerun -- rackup -p 3000 - start with auto-reload"
'';
};
};
}

View file

@ -1,205 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IE Sergei Poljanski</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans">
<link rel='stylesheet' href='//cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/hack-subset.css'>
<link rel="icon" type="image/png" href="hedgehog.png" sizes="32x32" />
<style>
body {
background-color: #121212;
color: #AAAAAA;
font-family: 'Open Sans', sans-serif;
margin: 0;
line-height: 1.5;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 40px 20px 80px;
}
.header {
text-align: center;
margin-bottom: 40px;
}
.header h1 {
font-size: 1.4rem;
font-weight: 400;
color: #DDDDDD;
margin: 0;
}
.header .ka {
display: block;
font-size: 0.85rem;
color: #666666;
margin-top: 6px;
}
.section {
margin: 36px 0;
}
.section h2 {
font-size: 1rem;
font-weight: 600;
color: #DDDDDD;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid #2A2A2A;
padding-bottom: 6px;
margin: 0 0 16px;
}
ul.services {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 24px;
}
ul.services li {
padding-left: 14px;
position: relative;
}
ul.services li::before {
content: '';
position: absolute;
left: 0;
color: #555555;
}
dl.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 18px;
margin: 0;
}
dl.kv dt {
color: #777777;
font-size: 0.9rem;
}
dl.kv dd {
margin: 0;
color: #CCCCCC;
}
dl.kv dd.mono,
.mono {
font-family: 'Hack', monospace;
font-size: 0.92rem;
}
.ka-note {
color: #666666;
font-size: 0.85rem;
margin-top: 4px;
}
pre {
position: relative;
background: #222222;
padding: 10px;
font-size: 14px;
font-family: 'Hack', monospace;
word-wrap: break-word;
white-space: pre-wrap;
border: 1px solid #020202;
margin: 8px 0;
}
.copy-button {
position: absolute;
top: 5px;
right: 10px;
cursor: pointer;
color: #424242;
background: #111111;
border-radius: 0.5em;
padding: 2px 7px;
}
.copy-button:hover {
color: #000;
}
a {
color: #BBBBBB;
text-decoration: none;
border-bottom: 1px dotted #444444;
}
a:hover {
color: #FFFFFF;
border-bottom-color: #888888;
}
.footer {
text-align: center;
color: #555555;
font-size: 0.8rem;
padding: 24px 20px;
border-top: 1px solid #1E1E1E;
}
@media (max-width: 560px) {
ul.services {
grid-template-columns: 1fr;
}
dl.kv {
grid-template-columns: 1fr;
gap: 2px 0;
}
dl.kv dd {
margin-bottom: 8px;
}
}
</style>
</head>
<body>
<main>
<div class="header">
<h1>DevOps &amp; infrastructure engineering — IE Sergei Poljanski, Tbilisi.</h1>
<span class="ka">ინდ. მეწარმე სერგეი პოლჯანსკი</span>
</div>
<section class="section">
<h2>Services</h2>
<ul class="services">
<li>IT infrastructure management</li>
<li>System administration</li>
<li>DevOps engineering</li>
<li>Software deployment &amp; configuration</li>
<li>CI/CD pipeline development</li>
<li>Container orchestration (Docker, Kubernetes)</li>
<li>Infrastructure as Code (Terraform, Ansible)</li>
<li>Monitoring &amp; observability (Prometheus, Grafana, Loki)</li>
</ul>
</section>
<section class="section">
<h2>Legal</h2>
<dl class="kv">
<dt>Legal name</dt><dd>IE Sergei Poljanski</dd>
<dt>Latin form</dt><dd class="mono">SERGEI POLJANSKI</dd>
<dt>Legal form</dt><dd>Individual Entrepreneur (Georgia)</dd>
<dt>Tax ID</dt><dd class="mono">304813343</dd>
<dt>Registered</dt><dd>2026-05-04</dd>
<dt>Address</dt><dd>Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3, Krtsanisi, Tbilisi, Georgia</dd>
</dl>
<div class="ka-note">ინდ. მეწარმე სერგეი პოლჯანსკი</div>
</section>
<section class="section">
<h2>Keys</h2>
<pre id="myPGP">gpg --recv-keys 85F7AFEDAB7D97BE667F99F24F8851660FA4121B</pre>
<pre id="mySSH">ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io</pre>
</section>
<section class="section">
<h2>Contact</h2>
<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>
<dt>Phone</dt><dd class="mono">+995 595 026 471</dd>
</dl>
</section>
</main>
<div class="footer">
IE Sergei Poljanski · Tbilisi, Georgia · ID 304813343
</div>
<script src="copyPGP.js"></script>
<script src="copySSH.js"></script>
</body>
</html>

72
lib/mailer.rb Normal file
View file

@ -0,0 +1,72 @@
require 'mail'
module Mailer
module_function
def configure!
Mail.defaults do
delivery_method :smtp,
address: ENV.fetch('SMTP_ADDR'),
port: ENV.fetch('SMTP_PORT').to_i,
user_name: ENV.fetch('SMTP_USER'),
password: ENV.fetch('SMTP_PASSWORD'),
authentication: :plain,
enable_starttls_auto: true
end
end
def notify_owner(name:, email:, subject:, message:, ip:)
from = ENV.fetch('MAIL_FROM')
to = ENV.fetch('MAIL_TO')
subj = subject.to_s.strip.empty? ? 'Contact form submission' : subject.to_s.strip
body = <<~BODY
New contact form submission on asxp.io.
From: #{name} <#{email}>
Subject: #{subj}
IP: #{ip}
Time: #{Time.now.utc.iso8601}
---
#{message}
BODY
Mail.deliver do
from from
to to
reply_to email
subject "[asxp.io] #{subj}"
body body
end
end
def confirm_visitor(name:, email:, subject:, message:)
from = ENV.fetch('MAIL_FROM')
to = ENV.fetch('MAIL_TO')
subj = subject.to_s.strip.empty? ? 'your message' : subject.to_s.strip
body = <<~BODY
Hi #{name},
Thanks for contacting IE Sergei Poljanski. I've received your message
and will get back to you as soon as possible.
For your records, here's what you sent:
Subject: #{subj}
#{message}
--
IE Sergei Poljanski
ie@asxp.io · https://asxp.io
BODY
Mail.deliver do
from from
to email
reply_to to
subject 'Thanks for contacting IE Sergei Poljanski'
body body
end
end
end

31
lib/rate_limit.rb Normal file
View file

@ -0,0 +1,31 @@
require 'thread'
class RateLimit
def initialize(limit:, window:)
@limit = limit
@window = window
@hits = Hash.new { |h, k| h[k] = [] }
@mutex = Mutex.new
end
def allow?(key)
now = Time.now.to_i
@mutex.synchronize do
@hits[key].reject! { |t| t < now - @window }
if @hits[key].size >= @limit
false
else
@hits[key] << now
true
end
end
end
def sweep!
cutoff = Time.now.to_i - @window
@mutex.synchronize do
@hits.each_value { |arr| arr.reject! { |t| t < cutoff } }
@hits.delete_if { |_, arr| arr.empty? }
end
end
end

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

@ -1 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB me@asxp.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io

217
public/style.css Normal file
View file

@ -0,0 +1,217 @@
body {
background-color: #121212;
color: #AAAAAA;
font-family: 'Open Sans', sans-serif;
margin: 0;
line-height: 1.5;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 40px 20px 80px;
}
.header {
text-align: center;
margin-bottom: 40px;
}
.header h1 {
font-size: 1.4rem;
font-weight: 400;
color: #DDDDDD;
margin: 0;
}
.header .ka {
display: block;
font-size: 0.85rem;
color: #666666;
margin-top: 6px;
}
.section {
margin: 36px 0;
}
.section h2 {
font-size: 1rem;
font-weight: 600;
color: #DDDDDD;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid #2A2A2A;
padding-bottom: 6px;
margin: 0 0 16px;
}
ul.services {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 24px;
}
ul.services li {
padding-left: 14px;
position: relative;
}
ul.services li::before {
content: '';
position: absolute;
left: 0;
color: #555555;
}
dl.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 18px;
margin: 0;
}
dl.kv dt {
color: #777777;
font-size: 0.9rem;
}
dl.kv dd {
margin: 0;
color: #CCCCCC;
}
dl.kv dd.mono,
.mono {
font-family: 'Hack', monospace;
font-size: 0.92rem;
}
.ka-note {
color: #666666;
font-size: 0.85rem;
margin-top: 4px;
}
pre {
position: relative;
background: #222222;
padding: 10px;
font-size: 14px;
font-family: 'Hack', monospace;
word-wrap: break-word;
white-space: pre-wrap;
border: 1px solid #020202;
margin: 8px 0;
}
.copy-button {
position: absolute;
top: 5px;
right: 10px;
cursor: pointer;
color: #424242;
background: #111111;
border-radius: 0.5em;
padding: 2px 7px;
}
.copy-button:hover {
color: #000;
}
a {
color: #BBBBBB;
text-decoration: none;
border-bottom: 1px dotted #444444;
}
a:hover {
color: #FFFFFF;
border-bottom-color: #888888;
}
.footer {
text-align: center;
color: #555555;
font-size: 0.8rem;
padding: 24px 20px;
border-top: 1px solid #1E1E1E;
}
/* 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: 14px;
}
.contact-form label {
display: block;
color: #888888;
font-size: 0.85rem;
margin-bottom: 4px;
}
.contact-form .optional {
color: #555555;
font-size: 0.8rem;
}
.contact-form input[type="text"],
.contact-form input[type="email"],
.contact-form textarea {
width: 100%;
box-sizing: border-box;
background: #1A1A1A;
color: #DDDDDD;
border: 1px solid #2A2A2A;
border-radius: 3px;
padding: 8px 10px;
font-family: inherit;
font-size: 0.95rem;
}
.contact-form textarea {
font-family: 'Hack', monospace;
font-size: 0.9rem;
resize: vertical;
}
.contact-form input:focus,
.contact-form textarea:focus {
outline: none;
border-color: #555555;
background: #1E1E1E;
}
.contact-form button {
background: #2A2A2A;
color: #DDDDDD;
border: 1px solid #3A3A3A;
border-radius: 3px;
padding: 8px 20px;
font-family: inherit;
font-size: 0.95rem;
cursor: pointer;
}
.contact-form button:hover {
background: #333333;
border-color: #555555;
}
.form-error {
display: block;
color: #C97070;
font-size: 0.85rem;
margin-top: 4px;
}
.form-error-base {
background: #2A1414;
border: 1px solid #4A1F1F;
padding: 10px;
border-radius: 3px;
margin-bottom: 16px;
}
.contact-direct {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #2A2A2A;
}
@media (max-width: 560px) {
ul.services {
grid-template-columns: 1fr;
}
dl.kv {
grid-template-columns: 1fr;
gap: 2px 0;
}
dl.kv dd {
margin-bottom: 8px;
}
}

52
views/index.erb Normal file
View file

@ -0,0 +1,52 @@
<div class="header">
<h1>DevOps &amp; infrastructure engineering — IE Sergei Poljanski, Tbilisi.</h1>
<span class="ka">ინდ. მეწარმე სერგეი პოლჯანსკი</span>
</div>
<section class="section">
<h2>Services</h2>
<ul class="services">
<li>IT infrastructure management</li>
<li>System administration</li>
<li>DevOps engineering</li>
<li>Software deployment &amp; configuration</li>
<li>CI/CD pipeline development</li>
<li>Container orchestration (Docker, Kubernetes)</li>
<li>Infrastructure as Code (Terraform, Ansible)</li>
<li>Monitoring &amp; observability (Prometheus, Grafana, Loki)</li>
</ul>
</section>
<section class="section">
<h2>Legal</h2>
<dl class="kv">
<dt>Legal name</dt><dd>IE Sergei Poljanski</dd>
<dt>Latin form</dt><dd class="mono">SERGEI POLJANSKI</dd>
<dt>Legal form</dt><dd>Individual Entrepreneur (Georgia)</dd>
<dt>Tax ID</dt><dd class="mono">304813343</dd>
<dt>Registered</dt><dd>2026-05-04</dd>
<dt>Address</dt><dd>Ilia and Nino Nakashidze St, N 1, Building N3, Apt N3, Krtsanisi, Tbilisi, Georgia</dd>
</dl>
<div class="ka-note">ინდ. მეწარმე სერგეი პოლჯანსკი</div>
</section>
<section class="section">
<h2>Keys</h2>
<pre id="myPGP">gpg --recv-keys 85F7AFEDAB7D97BE667F99F24F8851660FA4121B</pre>
<pre id="mySSH">ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io</pre>
</section>
<section class="section">
<h2>Contact</h2>
<%== erb :'partials/_contact_form', layout: false %>
<dl class="kv contact-direct">
<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>
<dt>Phone</dt><dd class="mono">+995 595 026 471</dd>
</dl>
</section>
<script src="/copyPGP.js"></script>
<script src="/copySSH.js"></script>

21
views/layout.erb Normal file
View file

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IE Sergei Poljanski</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans">
<link rel='stylesheet' href='//cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/hack-subset.css'>
<link rel="icon" type="image/png" href="/hedgehog.png" sizes="32x32" />
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main>
<%== yield %>
</main>
<div class="footer">
IE Sergei Poljanski · Tbilisi, Georgia · ID 304813343
</div>
</body>
</html>

View file

@ -0,0 +1,42 @@
<% 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="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 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>

13
views/thanks.erb Normal file
View file

@ -0,0 +1,13 @@
<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>