Files
neon-bar86.ru/crm/server.py
T
2026-10-11 22:24:54 +05:00

2504 lines
126 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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'<c r="{ref}"{style}><v>{value}</v></c>'
text = str(value)
# защита от formula injection: текст гостя, начинающийся с =+-@,
# Excel исполнил бы как формулу при открытии отчёта — гасим апострофом
if text[0] in "=+-@":
text = "'" + text
return f'<c r="{ref}" t="inlineStr"{style}><is><t>{escape(text)}</t></is></c>'
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'<col min="{i}" max="{i}" width="26" customWidth="1"/>'
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'<row r="{ri}">{cells}</row>')
sheet_parts.append({
"name": sheet["name"],
"xml": ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f"<cols>{cols}</cols>"
f'<sheetData>{"".join(data_rows)}</sheetData></worksheet>'),
})
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 = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<workbook xmlns="{ns_main}" xmlns:r="{ns_rel}"><sheets>'
+ "".join(
f'<sheet name="{escape(s["name"])}" sheetId="{i + 1}" r:id="rId{i + 1}"/>'
for i, s in enumerate(sheet_parts)
)
+ "</sheets></workbook>"
)
workbook_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<Relationships xmlns="{ns_pack}">'
+ "".join(
f'<Relationship Id="rId{i + 1}" Type="{ns_rel}/worksheet"'
f' Target="worksheets/sheet{i + 1}.xml"/>'
for i in range(len(sheet_parts))
)
+ f'<Relationship Id="rId{len(sheet_parts) + 1}" Type="{ns_rel}/styles" Target="styles.xml"/>'
+ "</Relationships>"
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
+ "".join(
f'<Override PartName="/xl/worksheets/sheet{i + 1}.xml"'
' ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
for i in range(len(sheet_parts))
)
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
+ "</Types>"
)
root_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<Relationships xmlns="{ns_pack}">'
f'<Relationship Id="rId1" Type="{ns_rel}/officeDocument" Target="xl/workbook.xml"/>'
"</Relationships>"
)
styles = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<styleSheet xmlns="{ns_main}">'
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font>'
'<font><b/><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>'
'<borders count="1"><border/></borders>'
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
'<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>'
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
"</styleSheet>"
)
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()