Files

477 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import json
import time
import random
import string
import threading
from collections import defaultdict
from http.server import HTTPServer, ThreadingHTTPServer, SimpleHTTPRequestHandler
from datetime import datetime
import sys
# Automatically handle dependencies: install openpyxl if it's missing
try:
import openpyxl
except ImportError:
import subprocess
print("Библиотека 'openpyxl' не найдена. Устанавливаем...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "openpyxl"])
import openpyxl
print("Библиотека 'openpyxl' успешно установлена!")
except Exception as e:
print(f"Ошибка при автоматической установке openpyxl: {e}")
print("Пожалуйста, установите её вручную: pip install openpyxl")
sys.exit(1)
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, Alignment, PatternFill
# Add parent directory to sys.path to load database.py and config.py
PARENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
try:
import config
from database import DatabaseManager
db = DatabaseManager(db_path=os.path.join(PARENT_DIR, "bot_database.db"))
except Exception as e:
print(f"Предупреждение: Не удалось загрузить модули базы данных/конфигурации: {e}")
db = None
config = None
try:
import mailer
except Exception as e:
print(f"Предупреждение: Не удалось загрузить модуль mailer: {e}")
mailer = None
try:
import vk_api
from vk_api.utils import get_random_id
except ImportError:
vk_api = None
# Path to the Excel spreadsheet in the same directory as server.py
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
FILE_NAME = os.path.join(SCRIPT_DIR, "registration_for_training.xlsx")
# Разрешённый источник для CORS (берётся из config, '*' только для локальной отладки)
ALLOWED_ORIGIN = getattr(config, "ALLOWED_ORIGIN", "*") if config else "*"
# ---------------------------------------------------------------
# Защита формы: валидация, рейт-лимит и honeypot
# ---------------------------------------------------------------
NAME_RE = re.compile(r"^[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё \-']{1,49}$")
EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
# Простой in-memory рейт-лимит по IP: не более N заявок за окно
RATE_LIMIT_MAX = 5
RATE_LIMIT_WINDOW = 600 # секунд (10 минут)
_rate_lock = threading.Lock()
_rate_hits = defaultdict(list)
# Сериализует приём заявок (генерация ID + запись), чтобы при многопоточности не было гонок
_submit_lock = threading.Lock()
_last_rate_cleanup = 0.0
def is_rate_limited(ip: str) -> bool:
"""True, если с этого IP было слишком много заявок за окно времени."""
global _last_rate_cleanup
now = time.time()
with _rate_lock:
# Периодически выметаем протухшие IP, чтобы словарь не рос бесконечно
if now - _last_rate_cleanup > RATE_LIMIT_WINDOW:
for old_ip in [k for k, v in _rate_hits.items()
if not v or now - v[-1] >= RATE_LIMIT_WINDOW]:
del _rate_hits[old_ip]
_last_rate_cleanup = now
hits = [t for t in _rate_hits[ip] if now - t < RATE_LIMIT_WINDOW]
if len(hits) >= RATE_LIMIT_MAX:
_rate_hits[ip] = hits
return True
hits.append(now)
_rate_hits[ip] = hits
return False
def validate_submission(data: dict):
"""Серверная валидация заявки. Возвращает (ok: bool, error: str|None)."""
name = (data.get("name") or "").strip()
phone_digits = re.sub(r"\D", "", data.get("phone") or "")
email = (data.get("email") or "").strip()
vk = (data.get("vk") or "").strip().lower()
if not NAME_RE.match(name):
return False, "Имя должно содержать только буквы (2–50 символов)."
# Российский (11 цифр) или международный (10–15 цифр) номер
if not (10 <= len(phone_digits) <= 15):
return False, "Некорректный номер телефона."
if not EMAIL_RE.match(email):
return False, "Некорректный e-mail."
if not (vk and ("vk.com" in vk or "vkontakte.ru" in vk)):
return False, "Ссылка должна вести на vk.com или vkontakte.ru."
if not data.get("agreeOferta") or not data.get("agreePolicy"):
return False, "Необходимо согласие с офертой и политикой обработки данных."
return True, None
def is_spam(data: dict) -> bool:
"""Honeypot: скрытое поле 'website' должно оставаться пустым у реальных пользователей."""
return bool((data.get("website") or "").strip())
def init_excel():
"""Create the Excel file with styled headers if it doesn't exist."""
recreate = False
if os.path.exists(FILE_NAME):
try:
wb = load_workbook(FILE_NAME)
ws = wb.active
# Recreate if it does not have the 'id_user' column as the first header
if ws.cell(row=1, column=1).value != "id_user":
recreate = True
except Exception:
recreate = True
if recreate:
try:
os.remove(FILE_NAME)
print("Обнаружена старая структура Excel файла. Пересоздаём с колонкой 'id_user'...")
except Exception as e:
print(f"Не удалось удалить старый Excel файл: {e}")
if not os.path.exists(FILE_NAME):
wb = Workbook()
ws = wb.active
ws.title = "Заявки на обучение"
headers = [
"id_user",
"Имя",
"Номер телефона",
"E-mail",
"Ссылка на профиль ВКонтакте",
"Согласие с офертой",
"Согласие с политикой",
"Согласие на рассылку",
"Дата регистрации"
]
ws.append(headers)
# Style header cells (Bold white text, dark blue/gray background)
header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
# Adjust column widths
column_widths = [15, 20, 25, 25, 30, 20, 22, 22, 22]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
wb.save(FILE_NAME)
print(f"Создан новый файл Excel с колонкой id_user: {FILE_NAME}")
def get_next_user_id(ws):
"""Generate the next unique user ID in the format id_001, id_002..."""
max_row = ws.max_row
if max_row <= 1:
return "id_001"
# Read the ID from the last row's first cell
last_id_val = ws.cell(row=max_row, column=1).value
if not last_id_val or not str(last_id_val).startswith("id_"):
# Fallback to row counts if the last row ID is corrupted/not matching mask
return f"id_{(max_row - 1):03d}"
try:
# Extract the numeric suffix, increment, and format back
numeric_part = int(str(last_id_val).split("_")[1])
next_num = numeric_part + 1
return f"id_{next_num:03d}"
except (IndexError, ValueError):
return f"id_{max_row:03d}"
def generate_next_id():
"""Генерирует следующий ID заявки. Источник правды — SQLite; Excel используется как запасной."""
if db:
try:
# По max(номеру), а не по COUNT — иначе при удалении записей возможна коллизия PRIMARY KEY
return db.next_web_registration_id()
except Exception as e:
print(f"Не удалось получить счётчик из SQLite ({e}), используем Excel как запасной источник.")
# Запасной вариант — по строкам Excel
try:
init_excel()
wb = load_workbook(FILE_NAME)
return get_next_user_id(wb.active)
except Exception:
return f"id_{int(time.time())}"
def append_to_excel(next_id, data, current_time):
"""Дописывает строку в Excel. Вынесено отдельно: ошибка здесь НЕ срывает приём заявки."""
init_excel()
try:
wb = load_workbook(FILE_NAME)
ws = wb.active
except Exception as e:
print(f"Ошибка загрузки Excel файла, пересоздаём: {e}")
init_excel()
wb = load_workbook(FILE_NAME)
ws = wb.active
row = [
next_id,
data.get("name", "").strip(),
data.get("phone", "").strip(),
data.get("email", "").strip(),
data.get("vk", "").strip(),
"Да" if data.get("agreeOferta") else "Нет",
"Да" if data.get("agreePolicy") else "Нет",
"Да" if data.get("agreeNewsletter") else "Нет",
current_time
]
ws.append(row)
new_row_index = ws.max_row
data_align_left = Alignment(horizontal="left", vertical="center")
data_align_center = Alignment(horizontal="center", vertical="center")
for col_idx in range(1, len(row) + 1):
cell = ws.cell(row=new_row_index, column=col_idx)
if col_idx in [2, 4, 5]:
cell.alignment = data_align_left
else:
cell.alignment = data_align_center
wb.save(FILE_NAME)
print(f"[{current_time}] Добавлена запись в Excel: ID={row[0]}, Имя={row[1]}, Телефон={row[2]}")
def gen_promo():
"""Персональный промокод на скидку — выдаётся клиенту на экране 'Спасибо'."""
return "ИКП-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
def add_registration(data):
"""Сохраняет заявку: SQLite (источник правды) → Excel (нестрого) → уведомления. Возвращает промокод."""
with _submit_lock:
return _add_registration_locked(data)
def _add_registration_locked(data):
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
next_id = generate_next_id()
# Промокод пока выключен флагом config.PROMO_ENABLED
promo = gen_promo() if (config and getattr(config, "PROMO_ENABLED", False)) else None
# 1. Запись в SQLite базу данных — основной приёмник, выполняется первым.
# Ошибка записи (например, коллизия ID) НЕ должна срывать остальные каналы
# доставки (Excel/VK/почта) — иначе заявка теряется целиком.
if db:
try:
db.add_web_registration(next_id, data)
except Exception as reg_err:
print(f"[{current_time}] ⚠️ Не удалось записать web_registration {next_id}: {reg_err}. "
f"Продолжаем доставку через Excel/VK/почту.")
# Единый журнал заявок для статистики (source='web')
try:
db.add_lead(
user_id=None, source="web",
name=data.get("name", "").strip(), phone=data.get("phone", "").strip(),
service=None, call_time=None, promo=promo,
marketing=bool(data.get("agreeNewsletter"))
)
except Exception as lead_err:
print(f"[{current_time}] Не удалось записать лид в журнал: {lead_err}")
print(f"[{current_time}] Заявка {next_id} сохранена в SQLite (web_registrations + leads).")
# 2. Дублирование в Excel — НЕ критично: если файл открыт/заблокирован, заявка всё равно принята
try:
append_to_excel(next_id, data, current_time)
except PermissionError:
print(f"[{current_time}] ⚠️ Excel заблокирован (открыт в программе). Заявка сохранена в SQLite, Excel пропущен.")
except Exception as xl_err:
print(f"[{current_time}] ⚠️ Ошибка записи в Excel (пропущено): {xl_err}")
# 3. Отправка уведомления оператору в VK
try:
send_vk_operator_notification(next_id, data)
except Exception as vk_err:
print(f"[{current_time}] Ошибка при отправке уведомления оператору: {vk_err}")
# 3. Отправка уведомления на корпоративную почту (group-zakaz@infocyber.pro)
if mailer:
try:
email_body = (
"Новая заявка с сайта.\n\n"
f"ID заявки: {next_id}\n"
f"Имя: {data.get('name', '').strip()}\n"
f"Телефон: {data.get('phone', '').strip()}\n"
f"E-mail: {data.get('email', '').strip()}\n"
f"Профиль VK: {data.get('vk', '').strip()}\n\n"
f"Согласия:\n"
f" - Оферта: {'Да' if data.get('agreeOferta') else 'Нет'}\n"
f" - Политика ПД: {'Да' if data.get('agreePolicy') else 'Нет'}\n"
f" - Рассылка: {'Да' if data.get('agreeNewsletter') else 'Нет'}\n\n"
+ (f"Промокод клиента: {promo}\n" if promo else "")
+ f"Дата: {current_time}\n"
)
mailer.send_email(f"Новая заявка с сайта — {data.get('name', '').strip()}", email_body)
except Exception as mail_err:
print(f"[{current_time}] Ошибка при отправке email: {mail_err}")
return promo
def send_vk_operator_notification(reg_id, data):
"""Отправить уведомление о новой веб-заявке в VK-чат оператора."""
if not vk_api or not config:
print("[VK NOTIFICATION] Пропущено: vk_api или конфигурация недоступны.")
return
token = getattr(config, "VK_TOKEN", "your_vk_community_token_here")
operator_id = getattr(config, "OPERATOR_PEER_ID", 0)
if not token or token == "your_vk_community_token_here" or not operator_id:
print("[VK NOTIFICATION] Пропущено: Токен VK или ID оператора не настроены в .env.")
return
try:
vk_session = vk_api.VkApi(token=token)
vk = vk_session.get_api()
# Формируем сообщение для оператора
message = (
f"🌐 **Новая заявка с сайта!**\n\n"
f"🆔 ID заявки: {reg_id}\n"
f"👤 Имя: {data.get('name', '').strip()}\n"
f"📞 Телефон: {data.get('phone', '').strip()}\n"
f"✉️ E-mail: {data.get('email', '').strip()}\n"
f"🌐 Профиль VK: {data.get('vk', '').strip()}\n\n"
f"⚖️ Согласия пользователя:\n"
f"• Оферта: {'Да' if data.get('agreeOferta') else 'Нет'}\n"
f"• Политика ПД: {'Да' if data.get('agreePolicy') else 'Нет'}\n"
f"• Рассылка: {'Да' if data.get('agreeNewsletter') else 'Нет'}\n\n"
f"Данные сохранены в Excel и базу данных SQLite."
)
vk.messages.send(
peer_id=operator_id,
message=message,
random_id=get_random_id()
)
print(f"[VK NOTIFICATION] Успешно отправлено уведомление оператору (ID: {operator_id}) для заявки {reg_id}.")
except Exception as e:
print(f"[VK NOTIFICATION] Ошибка при отправке уведомления в VK: {e}")
class FormHandler(SimpleHTTPRequestHandler):
"""Custom HTTP handler serving form files and processing POST requests."""
def translate_path(self, path):
# Override translate_path to serve static files from the 'form' directory
# even if launched from the parent directory
path = SimpleHTTPRequestHandler.translate_path(self, path)
rel_path = os.path.relpath(path, os.getcwd())
return os.path.join(SCRIPT_DIR, rel_path)
def _client_ip(self) -> str:
"""IP клиента с учётом обратного прокси (X-Forwarded-For)."""
xff = self.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
return self.client_address[0] if self.client_address else "unknown"
def _send_json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != '/submit':
self.send_response(404)
self.end_headers()
return
# Защита от флуда по IP
ip = self._client_ip()
if is_rate_limited(ip):
print(f"[RATE LIMIT] Слишком много заявок с IP {ip}.")
self._send_json(429, {"status": "error", "message": "Слишком много заявок. Попробуйте позже."})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
# Ограничение размера тела запроса (защита от больших payload'ов)
if content_length <= 0 or content_length > 10000:
self._send_json(400, {"status": "error", "message": "Некорректный размер запроса."})
return
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
# Honeypot: если скрытое поле заполнено — это бот. Делаем вид, что всё ок, но не сохраняем.
if is_spam(data):
print(f"[SPAM] Honeypot сработал для IP {ip}, заявка отброшена.")
self._send_json(200, {"status": "success", "message": "Заявка принята."})
return
# Серверная валидация
ok, error = validate_submission(data)
if not ok:
self._send_json(400, {"status": "error", "message": error})
return
promo = add_registration(data)
self._send_json(200, {"status": "success", "message": "Заявка успешно записана!", "promo": promo})
except json.JSONDecodeError:
self._send_json(400, {"status": "error", "message": "Некорректный формат данных."})
except Exception as e:
print(f"[ERROR] Ошибка обработки заявки: {e}")
self._send_json(500, {"status": "error", "message": "Внутренняя ошибка сервера. Попробуйте позже."})
def do_OPTIONS(self):
# Respond to CORS preflight requests
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', ALLOWED_ORIGIN)
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def run_server(port=8000):
init_excel() # Pre-create Excel on startup
server_address = ('', port)
# Многопоточный сервер: один медленный/зависший запрос не блокирует остальные.
httpd = ThreadingHTTPServer(server_address, FormHandler)
print(f"\n=======================================================")
print(f"Сервер ИКП запущен на http://localhost:{port}/")
print(f"Вы можете открыть форму по адресу: http://localhost:{port}/index.html")
print(f"Данные будут записываться в: {FILE_NAME}")
print(f"Нажмите Ctrl+C для остановки сервера.")
print(f"=======================================================\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nСервер останавливается...")
httpd.server_close()
if __name__ == '__main__':
run_server()