From 1ab09043ee256994689a3f5f6cc594bf42775162 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Tue, 5 May 2026 00:45:27 +0400 Subject: [PATCH] ruby rework with contact form --- .dockerignore | 9 + .env.example | 12 ++ .forgejo/workflows/deploy.yaml | 26 +++ .gitignore | 6 + CLAUDE.md | 86 ++++++++++ Dockerfile | 35 ++-- Gemfile | 17 ++ Gemfile.lock | 106 ++++++++++++ asxpio.rb | 119 +++++++++++++ config.ru | 3 + docker-compose.yml | 8 +- flake.lock | 27 +++ flake.nix | 44 +++++ index.html | 205 ---------------------- lib/mailer.rb | 72 ++++++++ lib/rate_limit.rb | 31 ++++ copyPGP.js => public/copyPGP.js | 0 copySSH.js => public/copySSH.js | 0 hedgehog.png => public/hedgehog.png | Bin id_ed25519.pub => public/id_ed25519.pub | 2 +- pgp.pub => public/pgp.pub | 0 public/style.css | 217 ++++++++++++++++++++++++ views/index.erb | 52 ++++++ views/layout.erb | 21 +++ views/partials/_contact_form.erb | 42 +++++ views/thanks.erb | 13 ++ 26 files changed, 932 insertions(+), 221 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 asxpio.rb create mode 100644 config.ru create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 index.html create mode 100644 lib/mailer.rb create mode 100644 lib/rate_limit.rb rename copyPGP.js => public/copyPGP.js (100%) rename copySSH.js => public/copySSH.js (100%) rename hedgehog.png => public/hedgehog.png (100%) rename id_ed25519.pub => public/id_ed25519.pub (65%) rename pgp.pub => public/pgp.pub (100%) create mode 100644 public/style.css create mode 100644 views/index.erb create mode 100644 views/layout.erb create mode 100644 views/partials/_contact_form.erb create mode 100644 views/thanks.erb diff --git a/.dockerignore b/.dockerignore index 89eeced..ab6fdaa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,12 @@ .DS_Store README.md .dockerignore +.env +.gems +.bundle +vendor/bundle +*.log +tmp +flake.nix +flake.lock +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aacc6ce --- /dev/null +++ b/.env.example @@ -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 " +MAIL_TO=ie@asxp.io diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index 136b842..32737d3 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -56,6 +56,32 @@ jobs: SOURCE: "docker-compose.yml" 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 < + MAIL_TO=ie@asxp.io + EOF + chmod 600 /opt/asxpio/.env + - name: Deploy and update container uses: appleboy/ssh-action@v1.2.3 env: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aec92ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.gems/ +.bundle/ +vendor/bundle/ +*.log +tmp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9314ce6 --- /dev/null +++ b/CLAUDE.md @@ -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: `. +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. diff --git a/Dockerfile b/Dockerfile index 7cc12bc..c65b5c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,26 @@ -FROM nginx:alpine +FROM ruby:3.4-slim-bookworm -# Copy website files to nginx html directory -COPY index.html /usr/share/nginx/html/ -COPY id_ed25519.pub /usr/share/nginx/html/ -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/ +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* -# Expose port 80 -EXPOSE 80 +RUN groupadd -g 11000 asxpio \ + && useradd -d /app -u 21000 -g 11000 -m -s /bin/bash asxpio -# Start nginx -CMD ["nginx", "-g", "daemon off;"] +WORKDIR /app +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"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..26deb0e --- /dev/null +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..9af2c9a --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/asxpio.rb b/asxpio.rb new file mode 100644 index 0000000..5c0dcf1 --- /dev/null +++ b/asxpio.rb @@ -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 diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..e2a968f --- /dev/null +++ b/config.ru @@ -0,0 +1,3 @@ +require 'sinatra/base' +require './asxpio.rb' +run AsxpioWeb.new diff --git a/docker-compose.yml b/docker-compose.yml index 65e1dcf..68118d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,12 @@ services: asxpio: # Image will be dynamically updated during deployment - # Format: registry.example.com/user/czsk:sha_tag - image: nginx:alpine + # Format: registry.example.com/user/asxpio:sha_tag + build: . + image: asxpio:latest container_name: asxpio restart: unless-stopped + env_file: .env networks: - traefik labels: @@ -13,7 +15,7 @@ services: - "traefik.http.routers.asxpio.entrypoints=websecure" - "traefik.http.routers.asxpio.tls=true" - "traefik.http.routers.asxpio.tls.certresolver=letsencrypt" - - "traefik.http.services.asxpio.loadbalancer.server.port=80" + - "traefik.http.services.asxpio.loadbalancer.server.port=3000" networks: traefik: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..25b0fc5 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..aa96987 --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + }; +} diff --git a/index.html b/index.html deleted file mode 100644 index 3838c71..0000000 --- a/index.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - - - IE Sergei Poljanski - - - - - - -
-
-

DevOps & infrastructure engineering — IE Sergei Poljanski, Tbilisi.

- ინდ. მეწარმე სერგეი პოლჯანსკი -
- -
-

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)
  • -
-
- -
-

Legal

-
-
Legal name
IE Sergei Poljanski
-
Latin form
SERGEI POLJANSKI
-
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
-
-
ინდ. მეწარმე სერგეი პოლჯანსკი
-
- -
-

Keys

-
gpg --recv-keys 85F7AFEDAB7D97BE667F99F24F8851660FA4121B
-
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io
-
- -
-

Contact

-
-
Email
ie@asxp.io
-
Telegram
t.me/ie_asxpi
-
Phone
+995 595 026 471
-
-
-
- - - - - - - diff --git a/lib/mailer.rb b/lib/mailer.rb new file mode 100644 index 0000000..c177ed6 --- /dev/null +++ b/lib/mailer.rb @@ -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 diff --git a/lib/rate_limit.rb b/lib/rate_limit.rb new file mode 100644 index 0000000..27d02ab --- /dev/null +++ b/lib/rate_limit.rb @@ -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 diff --git a/copyPGP.js b/public/copyPGP.js similarity index 100% rename from copyPGP.js rename to public/copyPGP.js diff --git a/copySSH.js b/public/copySSH.js similarity index 100% rename from copySSH.js rename to public/copySSH.js diff --git a/hedgehog.png b/public/hedgehog.png similarity index 100% rename from hedgehog.png rename to public/hedgehog.png diff --git a/id_ed25519.pub b/public/id_ed25519.pub similarity index 65% rename from id_ed25519.pub rename to public/id_ed25519.pub index a023c28..bcc58d4 100644 --- a/id_ed25519.pub +++ b/public/id_ed25519.pub @@ -1 +1 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB me@asxp.io +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZ+/5F7SxdTVW47iiStLpGK77oWfR5NgaK4tTSR/aVB ie+2026@asxp.io diff --git a/pgp.pub b/public/pgp.pub similarity index 100% rename from pgp.pub rename to public/pgp.pub diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..9d56c45 --- /dev/null +++ b/public/style.css @@ -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; + } +} diff --git a/views/index.erb b/views/index.erb new file mode 100644 index 0000000..3392702 --- /dev/null +++ b/views/index.erb @@ -0,0 +1,52 @@ +
+

DevOps & infrastructure engineering — IE Sergei Poljanski, Tbilisi.

+ ინდ. მეწარმე სერგეი პოლჯანსკი +
+ +
+

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)
  • +
+
+ +
+

Legal

+
+
Legal name
IE Sergei Poljanski
+
Latin form
SERGEI POLJANSKI
+
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
+
+
ინდ. მეწარმე სერგეი პოლჯანსკი
+
+ +
+

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
+
+
+ + + diff --git a/views/layout.erb b/views/layout.erb new file mode 100644 index 0000000..a764563 --- /dev/null +++ b/views/layout.erb @@ -0,0 +1,21 @@ + + + + + + IE Sergei Poljanski + + + + + + +
+ <%== yield %> +
+ + + + diff --git a/views/partials/_contact_form.erb b/views/partials/_contact_form.erb new file mode 100644 index 0000000..162bd47 --- /dev/null +++ b/views/partials/_contact_form.erb @@ -0,0 +1,42 @@ +<% 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 new file mode 100644 index 0000000..f1ddeaf --- /dev/null +++ b/views/thanks.erb @@ -0,0 +1,13 @@ +
+

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

+