ruby rework with contact form
This commit is contained in:
parent
90402ae14a
commit
1ab09043ee
26 changed files with 932 additions and 221 deletions
|
|
@ -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
12
.env.example
Normal 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
|
||||||
|
|
@ -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
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.env
|
||||||
|
.gems/
|
||||||
|
.bundle/
|
||||||
|
vendor/bundle/
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal 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.
|
||||||
35
Dockerfile
35
Dockerfile
|
|
@ -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
17
Gemfile
Normal 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
106
Gemfile.lock
Normal 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
119
asxpio.rb
Normal 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 (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
|
||||||
|
erb :thanks
|
||||||
|
end
|
||||||
|
|
||||||
|
error 403 do
|
||||||
|
'Forbidden — likely CSRF token expired. Reload the page and try again.'
|
||||||
|
end
|
||||||
|
end
|
||||||
3
config.ru
Normal file
3
config.ru
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
require 'sinatra/base'
|
||||||
|
require './asxpio.rb'
|
||||||
|
run AsxpioWeb.new
|
||||||
|
|
@ -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
27
flake.lock
generated
Normal 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
44
flake.nix
Normal 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"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
205
index.html
205
index.html
|
|
@ -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 & 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 & configuration</li>
|
|
||||||
<li>CI/CD pipeline development</li>
|
|
||||||
<li>Container orchestration (Docker, Kubernetes)</li>
|
|
||||||
<li>Infrastructure as Code (Terraform, Ansible)</li>
|
|
||||||
<li>Monitoring & 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
72
lib/mailer.rb
Normal 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
31
lib/rate_limit.rb
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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
217
public/style.css
Normal 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
52
views/index.erb
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<div class="header">
|
||||||
|
<h1>DevOps & 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 & configuration</li>
|
||||||
|
<li>CI/CD pipeline development</li>
|
||||||
|
<li>Container orchestration (Docker, Kubernetes)</li>
|
||||||
|
<li>Infrastructure as Code (Terraform, Ansible)</li>
|
||||||
|
<li>Monitoring & 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
21
views/layout.erb
Normal 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>
|
||||||
42
views/partials/_contact_form.erb
Normal file
42
views/partials/_contact_form.erb
Normal 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
13
views/thanks.erb
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue