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