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 <cursoragent@cursor.com>
This commit is contained in:
Artemii Peretiachenko 2026-07-26 18:43:43 +02:00
parent 228aba8af4
commit 793d093c86
16 changed files with 454 additions and 13 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
.tools/ .tools/
*.zip *.zip
.DS_Store .DS_Store
.env
api/.env
api/messages.jsonl

12
api/.env.example Normal file
View file

@ -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

109
api/README.md Normal file
View file

@ -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.

268
api/contact_server.py Normal file
View file

@ -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()

View file

@ -449,19 +449,17 @@ function initContactForm(){
const data = Object.fromEntries(new FormData(form).entries()); const data = Object.fromEntries(new FormData(form).entries());
try { try {
const res = await fetch('/api/contact', { // placeholder — заменит программист const res = await fetch('/api/contact', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error('request_failed');
status.textContent = form.dataset.statusSuccess || '✓'; status.textContent = form.dataset.statusSuccess || '✓';
form.reset(); form.reset();
setTimeout(() => modal.classList.remove('open'), 1200); setTimeout(() => modal.classList.remove('open'), 1200);
} catch (err) { } catch (err) {
status.textContent = form.dataset.statusSuccess || '✓'; // без реального сервера считаем успехом status.textContent = form.dataset.statusError || 'Error';
form.reset();
setTimeout(() => modal.classList.remove('open'), 1200);
} }
}); });
} }

View file

@ -427,7 +427,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3>Contact form</h3> <h3>Contact form</h3>
<form id="contactForm" data-status-loading="..." data-status-success="Sent ✓"> <form id="contactForm" data-status-loading="..." data-status-success="Sent ✓" data-status-error="Could not send. Please try again.">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span>Name</span> <span>Name</span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -276,7 +276,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3>Contact form</h3> <h3>Contact form</h3>
<form id="contactForm" data-status-loading="..." data-status-success="Sent ✓"> <form id="contactForm" data-status-loading="..." data-status-success="Sent ✓" data-status-error="Could not send. Please try again.">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span>Name</span> <span>Name</span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -427,7 +427,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3>Форма для связи</h3> <h3>Форма для связи</h3>
<form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓"> <form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓" data-status-error="Не удалось отправить. Попробуйте ещё раз.">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span>Имя</span> <span>Имя</span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -314,6 +314,10 @@ def build_page(locale: dict, page: dict, translations: dict, template: str) -> s
"{{FORM_STATUS_SUCCESS}}", "{{FORM_STATUS_SUCCESS}}",
esc_attr(t(translations, locale["dict"], "form.statusSuccess")), esc_attr(t(translations, locale["dict"], "form.statusSuccess")),
) )
html = html.replace(
"{{FORM_STATUS_ERROR}}",
esc_attr(t(translations, locale["dict"], "form.statusError")),
)
html = fill_i18n(html, translations, locale["dict"]) html = fill_i18n(html, translations, locale["dict"])
html = localize_urls(html, prefix) html = localize_urls(html, prefix)

View file

@ -332,7 +332,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3 data-i18n="form.title"></h3> <h3 data-i18n="form.title"></h3>
<form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}"> <form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}" data-status-error="{{FORM_STATUS_ERROR}}">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span data-i18n="form.nameLabel"></span> <span data-i18n="form.nameLabel"></span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -181,7 +181,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3 data-i18n="form.title"></h3> <h3 data-i18n="form.title"></h3>
<form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}"> <form id="contactForm" data-status-loading="{{FORM_STATUS_LOADING}}" data-status-success="{{FORM_STATUS_SUCCESS}}" data-status-error="{{FORM_STATUS_ERROR}}">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span data-i18n="form.nameLabel"></span> <span data-i18n="form.nameLabel"></span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -123,6 +123,7 @@
"form.submit": "Отправить", "form.submit": "Отправить",
"form.statusLoading": "...", "form.statusLoading": "...",
"form.statusSuccess": "Отправлено ✓", "form.statusSuccess": "Отправлено ✓",
"form.statusError": "Не удалось отправить. Попробуйте ещё раз.",
"a11y.toggleTheme": "Переключить тему", "a11y.toggleTheme": "Переключить тему",
"a11y.sections": "Разделы", "a11y.sections": "Разделы",
"terminal.meta.title": "428th Terminal — автотрейдинг по TradingView сигналам", "terminal.meta.title": "428th Terminal — автотрейдинг по TradingView сигналам",
@ -289,6 +290,7 @@
"form.submit": "Надіслати", "form.submit": "Надіслати",
"form.statusLoading": "...", "form.statusLoading": "...",
"form.statusSuccess": "Надіслано ✓", "form.statusSuccess": "Надіслано ✓",
"form.statusError": "Не вдалося надіслати. Спробуйте ще раз.",
"a11y.toggleTheme": "Перемкнути тему", "a11y.toggleTheme": "Перемкнути тему",
"a11y.sections": "Розділи", "a11y.sections": "Розділи",
"terminal.meta.title": "428th Terminal — автотрейдинг за сигналами TradingView", "terminal.meta.title": "428th Terminal — автотрейдинг за сигналами TradingView",
@ -455,6 +457,7 @@
"form.submit": "Send", "form.submit": "Send",
"form.statusLoading": "...", "form.statusLoading": "...",
"form.statusSuccess": "Sent ✓", "form.statusSuccess": "Sent ✓",
"form.statusError": "Could not send. Please try again.",
"a11y.toggleTheme": "Toggle theme", "a11y.toggleTheme": "Toggle theme",
"a11y.sections": "Sections", "a11y.sections": "Sections",
"terminal.meta.title": "428th Terminal — auto trading via TradingView signals", "terminal.meta.title": "428th Terminal — auto trading via TradingView signals",

View file

@ -440,6 +440,10 @@ a:hover{opacity:0.7;}
font-size:22px; cursor:pointer; color:var(--text); font-size:22px; cursor:pointer; color:var(--text);
} }
#contactForm label{display:block; margin-bottom:20px; font-size:13px; color:var(--text);} #contactForm label{display:block; margin-bottom:20px; font-size:13px; color:var(--text);}
#contactForm .hp-field{
position:absolute; left:-10000px; top:auto; width:1px; height:1px;
overflow:hidden; opacity:0; pointer-events:none;
}
#contactForm input, #contactForm textarea{ #contactForm input, #contactForm textarea{
width:100%; margin-top:6px; padding:10px; border:1px solid var(--border); width:100%; margin-top:6px; padding:10px; border:1px solid var(--border);
border-radius:4px; background:transparent; color:var(--heading); font-family:inherit; border-radius:4px; background:transparent; color:var(--heading); font-family:inherit;

View file

@ -276,7 +276,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3>Форма для связи</h3> <h3>Форма для связи</h3>
<form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓"> <form id="contactForm" data-status-loading="..." data-status-success="Отправлено ✓" data-status-error="Не удалось отправить. Попробуйте ещё раз.">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span>Имя</span> <span>Имя</span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -427,7 +427,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3>Форма для зв'язку</h3> <h3>Форма для зв'язку</h3>
<form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓"> <form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓" data-status-error="Не вдалося надіслати. Спробуйте ще раз.">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span>Ім'я</span> <span>Ім'я</span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">

View file

@ -276,7 +276,12 @@
<button class="modal-close" id="closeContactForm">×</button> <button class="modal-close" id="closeContactForm">×</button>
<h3>Форма для зв'язку</h3> <h3>Форма для зв'язку</h3>
<form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓"> <form id="contactForm" data-status-loading="..." data-status-success="Надіслано ✓" data-status-error="Не вдалося надіслати. Спробуйте ще раз.">
<label class="hp-field" aria-hidden="true">
<span>Website</span>
<input type="text" name="website" tabindex="-1" autocomplete="off">
</label>
<label> <label>
<span>Ім'я</span> <span>Ім'я</span>
<input type="text" name="name" required=""> <input type="text" name="name" required="">