# -*- coding: utf-8 -*- """ НЕОН CRM — HTTP-сервер (stdlib) + REST API поверх PostgreSQL. Публичные эндпоинты (для сайта neon-bar86.ru): POST /api/orders — новый заказ из корзины сайта GET /api/stoplist — стоп-лист (позиции меню и бренды табака) GET /api/catalog — позиции, добавленные владельцем («новинки») GET /api/track/<код> — статус заказа по коду отслеживания GET /api/healthz — проверка живости для Docker Остальное требует сессию (X-Auth-Token из POST /api/login). Запуск: python server.py (порт из CRM_PORT, по умолчанию 47613) """ import glob import io import ipaddress import json import os import re import sys import threading import time from collections import defaultdict, deque from datetime import date, datetime, time as dtime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlsplit import notes_api import storage from storage import clean_guest_text # гигиена текста — общая с notes_api (NF-14) ROOT = Path(__file__).resolve().parent PORT = int(os.environ.get("CRM_PORT", "47613")) ORDER_RE = re.compile(r"^/api/orders/(\d+)$") USER_RE = re.compile(r"^/api/users/([A-Za-z0-9_.-]+)$") MIX_RE = re.compile(r"^/api/mixes/([A-Za-z0-9_.-]+)$") MIX_SITE_RE = re.compile(r"^/api/mixes-site/([A-Za-z0-9_.-]+)$") MENU_PHOTO_RE = re.compile(r"^/api/menu/([A-Za-z0-9_.:%-]+)/photo$") TRACK_RE = re.compile(r"^/api/track/([A-Za-z0-9]+)$") TRACK_CANCEL_RE = re.compile(r"^/api/track/([A-Za-z0-9]+)/cancel$") # сколько минут гость может отменить случайный заказ по коду (пока «Новый») GUEST_CANCEL_MINUTES = 10 HALL_RES_RE = re.compile(r"^/api/halls/reservations/(\d+)$") HALL_DEF_RE = re.compile(r"^/api/halls/([^/]+)$") RESERVE_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$") # id позиции меню и id вкуса табака — ASCII-слаги сайта; unquote ниже оставлен # на случай кириллических id (например, старых брендов в ссылках) MENU_RE = re.compile(r"^/api/menu/([A-Za-z0-9_.:%-]+)$") TOBACCO_RE = re.compile(r"^/api/tobacco/([^/]+)$") GAME_RE = re.compile(r"^/api/games/(\d+)$") GAME_PHOTO_RE = re.compile(r"^/api/games/(\d+)/photo$") GAME_KINDS = ("ps5", "board") # логотипы настолок — ключи иконок каталога сайта (BoardGamesPage.iconMap) GAME_ICONS = ("eye", "skull", "spade", "crown", "dices", "grid", "circle", "hash", "landmark", "layout", "brain", "puzzle", "users", "club", "ghost", "castle", "heart", "target") HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") ORDER_NUM_RESET_RE = re.compile(r"^/api/orders/numbering/reset$") # доски заметок — маршруты и хендлеры в notes_api.py BOARDS_RE = notes_api.BOARD_ID_RE BOARD_NOTES_RE = notes_api.BOARD_NOTES_RE BOARD_ACTIVITY_RE = notes_api.BOARD_ACTIVITY_RE NOTE_RE = notes_api.NOTE_ID_RE # Роли с правами администратора (владелец умеет всё то же и больше) ADMIN_ROLES = ("owner", "admin") STAFF_ROLES = ("owner", "admin", "waiter", "hookah") # роли смены: выходят на смену и управляют заказами только на смене SHIFT_ROLES = ("waiter", "hookah") # миксами для сайта ведают владелец и кальянщик (полный цикл: просмотр, # создание, удаление) MIX_ROLES = ("owner", "hookah") CREATABLE_ROLES = ("waiter", "hookah", "admin") # кого владелец может создавать DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") LOGIN_RE = re.compile(r"^[A-Za-z0-9_.-]{3,40}$") PHOTO_MAX_BYTES = 6 * 1024 * 1024 def image_extension(data: bytes) -> str | None: """Определяем реальный формат по сигнатуре — доверять клиенту нельзя.""" if len(data) < 12: return None if data[:3] == b"\xff\xd8\xff": return "jpg" if data[:8] == b"\x89PNG\r\n\x1a\n": return "png" if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "webp" if data[4:8] == b"ftyp": # ISO-BMFF: avif/heic return "avif" if data[:3] == b"GIF": return "gif" return None def _xlsx_bytes(sheets: list[dict]) -> bytes: """Простой xlsx на stdlib (zip + XML), без внешних зависимостей и без оформления (по решению владельца): sheets — [{name, rows}]; строка — {cells: [...]}. Первая строка каждого листа — жирная шапка.""" import zipfile from xml.sax.saxutils import escape def col_letter(n: int) -> str: # 1 -> A, 27 -> AA s = "" while n: n, r = divmod(n - 1, 26) s = chr(65 + r) + s return s def cell(ref: str, value, bold: bool) -> str: if value is None or value == "": return "" style = ' s="1"' if bold else "" if isinstance(value, (int, float)) and not isinstance(value, bool): return f'{value}' text = str(value) # защита от formula injection: текст гостя, начинающийся с =+-@, # Excel исполнил бы как формулу при открытии отчёта — гасим апострофом if text[0] in "=+-@": text = "'" + text return f'{escape(text)}' sheet_parts: list[dict] = [] for sheet in sheets: rows = sheet["rows"] n_cols = max((len(r.get("cells", [])) for r in rows), default=1) cols = "".join( f'' for i in range(1, n_cols + 1) ) data_rows: list[str] = [] for ri, row in enumerate(rows, 1): cells = "".join( cell(f"{col_letter(ci)}{ri}", v, ri == 1) for ci, v in enumerate(row.get("cells", []), 1) ) data_rows.append(f'{cells}') sheet_parts.append({ "name": sheet["name"], "xml": ('' '' f"{cols}" f'{"".join(data_rows)}'), }) ns_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" ns_rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" ns_pack = "http://schemas.openxmlformats.org/package/2006/relationships" workbook = ( '' f'' + "".join( f'' for i, s in enumerate(sheet_parts) ) + "" ) workbook_rels = ( '' f'' + "".join( f'' for i in range(len(sheet_parts)) ) + f'' + "" ) content_types = ( '' '' '' '' '' + "".join( f'' for i in range(len(sheet_parts)) ) + '' + "" ) root_rels = ( '' f'' f'' "" ) styles = ( '' f'' '' '' '' '' '' '' '' '' "" ) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("[Content_Types].xml", content_types) z.writestr("_rels/.rels", root_rels) z.writestr("xl/workbook.xml", workbook) z.writestr("xl/_rels/workbook.xml.rels", workbook_rels) z.writestr("xl/styles.xml", styles) for i, s in enumerate(sheet_parts): z.writestr(f"xl/worksheets/sheet{i + 1}.xml", s["xml"]) return buf.getvalue() # удобные конструкторы строк для листов def _xl_title(text): return {"style": "title", "cells": [text]} def _xl_header(*cells): return {"style": "header", "cells": list(cells)} def _xl_row(*cells): return {"style": "row", "cells": list(cells)} def _xl_total(*cells): return {"style": "total", "cells": list(cells)} _XL_SPACER = {"style": "spacer", "cells": []} def _summary_sheet(report: dict, staff: list[dict], period: dict) -> dict: """Лист «Сводка»: показатели, источники с итогом «Всего (сайт + зал)».""" t = report["totals"] dur_min = (round(t["avg_duration_seconds"] / 60, 1) if t.get("avg_duration_seconds") is not None else None) rows = [ _xl_title(f"Отчёт по заказам — лаундж НЕОН · {period['from']} — {period['to']}"), _XL_SPACER, _xl_header("Показатель", "Значение"), _xl_row("Выручка (выданные заказы)", t["revenue"]), _xl_row("Заказов всего (все статусы)", t["orders"]), _xl_row("Выдано", t["done"]), _xl_row("Средний чек", t["avg_check"]), _xl_row("Отменено", t["cancelled"]), _xl_row("Сумма отменённых", t["cancelled_amount"]), _xl_row("Средняя подача, минут", dur_min), _XL_SPACER, _xl_header("Источник", "Заказов", "Выручка"), ] src = report.get("by_source", {}) orders_sum = 0 revenue_sum = 0 for key, label in (("site", "Сайт"), ("hall", "Из зала")): s = src.get(key, {"orders": 0, "revenue": 0}) orders_sum += s["orders"] revenue_sum += s["revenue"] rows.append(_xl_row(label, s["orders"], s["revenue"])) rows.append(_xl_total("Всего (сайт + зал)", orders_sum, revenue_sum)) return {"name": "Сводка", "rows": rows, "money": [1, 2], "freeze": False} def _days_sheet(report: dict) -> dict: rows = [_xl_header("День", "Заказов", "Выручка", "Отменено")] rows += [_xl_row(d["day"], d["orders"], d["revenue"], d["cancelled"]) for d in report["by_day"]] return {"name": "По дням", "rows": rows, "money": [2], "freeze": True} def _hours_sheet(report: dict) -> dict: rows = [_xl_header("Час", "Заказов", "Выручка")] rows += [_xl_row(f"{h['hour']:02d}:00–{h['hour'] + 1:02d}:00", h["orders"], h["revenue"]) for h in report.get("by_hour", []) if h["orders"] or h["revenue"]] if len(rows) == 1: rows.append(_xl_row("нет заказов", None, None)) return {"name": "По часам", "rows": rows, "money": [2], "freeze": True} def _top_sheet(report: dict) -> dict: rows = [_xl_header("№", "Позиция", "Штук", "Выручка")] rows += [_xl_row(i + 1, item["name"], item["qty"], item["revenue"]) for i, item in enumerate(report["top_items"])] if len(rows) == 1: rows.append(_xl_row("нет продаж", None, None, None)) return {"name": "Топ позиций", "rows": rows, "money": [3], "freeze": True} def _tables_sheet(report: dict) -> dict: rows = [_xl_header("Стол / зона", "Заказов", "Выручка")] rows += [_xl_row(r["table"], r["orders"], r["revenue"]) for r in report.get("top_tables", [])] if len(rows) == 1: rows.append(_xl_row("нет заказов", None, None)) return {"name": "Столы", "rows": rows, "money": [2], "freeze": True} def _staff_sheet(staff: list[dict]) -> dict: role_label = {"owner": "Владелец", "admin": "Администратор", "waiter": "Официант", "hookah": "Кальянщик"} rows = [_xl_header("Сотрудник", "Роль", "Заказов", "Выдано", "Отменено", "Выручка", "Средний чек", "Средняя подача, мин", "С сайта", "Из зала", "Смен", "Часов")] for s in staff: rows.append(_xl_row( s["name"], role_label.get(s["role"], s["role"]), s["orders_total"], s["orders_done"], s["orders_cancelled"], s["revenue"], s["avg_check"] if s["avg_check"] is not None else None, (round(s["avg_duration_seconds"] / 60, 1) if s["avg_duration_seconds"] is not None else None), s["site_orders"], s["hall_orders"], s["shifts"], round(s["hours_seconds"] / 3600, 1), )) return {"name": "Сотрудники", "rows": rows, "money": [5, 6], "freeze": True} def _day_sheet(d_report: dict, d_staff: list[dict], day) -> dict: """Лист одного дня для режима «по дням»: метрики, источники с итогом, топ позиций и сотрудники этого дня.""" rows = [_xl_title(day.strftime("%d.%m.%Y")), _XL_SPACER] t = d_report["totals"] dur_min = (round(t["avg_duration_seconds"] / 60, 1) if t.get("avg_duration_seconds") is not None else None) rows += [ _xl_header("Показатель", "Значение"), _xl_row("Выручка", t["revenue"]), _xl_row("Заказов всего", t["orders"]), _xl_row("Выдано", t["done"]), _xl_row("Средний чек", t["avg_check"]), _xl_row("Отменено", t["cancelled"]), _xl_row("Средняя подача, минут", dur_min), _XL_SPACER, _xl_header("Источник", "Заказов", "Выручка"), ] src = d_report.get("by_source", {}) orders_sum = 0 revenue_sum = 0 for key, label in (("site", "Сайт"), ("hall", "Из зала")): s = src.get(key, {"orders": 0, "revenue": 0}) orders_sum += s["orders"] revenue_sum += s["revenue"] rows.append(_xl_row(label, s["orders"], s["revenue"])) rows.append(_xl_total("Всего (сайт + зал)", orders_sum, revenue_sum)) rows.append(_XL_SPACER) rows.append(_xl_header("Позиция", "Штук", "Выручка")) if d_report["top_items"]: rows += [_xl_row(i["name"], i["qty"], i["revenue"]) for i in d_report["top_items"]] else: rows.append(_xl_row("нет продаж", None, None)) rows.append(_XL_SPACER) role_label = {"owner": "Владелец", "admin": "Администратор", "waiter": "Официант", "hookah": "Кальянщик"} rows.append(_xl_header("Сотрудник", "Роль", "Заказов", "Выдано", "Выручка", "Смен", "Часов")) staff_rows = [ _xl_row(s["name"], role_label.get(s["role"], s["role"]), s["orders_total"], s["orders_done"], s["revenue"], s["shifts"], round(s["hours_seconds"] / 3600, 1)) for s in d_staff ] rows += staff_rows if staff_rows else [_xl_row("нет данных", None, None, None, None, None, None)] return {"name": day.strftime("%d.%m"), "rows": rows, "money": [1, 2], "freeze": False} def _report_xlsx(report: dict, staff: list[dict], period: dict) -> list[dict]: """Листы обычного Excel-отчёта.""" return [ _summary_sheet(report, staff, period), _days_sheet(report), _hours_sheet(report), _top_sheet(report), _tables_sheet(report), _staff_sheet(staff), ] # ---------------------------------------------------------------- rate limit def _is_trusted_peer(ip: str) -> bool: """Пиры, чьему X-Forwarded-For можно верить: loopback, приватные подсети (docker-сеть, LAN с реверс-прокси) и unspecified. Публичному клиенту XFF не доверяем — он мог подделать заголовок.""" try: addr = ipaddress.ip_address(ip) except ValueError: return False return addr.is_loopback or addr.is_private or addr.is_unspecified class RateLimiter: """Скользящий лимит на ключ (обычно IP). Страница отслеживания опрашивает сервер чаще заказов, поэтому у неё свой, мягкий лимит.""" window = 300 limit = 30 def __init__(self, window: int | None = None, limit: int | None = None) -> None: if window is not None: self.window = window if limit is not None: self.limit = limit self._hits: dict[str, deque[float]] = defaultdict(deque) self._lock = threading.Lock() def allow(self, key: str) -> bool: now = time.monotonic() with self._lock: q = self._hits[key] while q and q[0] < now - self.window: q.popleft() if len(q) >= self.limit: return False q.append(now) if len(self._hits) > 10_000: # не копим ключи вечных ботов stale = now - self.window # удаляем на месте: переприсваивание dict ломало контракт # defaultdict, и каждый новый ключ падал с KeyError навсегда (F-5) for k in [k for k, v in self._hits.items() if not v or v[-1] <= stale]: del self._hits[k] return True limiter = RateLimiter() track_limiter = RateLimiter(window=60, limit=90) # опрос статуса раз в 15 с — с запасом # публичные GET каталога/стоп-листа/залов без лимита выедали пул БД при флуде # (NF-04): сайт опрашивает их раз в минуту-десять, 120/мин с IP — с запасом. # /api/healthz НЕ лимитируем: docker-healthcheck стучится каждые 30 с. public_get_limiter = RateLimiter(window=60, limit=120) # глобальный тормоз на перебор кодов отслеживания (F-8): 31^6 комбинаций при # 90/мин с одного IP перебираются долго, но ботнет с ротацией IP — нет. # 5000 просмотров в час на ВЕСЬ сайт с запасом покрывает живых гостей. track_global_limiter = RateLimiter(window=3600, limit=5000) # глобальный тормоз на фальшивые заказы: даже распределённый флуд не должен # заваливать бар и БД. По умолчанию 200 заказов за 5 минут на ВЕСЬ сайт — # живой пятничный поток в это не попадает, атака упирается в 503. FLOOD_WINDOW = 300 FLOOD_LIMIT = int(os.environ.get("CRM_ORDERS_FLOOD_LIMIT", "200") or "200") flood_limiter = RateLimiter(window=FLOOD_WINDOW, limit=FLOOD_LIMIT) # ----------------------------- расписание бара ----------------------------- # Бар живёт по фиксированному UTC+5 (без перевода часов). Онлайн-заказы # принимаются, когда бар открыт, и «под открытие»: не раньше, чем за час до # 19:00. Днём, пока бар закрыт, заказать нельзя: персонал не получает # заказов на закрытую смену, а гость не ждёт перед пустым залом. BAR_TZ = timezone(timedelta(hours=5)) BAR_OPEN_HOUR = 19 # все сессии начинаются в 19:00 барного времени PREORDER_WINDOW = timedelta(hours=1) BAR_CLOSED_WEEKDAY = 0 # понедельник — сессий не открываем # до скольки утра работает «хвост» сессии, по дню недели (Python: 0 = пн). # Хвост после полуночи относится к сессии предыдущего дня. BAR_TAIL_HOURS = { 0: 1, # пн: только хвост воскресенья до 01:00 1: 0, # вт: ночного хвоста нет — в пн бар не работал 2: 1, # ср: хвост вторника до 01:00 3: 1, # чт 4: 1, # пт: хвост четверга до 01:00 5: 3, # сб: хвост пятницы до 03:00 6: 3, # вс: хвост субботы до 03:00 } WEEKDAY_WHEN = ("в понедельник", "во вторник", "в среду", "в четверг", "в пятницу", "в субботу", "в воскресенье") def bar_now() -> datetime: """Текущее барное время (UTC+5), независимо от пояса сервера.""" return datetime.now(BAR_TZ) def bar_is_open(now: datetime) -> bool: """Открыт ли бар в момент now (передавать барное время).""" hour = now.hour + now.minute / 60 if hour >= BAR_OPEN_HOUR: return now.weekday() != BAR_CLOSED_WEEKDAY # в пн вечерней сессии нет return hour < BAR_TAIL_HOURS[now.weekday()] def next_bar_opening(now: datetime) -> datetime | None: """Ближайшее открытие (19:00) строго после now, кроме понедельника.""" base = now.replace(hour=BAR_OPEN_HOUR, minute=0, second=0, microsecond=0) for d in range(8): cand = base + timedelta(days=d) if cand.weekday() == BAR_CLOSED_WEEKDAY: continue if cand > now: return cand return None def bar_orders_gate() -> dict | None: """None — заказы принимаем; dict — почему нельзя (бар закрыт). Разрешаем заказ, когда бар открыт или до открытия осталось не больше часа («заказ к открытию»). Днём, пока бар закрыт, — отказываем с понятным текстом: когда снова откроемся.""" now = bar_now() if bar_is_open(now): return None opening = next_bar_opening(now) if opening is None: return None # расписание всегда найдёт открытие; не блокируем на всякий случай if opening - now <= PREORDER_WINDOW: return None if opening.date() == now.date(): when = "сегодня" elif opening.date() == now.date() + timedelta(days=1): when = "завтра" else: when = WEEKDAY_WHEN[opening.weekday()] return { "code": "bar_closed", # "error" — человекочитаемый текст: его показывает корзина сайта "error": ( f"Бар закрыт — онлайн-заказы открываются за час до работы: {when} в " f"{BAR_OPEN_HOUR:02d}:00. Позвоните нам: +7 (912) 901-72-17" ), "opens_at": opening.isoformat(), } # Гигиена свободного текста гостя (clean_guest_text) живёт в storage.py — # её применяют и публичный заказ, и ручной заказ персонала (NF-14). class PayloadDedup: """Короткая память «такой заказ уже был» (один IP + тот же стол/состав). Гасит случайный повтор (двойной клик, повторная отправка формы) и простейшие спам-циклы с фиксированным payload — раньше, чем заказ попадёт в канбан персонала.""" window = 60 def __init__(self, window: int | None = None) -> None: if window is not None: self.window = window self._seen: dict[str, float] = {} self._lock = threading.Lock() def seen(self, key: str) -> bool: now = time.monotonic() with self._lock: last = self._seen.get(key) self._seen[key] = now if len(self._seen) > 10_000: stale = now - self.window self._seen = {k: v for k, v in self._seen.items() if v > stale} return last is not None and now - last < self.window order_dedup = PayloadDedup() class _CatalogPatchAbort(Exception): """NF-07: мульти-полевой PATCH каталога выполняется в одной транзакции; этот отказ (seed/not found) откатывает уже применённые ops и уносит готовый ответ для клиента.""" def __init__(self, payload: dict, code: int): super().__init__(payload.get("error") or "patch aborted") self.payload = payload self.code = code class LoginLockout: """Анти-брутфорс одной учётки (F-2) без DoS по доступности (NF-05): каждая недавняя неудача удваивает паузу перед следующей попыткой входа в этот логин — 2, 4, 8… секунд до потолка (cap). Стоимость перебора растёт экспоненциально, но корректный пароль входит после паузы: чужие промахи больше не выключают вход сотруднику на 15 минут, как раньше. Пауза считается ДО проверки пароля и зависит только от истории неудач этого логина — ответы для существующих и несуществующих логинов неразличимы (внутри login_user ещё и dummy-хэш для отсутствующих). Успешный вход сбрасывает счётчик; состояние в памяти — рестарт снимает задержки, но лимитеры по IP продолжают работать.""" window = 900 # окно учёта неудач — 15 минут (как у прежнего жёсткого лока) base = 2.0 # первая неудача в окне -> +2 с к следующей попытке cap = 30.0 # потолок паузы одной попытки def __init__(self) -> None: self._fails: dict[str, list[float]] = {} self._lock = threading.Lock() def _recent(self, login: str, now: float) -> list[float]: """Свежие неудачи логина + чистка вечных ключей (NF-06) — та же схема, что в RateLimiter._hits (F-5): при >10 000 логинов выметаем опустевшие и протухшие, не ломая контракт defaultdict-подобного доступа.""" fails = [t for t in self._fails.get(login, []) if now - t < self.window] self._fails[login] = fails if len(self._fails) > 10_000: stale = now - self.window for k in [k for k, v in self._fails.items() if not v or v[-1] <= stale]: del self._fails[k] return fails def delay(self, login: str) -> float: """Сколько секунд ждать перед попыткой входа (0 — можно сразу).""" now = time.monotonic() with self._lock: fails = self._recent(login, now) if not fails: return 0.0 return min(self.cap, self.base * (2 ** len(fails))) def fail(self, login: str) -> None: now = time.monotonic() with self._lock: fails = self._recent(login, now) fails.append(now) def reset(self, login: str) -> None: with self._lock: self._fails.pop(login, None) login_lockout = LoginLockout() # ---------------------------------------------------------------- handler class Handler(notes_api.NotesRoutes, BaseHTTPRequestHandler): server_version = "NeonCRM/1.0" protocol_version = "HTTP/1.1" # ---------------- служебное ---------------- def _drain_body(self): """Гарантия чистоты keep-alive: если обработчик ответил, не прочитав тело запроса (ранний 401/403/404), соединение закрывается — иначе оставшееся тело сервер принял бы за следующий запрос (десинхронизация потока, «Unsupported method (\'{"...}\')»). Читаем флаг, который ставят _read_body/_read_binary фактическим чтением тела.""" if getattr(self, "_body_consumed", False): return try: length = int(self.headers.get("Content-Length") or 0) except ValueError: length = 0 if length > 0: self.close_connection = True def _json(self, obj, code=200, cors=False): self._drain_body() body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) 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.send_header("X-Content-Type-Options", "nosniff") if cors: self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass # клиент оборвал соединение — писать некуда, это не 500 def _read_body(self) -> dict | None: try: length = int(self.headers.get("Content-Length") or 0) except ValueError: return None if length <= 0 or length > 1_000_000: return None # слишком большое тело не читаем: _json закроет соединение try: data = json.loads(self.rfile.read(length).decode("utf-8")) self._body_consumed = True return data if isinstance(data, dict) else None except (ValueError, UnicodeDecodeError): self._body_consumed = True return None def _read_binary(self, limit: int) -> bytes | None: try: length = int(self.headers.get("Content-Length") or 0) except ValueError: return None if length <= 0 or length > limit: return None # слишком большое тело не читаем: _json закроет соединение data = self.rfile.read(length) self._body_consumed = True return data def _send_file(self, data: bytes, filename: str, ctype: str): """Файл на скачивание (ASCII-имя): Excel-отчёты и т.п.""" self._drain_body() # NF-13: непрочитанное тело ломает keep-alive self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Disposition", f'attachment; filename="{filename}"') self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() try: self.wfile.write(data) except (BrokenPipeError, ConnectionResetError): pass def _auth(self) -> dict | None: return storage.session_user(self.headers.get("X-Auth-Token", "")) def _client_ip(self) -> str: # За nginx (сайт) все гости приходят с одного IP контейнера: берём # реальный клиент из X-Forwarded-For — но ТОЛЬКО если соединение # пришло от доверенного (приватного/loopback) пира (F-7). Прямой # клиент из интернета может слать любой XFF: доверять ему нельзя, # иначе ротацией заголовка сбрасываются лимитеры брутфорса. forwarded = self.headers.get("X-Forwarded-For", "") peer = str(self.client_address[0] or "") if forwarded and _is_trusted_peer(peer): first = forwarded.split(",")[0].strip() if first: return first return peer or "?" def log_message(self, fmt, *args): sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args)) sys.stdout.flush() def log_error(self, fmt, *args): self.log_message(fmt, *args) # ---------------- статика ---------------- def serve_static(self, path: str): self._drain_body() # NF-13: GET со странным телом не должен рвать keep-alive if path == "/": path = "/index.html" # F-3: сегменты подъёма и обратные слеши отклоняем ДО whitelist: # whitelist проверяет сырой префикс, а Path.resolve() сворачивает # ../ внутрь ROOT, из-за чего /js/../server.py проходил как «/js/…» segments = path.lstrip("/").split("/") if any(s in ("", ".", "..") or "\\" in s for s in segments): self.send_error(404) return # whitelist: панель отдаёт только свои ассеты и медиа. Всё остальное # (server.py, storage.py, seed/*.json, …) наружу не утекает. allowed = ( path == "/index.html" or path.startswith("/js/") or path.startswith("/css/") or path.startswith("/media/") ) if not allowed: self.send_error(404) return file_path = (ROOT / path.lstrip("/")).resolve() # is_relative_to(ROOT) — защита в глубину поверх отказа от ../ if not file_path.is_relative_to(ROOT) or not file_path.is_file(): self.send_error(404) return import mimetypes ctype = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" body = file_path.read_bytes() self.send_response(200) self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else "")) self.send_header("Content-Length", str(len(body))) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.send_header("Referrer-Policy", "same-origin") # CSP панели: скрипты только свои (app.js внешний), стили — свои и # Google Fonts; инлайн-скриптам в панели места нет, так что даже # случайно пропущенный sink не исполнит внедрённый код self.send_header( "Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" " https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;" " img-src 'self' data:; connect-src 'self'; object-src 'none';" " base-uri 'none'; frame-ancestors 'none'; form-action 'self'", ) if path.startswith("/css/") or path.startswith("/js/"): self.send_header("Cache-Control", "no-cache") else: self.send_header("Cache-Control", "no-store") self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass # ---------------- маршруты ---------------- def do_GET(self): path = urlsplit(self.path).path try: # анонимный DoS-флуд публичными GET (NF-04) гасим до обращения к # БД: catalog/stoplist/halls-names — самые «дорогие» запросы сайта if path in ("/api/catalog", "/api/stoplist", "/api/halls/names") \ and not public_get_limiter.allow("public:" + self._client_ip()): self._json({"error": "too many requests"}, 429, cors=True) return if path == "/api/healthz": with storage.db() as conn: conn.execute("SELECT 1") self._json({"ok": True}) elif path == "/api/stoplist": self._json(storage.stoplist(), cors=True) elif path == "/api/catalog": self._json(storage.custom_catalog(), cors=True) elif path == "/api/orders": if not self._require_auth(): return user = self._auth() if user and user["role"] in SHIFT_ROLES: # официант/кальянщик видят заказы (с телефоном и комментарием # гостя) только во время смены — вне смены список закрыт (F-14) if storage.get_open_shift(user["login"]) is None: self._json({"error": "Смена не активна — список заказов доступен только на смене."}, 403) return self._json(storage.list_orders()) elif path == "/api/menu": if not self._require_auth(): return self._json(storage.list_menu()) elif path == "/api/tobacco": if not self._require_auth(): return self._json(storage.list_tobacco()) elif path == "/api/mixes": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] not in MIX_ROLES: self._json({"error": "owner or hookah only"}, 403) else: self._json(storage.list_mixes()) elif path == "/api/users": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._json(storage.list_users()) elif path == "/api/reports/orders/excel": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_report_excel() elif path == "/api/reports/staff": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_staff_report() elif path == "/api/reports/orders": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) else: self._handle_report(user) elif path == "/api/shifts/current": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if user["role"] in SHIFT_ROLES: mine = storage.get_open_shift(user["login"]) self._json({"on_shift": [mine] if mine else [], "staff": []}) else: on_shift = storage.on_shift() busy = {r["login"] for r in on_shift} staff = [{**u, "on_shift": u["login"] in busy} for u in storage.staff_shift_roles()] self._json({"on_shift": on_shift, "staff": staff}) elif path == "/api/shifts/counts": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._json(storage.shift_counts()) elif path == "/api/session": user = self._auth() self._json(user or {}, 200 if user else 401) elif path == "/api/halls/names": # публичный список активных столов/залов — корзина сайта # строит из него выбор столика self._json({"tables": storage.hall_names()}, cors=True) elif path == "/api/halls": if not self._require_auth(): return self._json(storage.hall_status()) elif path == "/api/games": if not self._require_auth(): return all_flag = (parse_qs(urlsplit(self.path).query).get("all") or ["0"])[0] == "1" user = self._auth() # корзину удалённых игр видим только владелец (админ — без редактирования) include_deleted = bool(all_flag) and user and user["role"] == "owner" self._json({"games": storage.games_list(include_deleted)}) elif path == "/api/mixes/site": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] not in MIX_ROLES: self._json({"error": "owner or hookah only"}, 403) else: self._json({"site_mixes": storage.site_mixes_edited()}) elif path == "/api/boards": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) else: self._handle_boards_get(user) elif BOARD_NOTES_RE.match(path): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) else: self._handle_board_notes_get(user, int(BOARD_NOTES_RE.match(path).group(1))) elif BOARD_ACTIVITY_RE.match(path): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) else: self._handle_board_activity(user, int(BOARD_ACTIVITY_RE.match(path).group(1))) elif TRACK_RE.match(path): self._handle_track(TRACK_RE.match(path).group(1)) elif path.startswith("/api/") or path == "/api": # неизвестный API-путь — JSON 404, а не HTML-заглушка статики self._json({"error": "not found"}, 404) else: self.serve_static(path) except Exception: self._server_error() def do_POST(self): path = urlsplit(self.path).path try: if path == "/api/login": self._handle_login() elif path == "/api/orders": self._handle_public_order() elif ORDER_NUM_RESET_RE.match(path): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if user["role"] != "owner": self._json({"error": "owner only"}, 403) return result = storage.reset_order_numbering() if not result: self._json( {"error": "сбросить нумерацию дважды за одну неделю нельзя —" " подождите следующую неделю нумерации (вторник)"}, 409, ) return self._json(result, 201) elif path == "/api/orders/manual": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return self._handle_manual_order(user) elif path == "/api/logout": if not self._require_auth(): return storage.logout_user(self.headers.get("X-Auth-Token", "")) self._json({"ok": True}) elif path == "/api/users": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_user_create() elif path == "/api/users/password": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_user_password() elif path == "/api/menu": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_menu_create() elif path == "/api/tobacco": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_tobacco_create() elif path == "/api/mixes": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] not in MIX_ROLES: self._json({"error": "owner or hookah only"}, 403) else: self._handle_mix_create() elif MENU_PHOTO_RE.match(path): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_menu_photo(unquote(MENU_PHOTO_RE.match(path).group(1))) elif GAME_PHOTO_RE.match(path): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return # правка: библиотеку игр ведёт только владелец # (админ видит раздел, официант и кальянщик его не видят) if user["role"] != "owner": self._json({"error": "owner only"}, 403) return self._handle_game_photo(int(GAME_PHOTO_RE.match(path).group(1))) elif path == "/api/games": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_game_create(user) elif path == "/api/boards": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) else: self._handle_board_create(user) elif BOARD_NOTES_RE.match(path): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) else: self._handle_note_create(user, int(BOARD_NOTES_RE.match(path).group(1))) elif path in ("/api/shifts/clock-in", "/api/shifts/clock-out"): user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if user["role"] not in SHIFT_ROLES: self._json({"error": "смены ведутся для официантов и кальянщиков"}, 400) return if path.endswith("clock-in"): shift, error = storage.clock_in(user["login"]) self._json(shift or {"error": error}, 201 if shift else 400) else: shift = storage.clock_out(user["login"]) self._json(shift or {"error": "активной смены нет"}, 200 if shift else 400) elif path == "/api/shifts/manage": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if user["role"] not in ADMIN_ROLES: self._json({"error": "admin only"}, 403) return self._handle_shift_manage() elif path == "/api/halls": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) elif user["role"] != "owner": self._json({"error": "owner only"}, 403) else: self._handle_hall_create() elif path == "/api/halls/manage": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if user["role"] not in ADMIN_ROLES: self._json({"error": "admin only"}, 403) return self._handle_hall_manage(user) elif path == "/api/halls/reserve": user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if user["role"] not in ADMIN_ROLES: self._json({"error": "admin only"}, 403) return self._handle_hall_reserve(user) elif TRACK_CANCEL_RE.match(path): self._handle_track_cancel(TRACK_CANCEL_RE.match(path).group(1)) else: self._json({"error": "not found"}, 404) except Exception: self._server_error() def do_PATCH(self): path = urlsplit(self.path).path try: user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return if path == "/api/session/password": self._handle_password_change(user) return m = ORDER_RE.match(path) if m: self._handle_order_patch(int(m.group(1))) return m = MENU_RE.match(path) if m: self._handle_catalog_patch("menu", unquote(m.group(1)), user) return m = TOBACCO_RE.match(path) if m: self._handle_catalog_patch("tobacco", unquote(m.group(1)), user) return m = MIX_RE.match(path) if m: # миксы ведут владелец и кальянщик — те же роли, что на создании if user["role"] not in MIX_ROLES: self._json({"error": "owner or hookah only"}, 403) return self._handle_mix_patch(m.group(1)) return m = MIX_SITE_RE.match(path) if m: # витринные миксы сайта правит только владелец if user["role"] != "owner": self._json({"error": "owner only"}, 403) return self._handle_site_mix_patch(m.group(1)) return m = BOARDS_RE.match(path) if m: self._handle_board_patch(user, int(m.group(1))) return m = NOTE_RE.match(path) if m: self._handle_note_patch(user, int(m.group(1))) return m = GAME_RE.match(path) if m: # правка: библиотеку игр редактирует только владелец if user["role"] != "owner": self._json({"error": "owner only"}, 403) return self._handle_game_patch(user, int(m.group(1))) return self._json({"error": "not found"}, 404) except Exception: self._server_error() def do_DELETE(self): path = urlsplit(self.path).path try: user = self._auth() if not user: self._json({"error": "unauthorized"}, 401) return def owner_only(): if user["role"] != "owner": self._json({"error": "owner only"}, 403) return False return True m = USER_RE.match(path) if m: if not owner_only(): return removed = storage.delete_user(unquote(m.group(1))) if not removed: self._json({"error": "нельзя удалить: пользователь не найден или это владелец"}, 400) return self._json({"ok": True}) return m = MENU_RE.match(path) if m: if not owner_only(): return item_id = unquote(m.group(1)) if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1": # безвозвратное удаление: только кастомные позиции — # стартовые позиции сайта сид пересоздаёт при рестарте result = storage.purge_menu_item(item_id) if result == "seed": self._json({ "error": "стартовые позиции сайта удалить навсегда нельзя —" " при перезапуске их восстановит сид;" " доступно только обычное удаление", }, 400) return if result is False: self._json({"error": "not found"}, 404) return self._json({"ok": True, "purged": item_id}) return updated = storage.set_menu_deleted(item_id, True) self._json(updated or {"error": "not found"}, 200 if updated else 404) return m = TOBACCO_RE.match(path) if m: if not owner_only(): return flavor_id = unquote(m.group(1)) if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1": # безвозвратное удаление: только кастомные вкусы — # стартовые вкусы сайта сид пересоздаёт при рестарте result = storage.purge_tobacco_item(flavor_id) if result == "seed": self._json({ "error": "стартовые вкусы сайта удалить навсегда нельзя —" " при перезапуске их восстановит сид;" " доступно только обычное удаление", }, 400) return if result is False: self._json({"error": "not found"}, 404) return self._json({"ok": True, "purged": flavor_id}) return updated = storage.set_tobacco_deleted(flavor_id, True) self._json(updated or {"error": "not found"}, 200 if updated else 404) return m = MIX_RE.match(path) if m: # правка: миксы ведут владелец и кальянщик if user["role"] not in MIX_ROLES: self._json({"error": "owner or hookah only"}, 403) return mix_id = m.group(1) if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1": # безвозвратное удаление — только владелец; в mix_recipes # только миксы владельца, сид их не восстанавливает if user["role"] != "owner": self._json({"error": "owner only"}, 403) return result = storage.purge_mix_item(mix_id) if result is False: self._json({"error": "not found"}, 404) return self._json({"ok": True, "purged": mix_id}) return updated = storage.set_mix_deleted(mix_id, True) self._json(updated or {"error": "not found"}, 200 if updated else 404) return m = MIX_SITE_RE.match(path) if m: # сброс правки витринного микса — вернуть исходный вид if user["role"] != "owner": self._json({"error": "owner only"}, 403) return if not storage.reset_site_mix(m.group(1)): self._json({"error": "у этого микса нет правок — он в исходном виде"}, 400) return self._json({"ok": True, "reset": m.group(1)}) return m = BOARDS_RE.match(path) if m: self._handle_board_delete(user, int(m.group(1))) return m = HALL_RES_RE.match(path) if m: if user["role"] not in ADMIN_ROLES: self._json({"error": "admin only"}, 403) return self._handle_hall_reserve_cancel(user, int(m.group(1))) return m = HALL_DEF_RE.match(path) if m: if not owner_only(): return if not storage.hall_delete(unquote(m.group(1)).strip()): self._json({"error": "стол или зал не найден"}, 404) return self._json({"ok": True}) return m = GAME_RE.match(path) if m: # правка: библиотеку игр удаляет только владелец if user["role"] != "owner": self._json({"error": "owner only"}, 403) return self._handle_game_delete(int(m.group(1))) return m = NOTE_RE.match(path) if m: self._handle_note_delete(user, int(m.group(1))) return self._json({"error": "not found"}, 404) except Exception: self._server_error() def do_HEAD(self): # мониторинги часто стучатся HEAD-ом: отдаём тот же вердикт, что и GET self._drain_body() # NF-13 path = urlsplit(self.path).path if path != "/api/healthz": self.send_error(404) return try: with storage.db() as conn: conn.execute("SELECT 1") self.send_response(200) except Exception: self.send_response(500) self.send_header("Content-Length", "0") self.end_headers() def do_OPTIONS(self): self._drain_body() # NF-13 self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Content-Length", "0") self.end_headers() # ---------------- обработчики ---------------- def _require_auth(self) -> bool: if self._auth(): return True self._json({"error": "unauthorized"}, 401) return False def _server_error(self): import traceback exc = sys.exc_info()[1] # sqlite-режим не реализует часть разделов (F-11): отвечаем честным # 501 с текстом вместо 500 «internal error» if isinstance(exc, storage.PostgresOnlyFeature): self._json({"error": str(exc)}, 501) return traceback.print_exc() try: self._json({"error": "internal error"}, 500) except Exception: pass def _handle_login(self): body = self._read_body() or {} login = str(body.get("login") or "")[:60] password = str(body.get("password") or "") if not login or not password: self._json({"error": "login and password required"}, 400) return # пауза от брутфорса: не больше 30 попыток входа с IP за 5 минут if not limiter.allow("login:" + self._client_ip()): self._json({"error": "too many attempts, wait a minute"}, 429) return # прогрессивная задержка перебора конкретной учётки (NF-05): чек ДО # проверки пароля — ждать приходится и промаху, и попаданию, поэтому # перебор дорожает экспоненциально, а сотрудник с верным паролем # входит (раньше 5 чужих промахов закрывали логин на 15 минут). # Задержка зависит только от истории неудач логина — существующие и # несуществующие логины неотличимы. pause = login_lockout.delay(login) if pause > 0: time.sleep(pause) session = storage.login_user(login, password) if session is None: login_lockout.fail(login) time.sleep(0.3) # одинаково тормозим и промах, и попадание self._json({"error": "неверный логин или пароль"}, 401) return login_lockout.reset(login) self._json(session) def _handle_password_change(self, user: dict): """Смена пароля своей учётки (F-16): текущий пароль обязателен, новый — минимум 8 символов. Все прочие сессии этой учётки гасятся: украденный токен перестаёт работать сразу после смены пароля.""" body = self._read_body() or {} current = str(body.get("current_password") or "") new = str(body.get("new_password") or "") if not current or not new: self._json({"error": "current_password и new_password required"}, 400) return if len(new) < 8: self._json({"error": "новый пароль — минимум 8 символов"}, 400) return if not storage.change_password(user["login"], current, new, keep_token=self.headers.get("X-Auth-Token", "")): self._json({"error": "текущий пароль неверен"}, 400) return self._json({"ok": True}) def _handle_public_order(self): # 1) глобальный тормоз на флуд фальшивыми заказами (распределённый # тоже: лимит общий на весь сайт) — иначе атака упирается в БД if not flood_limiter.allow("site"): self._json( {"error": "Онлайн-заказы временно недоступны — позвоните в бар, мы на месте"}, 503, cors=True, ) return # 2) обычный лимит на одного гостя (IP берётся из XFF за nginx) if not limiter.allow("order:" + self._client_ip()): self._json({"error": "too many orders, try later"}, 429, cors=True) return # 3) «заказ по времени»: пока бар закрыт и до открытия больше часа, # заказы не принимаются (проверка на сервере — её не обойти) gate = bar_orders_gate() if gate is not None: self._json(gate, 403, cors=True) return body = self._read_body() if not body or not isinstance(body.get("items"), list) or not body["items"]: self._json({"error": "items required"}, 400, cors=True) return # 4) honeypot: скрытое поле формы, которое человек не видит и не # заполняет. Отвечаем боту как успех — заказ не создаём. if str(body.get("hp_check") or "").strip(): self._json({"ok": True}, 200, cors=True) return # 5) строгая проверка состава: id, название и цена — ТОЛЬКО из каталога # CRM. Клиентские price/name игнорируются, неизвестные и стоповые # позиции отклоняют весь заказ: состав, сумму и количество нельзя # подменить прямым запросом к API (как это делает корзина сайта). raw_items = body["items"] if len(raw_items) > 30: self._json( {"error": "В заказе больше 30 позиций — разбейте его на два заказа"}, 400, cors=True, ) return catalog = {m["id"]: m for m in storage.list_menu() if not m["deleted"]} items = [] for i in raw_items: if not isinstance(i, dict): self._json( {"error": "Некорректный состав заказа — обновите страницу и соберите заказ заново"}, 400, cors=True, ) return item_id = str(i.get("id") or "").strip()[:64] entry = catalog.get(item_id) if entry is None: self._json( {"error": "Меню изменилось — обновите корзину и отправьте заказ ещё раз"}, 400, cors=True, ) return if entry["stopped"]: self._json( {"error": f"«{entry['name']}» только что ушла в стоп-лист — уберите её из корзины" " и отправьте заказ снова"}, 400, cors=True, ) return try: qty = min(50, max(1, int(i.get("qty", 1)))) except (TypeError, ValueError, OverflowError): # NF-08: qty 1e400 self._json( {"error": "Некорректное количество — обновите корзину и попробуйте снова"}, 400, cors=True, ) return name = entry["name"] + (f" ({entry['portion']})" if entry["portion"] else "") items.append({"id": item_id, "name": name, "qty": qty, "price": entry["price"]}) if not items: self._json({"error": "no valid items"}, 400, cors=True) return # стол обязателен и должен существовать: без него персонал не знает, # куда нести заказ, а выдуманный стол засоряет канбан и отчёты. # Пустой справочник залов — fail-closed: лучше отказаться от заказа # с внятной ошибкой, чем принять произвольный table_name (F-9). table = clean_guest_text(str(body.get("table_name") or ""), 60).strip() if not table: self._json({"error": "выберите столик в корзине — без него заказ не принять"}, 400, cors=True) return halls = storage.hall_names() if not halls: self._json( {"error": "Онлайн-заказы временно недоступны — позвоните в бар: +7 (912) 901-72-17"}, 503, cors=True, ) return if table not in halls: self._json({"error": "Такого столика нет — выберите столик из списка в корзине"}, 400, cors=True) return guest_name = (clean_guest_text(str(body.get("guest_name") or ""), 60).strip() or None) if body.get("guest_name") else None guest_phone = (re.sub(r"[^\d+]", "", str(body.get("guest_phone") or ""))[:20] or None) if body.get("guest_phone") else None comment = (clean_guest_text(str(body.get("comment") or ""), 300).strip() or None) if body.get("comment") else None # 4) дедупликация: тот же гость с тем же составом за последнюю минуту — # почти наверняка случайный повтор (двойной клик / повторная отправка) dedup_key = self._client_ip() + "|" + table + "|" + (guest_phone or "") + "|" + \ ";".join(sorted(f"{i['id']}:{i['qty']}" for i in items)) if order_dedup.seen(dedup_key): self._json( {"error": "Такой заказ вы уже отправили минуту назад — проверьте код отслеживания." " Нужен ещё один — отправьте через минуту или позовите официанта"}, 409, cors=True, ) return order = storage.create_order(table, items, source="site", guest_name=guest_name, guest_phone=guest_phone, comment=comment) self._json(order, 201, cors=True) def _handle_track_cancel(self, raw_code: str): """Гость отменяет СЛУЧАЙНО отправленный заказ по коду отслеживания. Разрешено только пока заказ «Новый» и ему меньше 10 минут — дальше бар уже готовит, звоните персоналу. Код — единственный секрет гостя.""" if not track_limiter.allow("track:" + self._client_ip()): self._json({"error": "too many requests"}, 429, cors=True) return if not track_global_limiter.allow("track-all"): self._json({"error": "сервис статуса перегружен — попробуйте позже"}, 503, cors=True) return order = storage.find_order_by_code(raw_code.upper()) if order is None: self._json({"error": "order not found"}, 404, cors=True) return if order["status"] == "cancelled": self._json({"error": "Этот заказ уже отменён"}, 409, cors=True) return if order["status"] != "new": self._json( {"error": "Заказ уже в работе — отменить нельзя, позвоните в бар: +7 (912) 901-72-17"}, 409, cors=True, ) return try: created = datetime.fromisoformat(order["created_at"]) too_late = datetime.now(created.tzinfo) - created > timedelta(minutes=GUEST_CANCEL_MINUTES) except (ValueError, TypeError): too_late = True if too_late: self._json( {"error": "Прошло больше 10 минут — заказ уже готовят, позвоните в бар: +7 (912) 901-72-17"}, 409, cors=True, ) return # атомарный предикат: гонка с PATCH персонала невозможна — если заказ # ушёл из «Новый» между проверкой и отменой, UPDATE не тронет ни строку updated = storage.cancel_order_as_guest(order["id"]) if updated is None: self._json( {"error": "Заказ уже в работе — отменить нельзя, позвоните в бар: +7 (912) 901-72-17"}, 409, cors=True, ) return self._json({"ok": True, "number": updated["number"], "status": updated["status"]}, 200, cors=True) def _handle_order_patch(self, order_id: int): body = self._read_body() or {} status = body.get("status") if status not in storage.STATUSES: self._json({"error": "bad status"}, 400) return user = self._auth() # официант и кальянщик управляют заказами только во время своей смены if user and user.get("role") in SHIFT_ROLES and storage.get_open_shift(user["login"]) is None: self._json({"error": "Смена не активна — управление заблокировано. Выйдите на смену."}, 403) return waiter = body.get("waiter") if waiter is not None: waiter = str(waiter)[:60].strip() or None if waiter is not None: # NF-03: сменные роли приписывают заказ только себе — чужое # (пусть и существующее) имя переезжает выручку в отчётах # задним числом; владелец/админ может любое валидное имя if user and user.get("role") in SHIFT_ROLES and waiter != user["name"]: self._json({"error": "заказ можно приписать только себе — выберите своё имя"}, 403) return # атрибуция — только реальному сотруднику (F-15) if not storage.is_known_staff_name(waiter): self._json({"error": "такого сотрудника нет в CRM — выберите имя из списка"}, 400) return updated = storage.update_order(order_id, status, waiter, actor=user) if updated is None: self._json({"error": "order not found"}, 404) return if updated == "bad_transition": # NF-03: произвольные переходы закрыты — из терминальных статусов # («Выдан»/«Отменён») и назад ведёт только владелец/админ self._json({"error": "такой переход статуса недоступен вашей роли — " "отмена выданного и возврат отменённого делает владелец/админ"}, 403) return self._json(updated) def _handle_manual_order(self, user: dict): """Ручной заказ из зала (владелец/админ/официант/кальянщик). Цена и названия позиций берутся из каталога CRM по id.""" if user.get("role") in SHIFT_ROLES and storage.get_open_shift(user["login"]) is None: self._json({"error": "Смена не активна — управление заблокировано. Выйдите на смену."}, 403) return body = self._read_body() or {} table = clean_guest_text(str(body.get("table") or ""), 60).strip() raw = body.get("items") if not table: self._json({"error": "выберите стол или зал"}, 400) return # NF-14: staff-путь валидирует стол как публичный (F-9) — произвольный # текст не попадёт в канбан и отчёт «Столы»; пустой справочник # залов — fail-closed if table not in storage.hall_names(): self._json({"error": "такого стола или зала нет — выберите из справочника"}, 400) return if not isinstance(raw, list) or not raw: self._json({"error": "добавьте хотя бы одну позицию"}, 400) return catalog = {m["id"]: m for m in storage.list_menu()} items = [] for i in raw[:30]: if not isinstance(i, dict): continue item_id = str(i.get("id") or "")[:64] entry = catalog.get(item_id) if entry is None or entry["deleted"]: continue # неизвестные/удалённые позиции игнорируем try: qty = min(50, max(1, int(i.get("qty", 1)))) except (TypeError, ValueError, OverflowError): # NF-08: qty 1e400 continue items.append({"id": item_id, "name": entry["name"], "qty": qty, "price": entry["price"]}) if not items: self._json({"error": "нет доступных позиций — обновите список"}, 400) return comment = (clean_guest_text(str(body.get("comment") or ""), 300).strip() or None) if body.get("comment") else None order = storage.create_order(table, items, source="hall", comment=comment) self._json(order, 201) def _handle_shift_manage(self): """Владелец/админ вручную записывает или снимает официанта/кальянщика.""" body = self._read_body() or {} login = str(body.get("login") or "").strip() action = str(body.get("action") or "") if not login or action not in ("in", "out"): self._json({"error": "login и action (in|out) required"}, 400) return target = next((u for u in storage.staff_shift_roles() if u["login"] == login), None) if target is None: self._json({"error": "смены ведутся для официантов и кальянщиков"}, 400) return if action == "in": if storage.get_open_shift(login) is not None: self._json({"error": f"{target['name']} уже на смене"}, 400) return self._json(storage.admin_clock_in(login), 201) return shift = storage.clock_out(login) self._json(shift or {"error": "нет активной смены"}, 200 if shift else 400) def _handle_track(self, raw_code: str): """Публичный статус заказа по коду — для страницы «Отследить заказ». Отдаём только то, что нужно гостю: статус, стол, сумму и метки времени; без телефона, имени и комментариев. """ if not track_limiter.allow("track:" + self._client_ip()): self._json({"error": "too many requests"}, 429, cors=True) return # перебор кодов гасим и глобально — ротацией IP его не обойти (F-8) if not track_global_limiter.allow("track-all"): self._json({"error": "сервис статуса перегружен — попробуйте позже"}, 503, cors=True) return order = storage.find_order_by_code(raw_code.upper()) if order is None: self._json({"error": "order not found"}, 404, cors=True) return self._json({ "id": order["id"], # недельный номер заказа — тот, что гость видел при создании "number": order["number"], "table": order["table"], "status": order["status"], "price": order["price"], "created_at": order["created_at"], "accepted_at": order["accepted_at"], "delivered_at": order["delivered_at"], }, cors=True) def _handle_hall_create(self): """Новый стол/зал (владелец): {name, sub?}. Имя уникально, подпись — произвольная («основной зал», «до 8 гостей»…).""" body = self._read_body() or {} name = str(body.get("name") or "").strip()[:60] sub = (str(body.get("sub") or "").strip()[:60] or None) if body.get("sub") else None if not name: self._json({"error": "укажите название стола или зала"}, 400) return created = storage.hall_create(name, sub) if created is None: self._json({"error": "стол или зал с таким названием уже есть"}, 409) return self._json(created, 201) def _handle_hall_manage(self, user: dict): """Занять/освободить стол или зону (админ/владелец). body: {name, busy: bool, minutes?: int} — minutes задаёт таймер освобождения; без него стол занят до ручного освобождения. """ body = self._read_body() or {} name = str(body.get("name") or "").strip() if not any(h["name"] == name for h in storage.halls_active()): self._json({"error": "неизвестный стол или зона"}, 400) return busy = body.get("busy") if not isinstance(busy, bool): self._json({"error": "busy (bool) required"}, 400) return minutes = None if busy and body.get("minutes") is not None: try: minutes = int(body["minutes"]) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 self._json({"error": "minutes — целое число минут"}, 400) return if not 5 <= minutes <= 720: self._json({"error": "таймер: от 5 до 720 минут"}, 400) return self._json(storage.set_hall_busy(name, busy, minutes, user["name"])) def _handle_hall_reserve(self, user: dict): """Бронь стола/зоны: body {name, from: 'ЧЧ:ММ', until: 'ЧЧ:ММ', guest?}. Время «раньше сейчас» означает следующие сутки (вечерняя бронь через полночь); окно короче 15 минут или длиннее суток не принимается. До начала брони стол можно занять как обычно — бронь лишь помечает стол, фронт ведёт отсчёт и напоминает персоналу за 20 минут. """ body = self._read_body() or {} name = str(body.get("name") or "").strip() if not any(h["name"] == name for h in storage.halls_active()): self._json({"error": "неизвестный стол или зона"}, 400) return m_from = RESERVE_TIME_RE.match(str(body.get("from") or "").strip()) m_until = RESERVE_TIME_RE.match(str(body.get("until") or "").strip()) if not m_from or not m_until: self._json({"error": "время брони в формате ЧЧ:ММ, например 21:00"}, 400) return tz = datetime.now().astimezone().tzinfo or timezone.utc now = datetime.now(tz) dt_from = now.replace(hour=int(m_from.group(1)), minute=int(m_from.group(2)), second=0, microsecond=0) if dt_from < now - timedelta(minutes=1): dt_from += timedelta(days=1) dt_until = now.replace(hour=int(m_until.group(1)), minute=int(m_until.group(2)), second=0, microsecond=0) # until должен быть первой меткой СТРОГО после from: и случай «01:30 # после 23:00», и случай, когда оба времени уже прошли сегодня while dt_until <= dt_from: dt_until += timedelta(days=1) if not timedelta(minutes=15) <= dt_until - dt_from <= timedelta(hours=24): self._json({"error": "бронь — от 15 минут до суток"}, 400) return if dt_from > now + timedelta(days=30): self._json({"error": "бронь не дальше чем на 30 дней"}, 400) return guest = (str(body.get("guest") or "").strip()[:60] or None) if body.get("guest") else None reservation = storage.hall_reserve(name, dt_from, dt_until, guest, user["name"]) if reservation is None: self._json({"error": "на этот стол в это время уже есть бронь"}, 409) return self._json({"reservation": reservation}, 201) def _handle_hall_reserve_cancel(self, user: dict, res_id: int): if not storage.hall_reserve_cancel(res_id): self._json({"error": "бронь не найдена или уже отменена"}, 404) return self._json({"ok": True}) def _handle_menu_photo(self, item_id: str): """Загрузка фото позиции (owner). Формат проверяем по сигнатуре файла.""" # id строго в формате слага сайта: unquote раскрывает %2F→../ и # %2A→*, что дало бы запись вне media/ или glob-удаление всех фото if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", item_id): self._json({"error": "некорректный id позиции"}, 400) return data = self._read_binary(PHOTO_MAX_BYTES) if data is None: self._json({"error": "файл не передан или больше 6 МБ"}, 400) return ext = image_extension(data) if ext is None: self._json({"error": "поддерживаются изображения avif, jpg, png, webp, gif"}, 400) return media = storage.MEDIA_DIR media.mkdir(parents=True, exist_ok=True) # убрать старые версии с другим расширением for old in media.glob(glob.escape(item_id) + ".*"): old.unlink() target = media / f"{item_id}.{ext}" target.write_bytes(data) self._json({"id": item_id, "image": f"/media/{target.name}"}, 201) def _handle_game_photo(self, game_id: int): """Обложка игры (админ/владелец) — та же проверка по сигнатуре файла, файлы живут в /media/games/. Игра должна существовать.""" game = storage.game_get(game_id) if game is None: self._json({"error": "игра не найдена"}, 404) return data = self._read_binary(PHOTO_MAX_BYTES) if data is None: self._json({"error": "файл не передан или больше 6 МБ"}, 400) return ext = image_extension(data) if ext is None: self._json({"error": "поддерживаются изображения avif, jpg, png, webp, gif"}, 400) return media = storage.GAMES_MEDIA_DIR media.mkdir(parents=True, exist_ok=True) for old in media.glob(glob.escape(str(game_id)) + ".*"): old.unlink() target = media / f"{game_id}.{ext}" target.write_bytes(data) self._json({"id": game_id, "image": f"/media/games/{target.name}"}, 201) def _handle_game_create(self, user: dict): """Новая игра: {kind: 'ps5'|'board', title, note?, difficulty?, icon?, accent?}.""" body = self._read_body() or {} kind = str(body.get("kind") or "").strip() title = str(body.get("title") or "").strip()[:120] note = (str(body.get("note") or "").strip()[:160] or None) if body.get("note") else None if kind not in GAME_KINDS: self._json({"error": "вид игры: ps5 или board"}, 400) return if not title: self._json({"error": "укажите название игры"}, 400) return fields = self._parse_game_fields(body, kind) if not isinstance(fields, dict): self._json({"error": fields}, 400) return game = storage.game_create(kind, title, note, user["name"], **fields) self._json({"game": game}, 201) def _parse_game_fields(self, body: dict, kind: str): """Общие поля настолок: difficulty/icon/accent. Для PS5 не принимаются. Возвращает dict полей либо строку с ошибкой.""" fields: dict = {} if kind != "board": return fields diff = body.get("difficulty") if diff is not None: try: diff = int(diff) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 return "сложность: 1, 2 или 3" if diff not in (1, 2, 3): return "сложность: 1, 2 или 3" fields["difficulty"] = diff icon = body.get("icon") if icon is not None: icon = str(icon).strip() if icon and icon not in GAME_ICONS: return "неизвестная иконка" fields["icon"] = icon or None accent = body.get("accent") if accent is not None: accent = str(accent).strip() if accent and not HEX_COLOR_RE.match(accent): return "цвет в формате #rrggbb" fields["accent"] = accent.lower() or None return fields def _handle_game_patch(self, user: dict, game_id: int): """Правка игры: {deleted?: bool, note?, difficulty?, icon?, accent?}.""" # тело читаем до раннего 404: непрочитанное тело PATCH ломает # следующий запрос keep-alive-клиента (десинхронизация потока) body = self._read_body() or {} game = storage.game_get(game_id) if game is None: self._json({"error": "игра не найдена"}, 404) return if not body: self._json({"error": "нечего изменять"}, 400) return fields: dict = {} if "deleted" in body: if not isinstance(body["deleted"], bool): self._json({"error": "deleted (bool) required"}, 400) return if body["deleted"]: if not storage.game_delete(game_id): self._json({"error": "игра не найдена или уже удалена"}, 404) return self._json({"ok": True}) return updated = storage.game_set_deleted(game_id, False) if updated is None: self._json({"error": "игра не найдена"}, 404) return self._json({"game": updated}) return else: fields = self._parse_game_fields(body, game["kind"]) if not isinstance(fields, dict): self._json({"error": fields}, 400) return if "note" in body: fields["note"] = (str(body["note"] or "").strip()[:160] or None) updated = storage.game_update(game_id, fields) if updated is None: self._json({"error": "игра не найдена"}, 404) return self._json({"game": updated}) def _handle_game_delete(self, game_id: int): """Удаление игры → корзина (мягко): сид сайта не пересоздаст её, вернуть можно через PATCH {deleted: false}. Обложка сохраняется.""" if not storage.game_delete(game_id): self._json({"error": "игра не найдена или уже удалена"}, 404) return self._json({"ok": True}) def _handle_catalog_patch(self, kind: str, item_id: str, user: dict): """Стоп-лист (админ/владелец), флаг deleted — удаление/восстановление (только владелец), подсветка (админ/владелец), поштучный учёт stock/stock_add (только владелец, только меню) и правка карточки (имя/категория/цена/порция, только владелец и только CRM-позиции). kind: 'menu' | 'tobacco'. Двухфазный (F-18): права и валидность КАЖДОГО поля проверяются до первой записи — мульти-полевой PATCH либо применяется целиком, либо отвечает ошибкой, не оставляя половину изменений в БД.""" body = self._read_body() or {} if not body: self._json({"error": "stopped, deleted, accent, stock, stock_add или поля правки required"}, 400) return role = user.get("role") ops = [] if "stopped" in body: # стоп-лист ведут админ/владелец по всему каталогу, кальянщик — # только по табаку (вкусы для кальянов) if role not in ADMIN_ROLES and \ not (kind == "tobacco" and role == "hookah"): self._json({"error": "admin only"}, 403) return if not isinstance(body["stopped"], bool): self._json({"error": "stopped (bool) required"}, 400) return ops.append(lambda conn: storage.set_menu_stopped(item_id, body["stopped"], conn=conn) if kind == "menu" else storage.set_tobacco_stopped(item_id, body["stopped"], conn=conn)) if "deleted" in body: if role != "owner": self._json({"error": "owner only"}, 403) return if not isinstance(body["deleted"], bool): self._json({"error": "deleted (bool) required"}, 400) return ops.append(lambda conn: storage.set_menu_deleted(item_id, body["deleted"], conn=conn) if kind == "menu" else storage.set_tobacco_deleted(item_id, body["deleted"], conn=conn)) if "accent" in body and kind == "menu": if role not in ADMIN_ROLES: self._json({"error": "admin only"}, 403) return accent = body["accent"] if accent is not None: accent = str(accent).strip() if not HEX_COLOR_RE.match(accent): self._json({"error": "цвет подсветки в формате #rrggbb"}, 400) return accent = accent.lower() ops.append(lambda conn: storage.set_menu_accent(item_id, accent or None, conn=conn)) # поштучный учёт (пиво/энергетики банками): stock — задать остаток # абсолютно (None — выключить учёт), stock_add — приход N штук if "stock" in body or "stock_add" in body: if kind != "menu": self._json({"error": "поштучный учёт ведётся только по позициям меню"}, 400) return if role != "owner": self._json({"error": "owner only"}, 403) return if "stock" in body and "stock_add" in body: self._json({"error": "отправьте либо stock, либо stock_add"}, 400) return if "stock" in body: raw = body["stock"] if raw is None: ops.append(lambda conn: storage.set_menu_stock(item_id, new_stock=None, conn=conn)) else: try: qty = int(raw) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 self._json({"error": "stock — целое число штук или null"}, 400) return if not 0 <= qty <= 100_000: self._json({"error": "stock — от 0 до 100000"}, 400) return ops.append(lambda conn: storage.set_menu_stock(item_id, new_stock=qty, conn=conn)) else: try: add = int(body["stock_add"]) except (TypeError, ValueError, OverflowError): # NF-08 self._json({"error": "stock_add — целое число штук"}, 400) return if not 1 <= add <= 100_000: self._json({"error": "stock_add — приход от 1 до 100000 штук"}, 400) return ops.append(lambda conn: storage.set_menu_stock(item_id, add=add, conn=conn)) # правка карточки позиции: имя/категория/цена/порция (только владелец, # только позиции, добавленные через CRM, — стартовые задаёт код сайта) edit_keys = [k for k in ("name", "category", "price", "portion") if k in body] if edit_keys: if kind != "menu": self._json({"error": "название и цена редактируются только у позиций меню"}, 400) return if role != "owner": self._json({"error": "owner only"}, 403) return fields: dict = {} if "name" in body: name = str(body["name"] or "").strip()[:120] if not name: self._json({"error": "укажите название позиции"}, 400) return fields["name"] = name if "category" in body: category = str(body["category"] or "").strip()[:60] if category not in storage.menu_categories(): self._json({"error": "выберите существующую категорию: " + ", ".join(storage.menu_categories())}, 400) return fields["category"] = category if "price" in body: try: fields["price"] = min(1_000_000, max(0, int(body["price"]))) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 self._json({"error": "цена — целое число"}, 400) return if "portion" in body: portion = str(body["portion"] or "").strip()[:40] fields["portion"] = portion or None ops.append(lambda conn: storage.update_menu_item(item_id, fields, conn=conn)) # --- фаза 2: применяем в ОДНОЙ транзакции (NF-07): все проверки # пройдены, но и записи атомарны — отказ любого op (seed / not found) # откатывает уже применённые, «400 на втором поле» больше не оставляет # закоммиченный первый стоп/удаление updated = None try: with storage.db() as conn: for op in ops: updated = op(conn) if updated == "seed": raise _CatalogPatchAbort({ "error": "стартовые позиции сайта заданы в его коде — править" " можно только позиции, добавленные через CRM", }, 400) if updated is None: raise _CatalogPatchAbort({"error": "not found"}, 404) except _CatalogPatchAbort as abort: self._json(abort.payload, abort.code) return if updated is None: # тело было, но ни один известный ключ не совпал self._json({"error": "not found"}, 404) return self._json(updated) # ---------------- владелец: сотрудники, позиции, отчёты ---------------- def _handle_user_create(self): body = self._read_body() or {} login = str(body.get("login") or "").strip() password = str(body.get("password") or "") name = str(body.get("name") or "").strip() role = str(body.get("role") or "") if not LOGIN_RE.match(login): self._json({"error": "логин: 3–40 символов, латиница, цифры, . _ -"}, 400) return if len(password) < 8: self._json({"error": "пароль минимум 8 символов"}, 400) return if not name: self._json({"error": "укажите имя сотрудника"}, 400) return if role not in CREATABLE_ROLES: self._json({"error": "роль: waiter, hookah или admin"}, 400) return created = storage.create_user(login, password, name, role) if created is None: self._json({"error": "такой логин уже занят"}, 409) return if created == "name_taken": self._json({"error": "сотрудник с таким именем уже есть — атрибуция заказов" " требует уникальных имён"}, 409) return self._json(created, 201) def _handle_user_password(self): """Сброс пароля сотрудника владельцем: текущий пароль сотрудника не нужен, его сессии гасятся (см. storage.reset_password). Владельца через этот эндпоинт менять нельзя — только сам, кнопкой 🔑.""" body = self._read_body() or {} login = str(body.get("login") or "").strip() new = str(body.get("new_password") or "") if not login or not new: self._json({"error": "login и new_password required"}, 400) return if len(new) < 8: self._json({"error": "новый пароль — минимум 8 символов"}, 400) return targets = [u for u in storage.list_users() if u["login"] == login] if not targets: self._json({"error": "сотрудник не найден"}, 404) return if targets[0]["role"] == "owner": self._json({"error": "пароль владельца меняется только им самим (кнопка 🔑)"}, 400) return if not storage.reset_password(login, new): self._json({"error": "сотрудник не найден"}, 404) return self._json({"ok": True}) def _handle_menu_create(self): body = self._read_body() or {} name = str(body.get("name") or "").strip()[:120] category = str(body.get("category") or "").strip()[:60] portion = (str(body.get("portion") or "").strip()[:40] or None) if body.get("portion") else None try: price = min(1_000_000, max(0, int(body.get("price", 0)))) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 self._json({"error": "цена — целое число"}, 400) return # поштучный учёт: необязательное начальное количество в штуках raw_stock = body.get("stock") stock = None if raw_stock not in (None, ""): try: stock = int(raw_stock) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 self._json({"error": "количество — целое число штук"}, 400) return if not 0 <= stock <= 100_000: self._json({"error": "количество — от 0 до 100000 штук"}, 400) return if not name: self._json({"error": "укажите название позиции"}, 400) return # категория должна существовать — иначе сайт не знает, куда поставить позицию if category not in storage.menu_categories(): self._json({"error": "выберите существующую категорию: " + ", ".join(storage.menu_categories())}, 400) return self._json(storage.create_custom_menu_item(name, category, price, portion, stock), 201) def _handle_tobacco_create(self): body = self._read_body() or {} brand = str(body.get("brand") or "").strip()[:60] flavor = str(body.get("flavor") or "").strip()[:120] note = (str(body.get("note") or "").strip()[:160] or None) if body.get("note") else None try: strength = min(3, max(1, int(body.get("strength", 2)))) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 self._json({"error": "крепость — число от 1 до 3"}, 400) return cooling = bool(body.get("cooling")) if not brand: self._json({"error": "укажите бренд"}, 400) return if not flavor: self._json({"error": "укажите название вкуса"}, 400) return self._json(storage.create_custom_tobacco(brand, flavor, note, strength, cooling), 201) def _parse_mix_payload(self, body: dict) -> dict | None: """Общая валидация полей микса для создания и правки. None — ошибка уже отправлена гостю; иначе словарь полей.""" name = str(body.get("name") or "").strip()[:120] note = (str(body.get("note") or "").strip()[:200] or None) if body.get("note") else None if not name: self._json({"error": "укажите название микса"}, 400) return None raw_ids = body.get("flavor_ids") if not isinstance(raw_ids, list): self._json({"error": "выберите вкусы микса"}, 400) return None flavor_ids: list[str] = [] for x in raw_ids: fid = str(x or "").strip()[:64] if fid and fid not in flavor_ids: flavor_ids.append(fid) if not 2 <= len(flavor_ids) <= 5: self._json({"error": "микс — это 2–5 вкусов"}, 400) return None def opt_int(key, lo, hi): """Необязательное целое в диапазоне; None — поле не задано.""" if body.get(key) in (None, ""): return None try: val = int(body[key]) except (TypeError, ValueError, OverflowError): return None # мусор (включая 1e400, NF-08) трактуем как «не задано» return val if lo <= val <= hi else None # теги для AI-генератора: максимум 6, каждый 1–20 видимых символов tags: list[str] = [] raw_tags = body.get("tags") if isinstance(raw_tags, list): for x in raw_tags[:6]: tag = str(x or "").strip()[:20] if tag and tag not in tags: tags.append(tag) return { "name": name, "note": note, "flavor_ids": flavor_ids, "strength": opt_int("strength", 1, 5), "cooling": opt_int("cooling", 0, 3), "price": opt_int("price", 0, 1_000_000), "tags": tags, } def _handle_mix_create(self): """Микс от владельца или кальянщика: название + вкусы справочника табаков, без долей. Состав видят гости на странице «Миксы НЕОНа», пропорции делает кальянщик. Валидируем: имя, 2–5 существующих вкусов, заметка ≤200. Расширенная информация (всё необязательное): крепость 1–5, холодок 0–3, цена ₽, теги — сайт покажет их в карточке микса и по тегам подмешает микс в AI-генерацию. """ fields = self._parse_mix_payload(self._read_body() or {}) if fields is None: return created = storage.create_custom_mix(**fields) if created is None: self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400) return self._json(created, 201) def _handle_mix_patch(self, mix_id: str): """Правка микса (владелец/кальянщик): тот же набор полей, что и при создании — название, состав и расширенная информация.""" fields = self._parse_mix_payload(self._read_body() or {}) if fields is None: return updated = storage.update_mix(mix_id, **fields) if updated == "not_found": self._json({"error": "микс не найден"}, 404) return if updated in ("bad_flavors", None): self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400) return self._json(updated) def _handle_site_mix_patch(self, mix_id: str): """Правка витринного микса сайта (только владелец). Форма присылает полный снимок полей: название, заметку (пустая строка = убрать описание), крепость/холодок/цену и теги. Состав опционален: flavor_ids [] — оставить исходный состав витрины, иначе 2–5 вкусов из справочника табаков. Снимок сохраняется в site_mix_edits, сайт применяет его к своей витрине через /api/catalog; DELETE по тому же адресу возвращает микс к исходному виду.""" # тело читаем до любых ранних выходов: непрочитанное тело PATCH # остаётся в сокете и ломает следующий запрос keep-alive-клиента body = self._read_body() or {} seed_ids = {m["id"] for m in storage.site_mixes()} if mix_id not in seed_ids: self._json({"error": "витринный микс с таким id не найден"}, 404) return name = str(body.get("name") or "").strip()[:120] if not name: self._json({"error": "укажите название микса"}, 400) return def opt_int(key, lo, hi): if body.get(key) in (None, ""): return None try: val = int(body[key]) except (TypeError, ValueError, OverflowError): # NF-08: 1e400 return None return val if lo <= val <= hi else None strength = opt_int("strength", 1, 5) cooling = opt_int("cooling", 0, 3) price = opt_int("price", 0, 1_000_000) if strength is None or cooling is None or price is None: self._json({"error": "заполните крепость (1–5), холодок (0–3) и цену"}, 400) return tags: list[str] = [] raw_tags = body.get("tags") if isinstance(raw_tags, list): for x in raw_tags[:6]: tag = str(x or "").strip()[:20] if tag and tag not in tags: tags.append(tag) raw_ids = body.get("flavor_ids") if raw_ids is None or raw_ids == []: flavors = None # состав не менялся — сайт оставит исходный elif isinstance(raw_ids, list): flavor_ids: list[str] = [] for x in raw_ids: fid = str(x or "").strip()[:64] if fid and fid not in flavor_ids: flavor_ids.append(fid) if len(flavor_ids) == 1: self._json({"error": "микс — это 2–5 вкусов; очистите состав, чтобы оставить исходный"}, 400) return rows = storage.list_tobacco() by_id = {t["id"]: t for t in rows if not t.get("deleted")} flavors = [ {"id": t["id"], "brand": t["brand"], "flavor": t["flavor"]} for t in (by_id.get(i) for i in flavor_ids) if t ] if len(flavors) < 2: self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400) return else: self._json({"error": "flavor_ids — список вкусов"}, 400) return storage.update_site_mix(mix_id, { "name": name, "note": str(body.get("note") or "").strip()[:200], "strength": strength, "cooling": cooling, "price": price, "tags": tags, "flavors": flavors, }) self._json({"ok": True, "id": mix_id}) def _report_period(self, qs: dict): """Период отчёта из query: ?from=ГГГГ-ММ-ДД&to=ГГГГ-ММ-ДД или ?all=1 (от самого раннего заказа по сегодня). Возвращает (day_from, day_to) или None — ответ об ошибке уже отправлен.""" tz = datetime.now().astimezone().tzinfo or timezone.utc if (qs.get("all") or ["0"])[0] == "1": # «за всё время»: от самого раннего заказа по сегодня включительно first = storage.first_order_dt() day_from = first.astimezone(tz).date() if first else datetime.now(tz).date() return day_from, datetime.now(tz).date() raw_from = (qs.get("from") or [""])[0] raw_to = (qs.get("to") or [""])[0] if not DATE_RE.match(raw_from) or not DATE_RE.match(raw_to): self._json({"error": "период: from=ГГГГ-ММ-ДД&to=ГГГГ-ММ-ДД"}, 400) return None try: day_from = date.fromisoformat(raw_from) day_to = date.fromisoformat(raw_to) except ValueError: self._json({"error": "неверная дата"}, 400) return None if day_to < day_from: self._json({"error": "конец периода раньше начала"}, 400) return None return day_from, day_to def _report_utc_range(self, days): """(day_from, day_to) → (dt_from, dt_to) UTC; конец — полночь следующего дня, границы включительные.""" tz = datetime.now().astimezone().tzinfo or timezone.utc day_from, day_to = days dt_from = datetime.combine(day_from, dtime(0, 0), tzinfo=tz).astimezone(timezone.utc) dt_to = datetime.combine(day_to + timedelta(days=1), dtime(0, 0), tzinfo=tz).astimezone(timezone.utc) return dt_from, dt_to def _handle_report(self, user: dict): if user["role"] != "owner": self._json({"error": "owner only"}, 403) return days = self._report_period(parse_qs(urlsplit(self.path).query)) if days is None: return dt_from, dt_to = self._report_utc_range(days) report = storage.orders_report(dt_from, dt_to) report["period"] = {"from": days[0].isoformat(), "to": days[1].isoformat()} self._json(report) def _handle_staff_report(self): """Статистика сотрудников за период: заказы, выручка, средний чек, средняя подача, смены и часы (см. storage.staff_report).""" days = self._report_period(parse_qs(urlsplit(self.path).query)) if days is None: return dt_from, dt_to = self._report_utc_range(days) staff = storage.staff_report(dt_from, dt_to) self._json({ "period": {"from": days[0].isoformat(), "to": days[1].isoformat()}, "staff": staff, }) def _handle_report_excel(self): """Выгрузка отчёта в xlsx (stdlib zip+xml, без зависимостей): сводка с итогом «Всего (сайт + зал)», по дням, по часам, топ позиций, столы и сотрудники. by_day=1 — отдельный лист на каждый день периода (лимит 31 день).""" qs = parse_qs(urlsplit(self.path).query) days = self._report_period(qs) if days is None: return dt_from, dt_to = self._report_utc_range(days) report = storage.orders_report(dt_from, dt_to) staff = storage.staff_report(dt_from, dt_to) period = {"from": days[0].isoformat(), "to": days[1].isoformat()} if (qs.get("by_day") or ["0"])[0] == "1": span = (days[1] - days[0]).days + 1 if span > 31: self._json({"error": "разбивка по дням — период не длиннее 31 дня"}, 400) return sheets = [_summary_sheet(report, staff, period)] day = days[0] while day <= days[1]: d_from, d_to = self._report_utc_range((day, day)) sheets.append(_day_sheet(storage.orders_report(d_from, d_to), storage.staff_report(d_from, d_to), day)) day += timedelta(days=1) name = f"neon-report-days_{period['from']}_{period['to']}.xlsx" else: sheets = _report_xlsx(report, staff, period) name = f"neon-report_{period['from']}_{period['to']}.xlsx" self._send_file( _xlsx_bytes(sheets), name, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) # ---------------------------------------------------------------- запуск def main() -> None: import mimetypes mimetypes.add_type("image/avif", ".avif") mimetypes.add_type("image/webp", ".webp") storage.init_pool() storage.init_schema() # F-6: seed-файлы проверяем до старта — битый или отсутствующий роняет # запуск с внятной ошибкой вместо тихой чистки каталога при сидинге storage.validate_seeds() # проверяем ФАКТ: у каких учёток пароль всё ещё дефолтный. Смена # пароля в .env на уже созданной базе не применяется (сид делает # ON CONFLICT DO NOTHING), поэтому переменную окружения не проверяем — # сверяем хэши со всеми известными дефолтами (F-13). По умолчанию — # отказ старта: с дефолтными паролями панель доступна всему интернету. weak = storage.weak_default_logins() if weak: message = ( "[security] Учётки с паролем по умолчанию: " + ", ".join(weak) + ".\n" "Смените пароли каждому сотруднику (панель: кнопка «Сменить пароль»)\n" "или пересоздайте базу с новыми значениями в .env.\n" "Запуск с дефолтными паролями запрещён; для dev-стенда явно задайте\n" "переменную окружения CRM_ALLOW_DEFAULT_PASSWORDS=1." ) if os.environ.get("CRM_ALLOW_DEFAULT_PASSWORDS", "").strip() == "1": print(message, file=sys.stderr) else: sys.exit(message) # NF-12: бинд задаётся переменной CRM_BIND. В контейнере 0.0.0.0 — # снаружи порт всё равно закрыт (compose публикует только 127.0.0.1), # а локальный dev-стенд (dev-crm.bat) ставит 127.0.0.1, чтобы панель # со слабыми dev-настройками не светилась в LAN. bind = os.environ.get("CRM_BIND", "0.0.0.0").strip() or "0.0.0.0" server = ThreadingHTTPServer((bind, PORT), Handler) print(f"NEON CRM -> http://{bind}:{PORT} ({storage.BACKEND}, таблицы готовы)") try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() storage.close_pool() if __name__ == "__main__": main()