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
|
||||
README.md
|
||||
.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"
|
||||
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
|
||||
uses: appleboy/ssh-action@v1.2.3
|
||||
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
|
||||
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"]
|
||||
|
|
|
|||
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:
|
||||
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:
|
||||
|
|
|
|||
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