From 793d093c86bc2982d1a1dd43a8e34b90fa665191 Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Sun, 26 Jul 2026 18:43:43 +0200 Subject: [PATCH] Add Telegram contact API with JSONL backup and real form errors. Wire the anonymous form to a VPS Python endpoint that notifies Telegram, persists submissions locally, and stops faking success on failure. Co-authored-by: Cursor --- .gitignore | 3 + api/.env.example | 12 ++ api/README.md | 109 +++++++++++++++++ api/contact_server.py | 268 +++++++++++++++++++++++++++++++++++++++++ common.js | 8 +- en/index.html | 7 +- en/terminal/index.html | 7 +- index.html | 7 +- scripts/build.py | 4 + src/index.html | 7 +- src/terminal.html | 7 +- src/translations.json | 3 + styles.css | 4 + terminal/index.html | 7 +- ua/index.html | 7 +- ua/terminal/index.html | 7 +- 16 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 api/.env.example create mode 100644 api/README.md create mode 100644 api/contact_server.py diff --git a/.gitignore b/.gitignore index f928a7f..7bdf57f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .tools/ *.zip .DS_Store +.env +api/.env +api/messages.jsonl diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..451ff2e --- /dev/null +++ b/api/.env.example @@ -0,0 +1,12 @@ +# Copy to api/.env on the VPS and fill in real values. +# Never commit .env. + +TELEGRAM_BOT_TOKEN=1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz +TELEGRAM_CHAT_ID=123456789 + +# Bind address (default: loopback only) +HOST=127.0.0.1 +PORT=8787 + +# Local JSONL backup of submissions (relative to api/ or absolute path) +MESSAGES_FILE=messages.jsonl diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..5d7ba4f --- /dev/null +++ b/api/README.md @@ -0,0 +1,109 @@ +# Contact API → Telegram + +Small Python 3 (stdlib only) service that accepts `POST /api/contact` and forwards the message to Telegram. + +## Setup (once) + +1. Create a bot with [@BotFather](https://t.me/BotFather) → copy the token. +2. Open a chat with the bot and press **Start**. +3. Get your numeric chat id (`@userinfobot`, or call `getUpdates` after messaging the bot). +4. On the VPS: + +```bash +cd /path/to/site/api +cp .env.example .env +# edit .env — set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID +``` + +## Local smoke test + +```bash +cd api +cp .env.example .env # fill tokens +python3 contact_server.py +``` + +```bash +curl -sS -X POST http://127.0.0.1:8787/api/contact \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test","contact":"@you","message":"hello"}' +``` + +Health check: `GET /api/health` → `{"ok":true,"configured":true}`. + +## systemd + +`/etc/systemd/system/428th-contact.service`: + +```ini +[Unit] +Description=428th contact form → Telegram +After=network.target + +[Service] +Type=simple +WorkingDirectory=/var/www/428th.com/api +EnvironmentFile=/var/www/428th.com/api/.env +ExecStart=/usr/bin/python3 /var/www/428th.com/api/contact_server.py +Restart=always +RestartSec=3 +User=www-data +Group=www-data + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now 428th-contact +sudo systemctl status 428th-contact +``` + +Adjust `WorkingDirectory`, `EnvironmentFile`, `User`, and paths to match your VPS layout. + +## nginx + +Inside the HTTPS server block for `428th.com`: + +```nginx +location /api/contact { + proxy_pass http://127.0.0.1:8787/api/contact; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 16k; +} + +# optional +location /api/health { + proxy_pass http://127.0.0.1:8787/api/health; + proxy_set_header Host $host; +} +``` + +Then: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +Deploy updated static files (`common.js`, built HTML) as usual so the form shows real errors and includes the honeypot field. + +## Message backup + +Every valid submission is appended to `messages.jsonl` (override with `MESSAGES_FILE` in `.env`), one JSON object per line: + +```json +{"ts":"2026-07-26T16:40:00Z","name":"…","contact":"…","message":"…","ip":"1.2.3.4","telegram_ok":true,"telegram_error":null} +``` + +Backup is written even if Telegram fails (`telegram_ok: false`), so you still have the text on disk. Honeypot hits are not stored. Keep this file private (same permissions as `.env`); it is gitignored. + +## Behaviour notes + +- Honeypot field `website`: if filled, returns `200` without notifying Telegram or writing a backup. +- In-memory rate limit: 5 POSTs per IP per 60 seconds → `429`. +- Field limits: name/contact ≤ 200 chars, message ≤ 4000. diff --git a/api/contact_server.py b/api/contact_server.py new file mode 100644 index 0000000..b61c10f --- /dev/null +++ b/api/contact_server.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Minimal /api/contact endpoint — forwards form submissions to Telegram.""" + +from __future__ import annotations + +import json +import os +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import defaultdict, deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +# ---------- config ---------- + +ROOT = Path(__file__).resolve().parent +MAX_NAME = 200 +MAX_CONTACT = 200 +MAX_MESSAGE = 4000 +MAX_BODY = 16_384 +RATE_LIMIT = 5 # requests +RATE_WINDOW = 60 # seconds + + +def load_dotenv(path: Path) -> None: + if not path.is_file(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'").strip('"') + if key and key not in os.environ: + os.environ[key] = value + + +load_dotenv(ROOT / ".env") + +BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() +CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "").strip() +HOST = os.environ.get("HOST", "127.0.0.1").strip() or "127.0.0.1" +PORT = int(os.environ.get("PORT", "8787")) +_backup_raw = os.environ.get("MESSAGES_FILE", "messages.jsonl").strip() or "messages.jsonl" +MESSAGES_FILE = Path(_backup_raw) +if not MESSAGES_FILE.is_absolute(): + MESSAGES_FILE = ROOT / MESSAGES_FILE + +_rate_lock = threading.Lock() +_file_lock = threading.Lock() +_rate_hits: dict[str, deque[float]] = defaultdict(deque) + + +def client_ip(handler: BaseHTTPRequestHandler) -> str: + forwarded = handler.headers.get("X-Real-IP") or handler.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return handler.client_address[0] + + +def rate_limited(ip: str) -> bool: + now = time.monotonic() + with _rate_lock: + q = _rate_hits[ip] + while q and now - q[0] > RATE_WINDOW: + q.popleft() + if len(q) >= RATE_LIMIT: + return True + q.append(now) + return False + + +def send_telegram(text: str) -> None: + if not BOT_TOKEN or not CHAT_ID: + raise RuntimeError("Telegram is not configured") + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" + payload = urllib.parse.urlencode( + { + "chat_id": CHAT_ID, + "text": text, + "disable_web_page_preview": "1", + } + ).encode("utf-8") + req = urllib.request.Request( + url, + data=payload, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=15) as resp: + body = json.loads(resp.read().decode("utf-8")) + if not body.get("ok"): + raise RuntimeError("Telegram API rejected the message") + + +def append_message(record: dict) -> None: + """Append one JSON line to the backup file (best-effort durable log).""" + line = json.dumps(record, ensure_ascii=False) + "\n" + with _file_lock: + MESSAGES_FILE.parent.mkdir(parents=True, exist_ok=True) + with MESSAGES_FILE.open("a", encoding="utf-8") as f: + f.write(line) + f.flush() + os.fsync(f.fileno()) + + +def validate_fields(data: dict) -> tuple[str, str, str] | str: + """Return (name, contact, message) or an error code string.""" + if not isinstance(data, dict): + return "invalid" + + # Honeypot — treat as success upstream; caller checks separately. + website = data.get("website", "") + if isinstance(website, str) and website.strip(): + return "honeypot" + + def field(key: str, max_len: int) -> str | None: + raw = data.get(key, "") + if not isinstance(raw, str): + return None + value = raw.strip() + if not value or len(value) > max_len: + return None + return value + + name = field("name", MAX_NAME) + contact = field("contact", MAX_CONTACT) + message = field("message", MAX_MESSAGE) + if name is None or contact is None or message is None: + return "invalid" + return name, contact, message + + +class ContactHandler(BaseHTTPRequestHandler): + server_version = "428thContact/1.0" + + def log_message(self, fmt: str, *args) -> None: + # Avoid logging bodies; keep a short access line. + sys_stderr = __import__("sys").stderr + print( + f"{client_ip(self)} - [{self.log_date_time_string()}] {fmt % args}", + file=sys_stderr, + ) + + def _json(self, status: int, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + # Same-origin from the site; allow preflight just in case. + if self.path.rstrip("/") != "/api/contact": + self.send_error(404) + return + self.send_response(204) + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_POST(self) -> None: + if self.path.rstrip("/") != "/api/contact": + self._json(404, {"ok": False, "error": "not_found"}) + return + + ip = client_ip(self) + if rate_limited(ip): + self._json(429, {"ok": False, "error": "rate_limited"}) + return + + length_raw = self.headers.get("Content-Length", "0") + try: + length = int(length_raw) + except ValueError: + self._json(400, {"ok": False, "error": "invalid"}) + return + if length < 0 or length > MAX_BODY: + self._json(400, {"ok": False, "error": "invalid"}) + return + + raw = self.rfile.read(length) + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self._json(400, {"ok": False, "error": "invalid"}) + return + + result = validate_fields(data) + if result == "honeypot": + self._json(200, {"ok": True}) + return + if isinstance(result, str): + self._json(400, {"ok": False, "error": "invalid"}) + return + + name, contact, message = result + text = ( + "New contact form\n" + f"Name: {name}\n" + f"Contact: {contact}\n" + f"Message:\n{message}" + ) + + telegram_ok = False + telegram_error = None + try: + send_telegram(text) + telegram_ok = True + except (urllib.error.URLError, TimeoutError, RuntimeError, OSError) as err: + telegram_error = type(err).__name__ + + try: + append_message( + { + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "name": name, + "contact": contact, + "message": message, + "ip": ip, + "telegram_ok": telegram_ok, + "telegram_error": telegram_error, + } + ) + except OSError: + # Disk failure after Telegram: still report based on Telegram. + if not telegram_ok: + self._json(502, {"ok": False, "error": "upstream"}) + return + # Telegram delivered; backup failed — accept but log. + print(f"WARNING: failed to append backup for {ip}", flush=True) + + if not telegram_ok: + self._json(502, {"ok": False, "error": "upstream"}) + return + + self._json(200, {"ok": True}) + + def do_GET(self) -> None: + if self.path.rstrip("/") == "/api/health": + configured = bool(BOT_TOKEN and CHAT_ID) + self._json(200, {"ok": True, "configured": configured}) + return + self._json(404, {"ok": False, "error": "not_found"}) + + +def main() -> None: + if not BOT_TOKEN or not CHAT_ID: + print( + "WARNING: TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — " + "POSTs will fail with 502 until configured.", + flush=True, + ) + print(f"message backup file: {MESSAGES_FILE}", flush=True) + httpd = ThreadingHTTPServer((HOST, PORT), ContactHandler) + print(f"contact API listening on http://{HOST}:{PORT}/api/contact", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/common.js b/common.js index 38dfa68..cb6502a 100644 --- a/common.js +++ b/common.js @@ -449,19 +449,17 @@ function initContactForm(){ const data = Object.fromEntries(new FormData(form).entries()); try { - const res = await fetch('/api/contact', { // placeholder — заменит программист + const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error('request_failed'); status.textContent = form.dataset.statusSuccess || '✓'; form.reset(); setTimeout(() => modal.classList.remove('open'), 1200); } catch (err) { - status.textContent = form.dataset.statusSuccess || '✓'; // без реального сервера считаем успехом - form.reset(); - setTimeout(() => modal.classList.remove('open'), 1200); + status.textContent = form.dataset.statusError || 'Error'; } }); } diff --git a/en/index.html b/en/index.html index 054aa2b..cbdddb3 100644 --- a/en/index.html +++ b/en/index.html @@ -427,7 +427,12 @@

Contact form

-
+ + +