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

+