initial site

This commit is contained in:
merrow committed 2026-10-11 22:24:54 +05:00
commit da5b259d06
167 files changed
+34157

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
---
name: fix-finding
description: Исправление и верификация конкретной security-находки в проекте НЕОН (site/crm/db). Использовать, когда пользователь явно просит исправить, закрыть или залечить уязвимость или finding из аудита — «исправь F-3», «закрой находку из AUDIT/FINDINGS.md», «почини IDOR из аудита и проверь». НЕ использовать для обычных багов, рефакторинга, ревью кода/дизайна, полного аудита (полный аудит делает скилл security-audit — он только документирует) или деплоя.
---
# Fix Finding — минимальный проверенный фикс security-находки в НЕОН
Превратить текущую security-находку в минимальный, проверенный на живом стенде
код-фикс. Если код уже безопасен — доказать это и вернуть `no_change`.
Аудит (поиск и документирование) делает скилл `security-audit`; этот скилл —
вторая стадия над его результатами.
## Источник находки
Найди ID и контекст в одном из мест:
- `AUDIT/FINDINGS.md` — вывод аудит-скилла `security-audit`;
- `АУДИТ-ПЕРЕД-РЕЛИЗОМ.md` — предрелизный аудит, нумерация 1–38
(пункт с пометкой «вопрос к владельцу» — не фикси, это `blocked`);
- F-N — сквозная нумерация security-фиксов в комментариях кода
(`docker-compose.yml`, `crm/server.py`) и в `crm/_security_smoke.mjs`;
- прямое описание от пользователя (файл/эндпоинт + путь атаки).
Если в находке нет файла/эндпоинта — сначала локализуй путь атаки по коду.
Фиксить можно только явно запрошенную находку; соседние находки — доложить,
не чинить.
## Порядок свойств результата
Оценивай результат в этом порядке; более раннее никогда не меняется на более
позднее. «Минимальный» = наименьшее проектно-нативное изменение, закрывающее
все более ранние свойства, а не наименьшее число строк:
1. текущее состояние корректно классифицировано: уязвимо / уже безопасно / недоказано
2. фикс полностью закрывает нарушенную security-границу
3. легитимное поведение и совместимость сохранены
4. проверки проекта (см. гейты ниже) пройдены
5. реализация следует конвенциям проекта
6. патч содержит только необходимый охват
## Карта проекта
- `crm/` — Python 3, stdlib + psycopg, без фреймворков. `server.py` — HTTP +
REST + роли; `notes_api.py` — доски заметок (примешан в Handler);
`storage.py` — PostgreSQL (боевой), `storage_sqlite.py` — dev-двойник
(ограничен: игры/доски/брони/миксы сайта/сброс нумерации → 501);
`js/app.js` + `index.html` + `css/` — панель персонала; `seed/*.json` —
каталог; `migrate/notes_boards.sql` — канонический DDL досок.
- `site/` — React + Vite + Tailwind, собирается в статику под nginx.
`nginx.conf` — edge: лимит `POST /api/orders` (10/мин с IP), real_ip из
X-Forwarded-For, `/media/` отдаёт фото CRM; `src/lib/crm.ts` — мост к API.
- `docker-compose.yml` — site (наружу `127.0.0.1:8080`), crm
(`127.0.0.1:47613`), db (PostgreSQL 17, не публикуется). Секреты только
из `.env`.
- Роли CRM: владелец / администратор / официант / кальянщик — матрица в
README; пять слоёв защиты заказов (nginx-лимит → лимит на гостя →
дедупликация → глобальный тормоз → honeypot).
- Git в проекте НЕТ: перед правкой делай снимок (см. ниже), diff собирай
по снимкам.
Фикс может жить на любом из трёх слоёв (nginx → server.py → storage).
Логика storage продублирована в двух бэкендах: фикс в `storage.py` почти
всегда должен зеркалиться в `storage_sqlite.py` (и наоборот) — аудит не раз
писал «в обоих бэкендах». Учитывай и панель (`crm/js/app.js`): что она
ожидает от изменённого эндпоинта.
## Патч-контракт (до правки)
Изучи саму реализацию, её прямых вызователей, соседние хелперы и разметку
роль×эндпоинт в README. Установи из кода, а не из находки:
- атакующий вход и конкретный путь источник → sink (или сломанный контроль);
- security-инвариант и узкую общую границу, где его enforce;
- легитимное поведение, API-контракты, семантику ошибок, совместимость;
- ближайшие прецеденты в этом же коде (экранирование `esc()` в панели,
параметризованный SQL `%s`, whitelist статики, лимитеры).
Находка — задача о потоке данных и границе, а не о примере входа из отчёта.
Проверь эквивалентные кодировки, alternate-формы парсера, алиасы, всех
вызователей, оба бэкенда и каждую копию security-чувствительного состояния,
которая может обойти фикс. Небезопасное состояние обрабатывай явно — не
принимай, не обрезай и не переинтерпретируй его молча.
## Предпатчевое расследование
До правки запусти один свежий read-only субагент (Agent tool, general-purpose,
с инструкцией не менять файлы). Если делегирование недоступно — сделай тот же
проход сам отдельно, свежим взглядом:
- **Исследователь границы и совместимости:** независимо трассирует путь
источник → sink, находит общую границу enforcement, затронутые входы,
альтернативные представления и состояния, переходы валидация →
использование, конкретные sibling-пути и обходы. Фиксирует легитимные
сценарии и публичное поведение, которые обязаны остаться, и проверяет
вызователей, режимы, ошибки, побочные эффекты, конвенции, готовые хелперы
и применимые команды проверки.
Расследование требует ссылок на файлы/строки проекта и разделения фактов,
выводов и открытых вопросов. По завершении сверь с собственной трассировкой
и выбери границу фикса.
## Снимки вместо git
Перед первым изменением скопируй оригиналы изменяемых файлов в
`AUDIT/FIXES/.snapshots/<ID>/` (создай каталог). По снимкам собери diff для
ревью и отчёта (PowerShell `Compare-Object`, `fc` или `git diff --no-index`,
если git есть на хосте). Не оставляй временных файлов-двойников вида `.bak`
в рабочем дереве после завершения.
## Реализация
1. Протрассируй путь и верни `no_change`, если код уже безопасен — без
спекулятивных правок.
2. Где возможно, прогони через границу минимальный высокосигнальный repro
и один легитимный контрольный запрос по тому же пути (живой стенд ниже).
3. Сделай наименьший фикс на общей границе. Предпочитай соседние хелперы и
установленные API. Не расширяйся на редизайн, уборку и соседние находки.
4. До верификации атакуй свой патч, а не защищай: проверь каждого прямого
вызователя изменённого хелпера и обе ветки каждого изменённого условия.
Найди (а) один sibling-путь, представление или копию, которые всё ещё
доходят до sink, (б) один обычный/дефолтный вход, который патч начал
отвергать или переинтерпретировать. Если хоть одно есть — переделай.
5. Верифицируй по гейтам (порядок обязателен).
## Гейты верификации
1. **Синтаксис/сборка** — только изменённое:
- Python: `python -m py_compile crm\server.py crm\storage.py crm\storage_sqlite.py crm\notes_api.py` (в crm/);
- сайт (если тронут `site/`): из `site/` — `npx tsc --noEmit`, затем `npm run build`;
- движок миксов (если тронут `site/src` миксы/стоп-лист): `npm run test:engine`.
2. **Security-триггер** — repro находки на живом стенде больше не
воспроизводится + один альтернативный класс вредоносного входа.
3. **Легитимный контроль** — обычный сценарий по тому же пути работает;
соседние роли не сломаны (токены owner/admin/waiter — матрица в README).
### Живой стенд
- **Postgres-стек** (нужен для игр, досок, броней, миксов, сброса нумерации —
в sqlite это 501): `docker compose up -d --build`, CRM на
`127.0.0.1:47613`, сайт на `127.0.0.1:8080`.
- **SQLite-стенд** без Docker: `start-crm.bat` (`CRM_DB=sqlite`,
`CRM_ALLOW_DEFAULT_PASSWORDS=1`, порт 47613).
- Сайт-дев: в `site/` `npm run dev`; если docker уже держит 8080 —
`npm run dev -- --port 8090 --strictPort`.
- Перед стартом проверь, кто слушает порт (`netstat -ano | findstr :47613`):
8080/47613 держит docker-стек, **8081 — чужой процесс, его не трогать**.
- Доступы стенда — из `.env` (SMOKE_* нет в .env — задавай при запуске скрипта).
### Регрессионный артефакт
Проект верифицирует фиксы smoke-скриптами по образцу `crm/_security_smoke.mjs`:
`fetch` против `127.0.0.1:47613`, PASS/FAIL, ненулевой exit при провале,
пароли через env (`SMOKE_OWNER_PW`, `SMOKE_WAITER_PW`). Добавь проверки
фикса в `_security_smoke.mjs` или создай сфокусированный `crm/_fix_<ID>.mjs`
по тому же образцу. Помни побочные эффекты: lockout-тест закрывает
`waiter.mark` на 15 минут (порядок проверок важен), тест смены пароля обязан
возвращать пароль обратно.
## Ревью кандидата
После реализации и фокусных проверок запусти одного свежего read-only
субагента (Agent tool; инструкции не редактировать и не делегировать). Дай
ему только находку, корень проекта, авторизованный объём, конвенции и текущий
diff кандидата — без обоснований, отчёта исследователя и заявлений, что
тесты прошли. Если делегирование недоступно — сделай этот проход сам,
отдельно, до финальной верификации:
- **Ревьюер обходов и регрессий:** реконструирует инвариант и ищет конкретный
выживший маршрут через затронутые входы, эквивалентные представления,
границы парсеров, алиасы, оба бэкенда, слой nginx и щели
валидация → использование; трассирует изменённые условия и вызователей на
предмет конкретной поломки легитимных входов, контрактов, ошибок,
побочных эффектов и конвенций.
Находки ревьюера — гипотезы: подтверждай их по коду или фокусным запуском,
правь только подтверждённые в рамках находки и совместимости. Затем
прогони затронутые гейты заново и убедись, что временных и посторонних
изменений не осталось. Только один цикл ревью.
Верни `blocked`, если находка может быть реальной, но не хватает существенных
данных, инструментария, доступа или это продуктовое решение владельца
(пример: №38 «waiter вне смены видит телефоны» из предрелизного аудита) —
тогда безопасный фикс невозможно завершить или проверить.
## Отчёт
Напиши `AUDIT/FIXES/<ID>.md` и проставь в источнике находки (`AUDIT/FINDINGS.md`
или `АУДИТ-ПЕРЕД-РЕЛИЗОМ.md`) статус фикса со ссылкой на отчёт. В финальном
ответе в чате обязательно:
- outcome: `fixed` / `no_change` / `blocked`
- конкретный уязвимый путь, security-инвариант и легитимное поведение, которое
обязано было сохраниться
- выбранная стратегия и почему это самый узкий полный вариант (либо — какое
решение владельца отсутствует при `blocked`)
- изменённые файлы и добавленные тесты/артефакты
- команды по гейтам с результатами pass/fail/unknown
- как показано, что исходная находка больше не воспроизводится
- как показано, что легитимное поведение сохранилось
- оставшиеся пробелы и пропущенные проверки, если есть
## Жёсткие правила
- Не докладывай `fixed`, пока не пройдены все гейты по порядку. Проверку
можно пропустить только с обоснованием по коду; недоступная существенная
проверка = `blocked` и явное указание, чего не хватило.
- Не полагайся только на чтение кода, когда возможен фокусный repro на стенде.
- Не расширяй патч на уборку, соседние находки и редизайн без доказательства,
что без этого граница не закрывается.
- Не удаляй и не затирай правки пользователя — git нет, снимки единственная
страховка; не трогай `crm/data/`, `crm/media/`, `.env` и `site/dist`.
- Не ослабляй авторизацию, ролевую матрицу, пять слоёв защиты заказов,
валидацию входа, fail-fast на дефолтных паролях и лимитеры ради зелёных
проверок.
- Не деплой на прод (`docker compose up -d --build site crm`) без явной
команды владельца — верификация идёт на локальном стенде.
- Не прячь пробелы доказательств: если среда не дала проверить — назови
команду и чего не хватает.
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
# ===== PostgreSQL =====
# СЕКРЕТЫ: заполняйте своими значениями. Файл .env (с реальными паролями)
# не должен попадать в поставку/архив — только этот .env.example.
# ВНИМАНИЕ: если база уже создана, смена PG_PASSWORD в .env не действует —
# сначала ALTER USER в postgres, затем синхронизируйте .env.
PG_DB=neon_crm
PG_USER=neon
PG_PASSWORD=придумайте-длинный-случайный-пароль
# ===== Учётки CRM (вход в панель) =====
# Пароли применяются при ПЕРВОМ создании учётки (минимум 8 символов).
# На живой базе пароль каждому сотруднику меняется в панели: кнопка 🔑
# рядом с именем. Старт с дефолтными паролями запрещён — сервер не поднимется,
# пока все учётки не получат собственные пароли (см. CRM_ALLOW_DEFAULT_PASSWORDS).
CRM_OWNER_LOGIN=owner
CRM_OWNER_PASSWORD=придумайте-случайный-пароль-1
CRM_OWNER_NAME=Нурлан
CRM_ADMIN_LOGIN=admin
CRM_ADMIN_PASSWORD=придумайте-случайный-пароль-2
CRM_ADMIN_NAME=Ирина
CRM_WAITER_LOGIN=waiter.mark
CRM_WAITER_PASSWORD=придумайте-случайный-пароль-3
CRM_WAITER_NAME=Марк
# ===== CRM =====
# 1 — наполнить базу демо-заказами при первом старте (для показа заказчику).
# В бою держи 0.
CRM_SEED_DEMO_ORDERS=0
# Часовой пояс бара: по нему считаются расписание смен, сутки в отчётах
# и «новинки». По умолчанию Asia/Yekaterinburg (Нягань, UTC+5).
#CRM_TZ=Asia/Yekaterinburg
# 1 — разрешить старт с дефолтными паролями. ТОЛЬКО для локального dev-стенда,
# который не смотрит в интернет; боевой сервер так запускать нельзя.
#CRM_ALLOW_DEFAULT_PASSWORDS=0
# ===== Сайт =====
# На каком адресе и порте сайт смотрит наружу.
# За Caddy/nginx на хосте: 127.0.0.1:8080 (как есть).
# Голый VPS без реверс-прокси: SITE_BIND=0.0.0.0 и SITE_PORT=80.
SITE_BIND=127.0.0.1
SITE_PORT=8080
+31
View File
@@ -0,0 +1,31 @@
# Секреты и живые данные не должны попадать в поставку (аудит F-1/F-4)
.env
*.env.local
# Локальные базы CRM: хэши паролей и сессионные токены (F-4)
crm/data/
*.db
*.sqlite
# Медиа владельца (фото позиций, обложки игр)
crm/media/
# Зависимости и сборки
node_modules/
site/dist/
__pycache__/
*.pyc
# Временные файлы
*.tmp
*.log
.DS_Store
# Dev-мусор и бэкапы исходников (аудит NF-09/NF-19)
*.bak*
crm/*.mjs
crm/_*.py
crm/i
# Результат make-distrib.ps1
distrib/
+13
View File
@@ -0,0 +1,13 @@
__pycache__
*.pyc
data/
migrate/
.dockerignore
Dockerfile
*.log
# NF-09/NF-19: dev-бэкапы, dev-скрипты и секреты не должны попадать в образ,
# даже если Dockerfile когда-нибудь начнёт копировать каталог целиком
*.bak*
*.mjs
.env
+28
View File
@@ -0,0 +1,28 @@
# NEON CRM — Python + PostgreSQL, без веб-фреймворков.
# NF-20: дайджест-пин базового образа (воспроизводимая сборка).
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py storage.py storage_sqlite.py notes_api.py ./
COPY seed/ ./seed/
COPY index.html ./
COPY css/ ./css/
COPY js/ ./js/
RUN groupadd --system neon && useradd --system --gid neon --create-home neon \
&& chown -R neon:neon /app
USER neon
EXPOSE 47613
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:47613/api/healthz', timeout=3).status == 200 else 1)"
CMD ["python", "server.py"]
+1645
View File
File diff suppressed because it is too large. Load diff
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>НЕОН CRM — панель управления</title>
<meta name="description" content="CRM кальянного лаунжа НЕОН: стоп-лист табаков и меню, управление заказами.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230A0A0F'/%3E%3Ctext x='32' y='45' font-family='Arial Black, Arial' font-size='34' font-weight='900' text-anchor='middle' fill='%2300F0FF'%3EН%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<!-- ЭКРАН ВХОДА -->
<section class="login" id="loginScreen">
<div class="glow glow-cyan"></div>
<div class="glow glow-purple"></div>
<div class="login-card">
<div class="login-logo">
<span class="logo-neon">Н</span>
<div>
<h1>НЕОН <em>CRM</em></h1>
<p>Панель управления лаунджем · Нягань</p>
</div>
</div>
<form id="loginForm" autocomplete="off">
<label class="field">
<span>Логин</span>
<input type="text" id="loginUser" placeholder="admin" value="" autocomplete="username">
</label>
<label class="field">
<span>Пароль</span>
<input type="password" id="loginPass" placeholder="••••••••" value="" autocomplete="current-password">
</label>
<button class="btn btn-neon btn-block" type="submit">Войти</button>
</form>
<p class="login-error hidden" id="loginError" role="alert"></p>
</div>
<p class="login-foot">neon-bar86.ru · внутренняя система</p>
</section>
<!-- ПРИЛОЖЕНИЕ -->
<div class="app hidden" id="app">
<aside class="sidebar">
<div class="side-top">
<div class="brand">
<span class="logo-neon">Н</span>
<div class="brand-txt">
<strong>НЕОН <em>CRM</em></strong>
<small id="brandRole">—</small>
</div>
</div>
<!-- карточка аккаунта перед списком вкладок: имя, роль и выход всегда сверху -->
<div class="user-card">
<span class="avatar" id="userAvatar">М</span>
<div>
<strong id="userName">—</strong>
<small id="userRole">—</small>
</div>
<button class="icon-btn" id="passwordBtn" title="Сменить пароль" type="button">🔑</button>
<button class="icon-btn" id="logoutBtn" title="Выйти" type="button">✕</button>
</div>
<nav class="nav" id="nav"></nav>
</div>
<div class="side-bottom">
<button class="btn btn-ghost btn-sm btn-block offline-retry hidden" id="offlineRetry" type="button">⟳ Переподключиться</button>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<h2 id="pageTitle">—</h2>
<p id="pageSub">—</p>
</div>
<div class="topbar-right">
<span class="shift" id="shift">—</span>
<span class="topbar-actions" id="shiftActions"></span>
<button class="icon-btn bell" id="bellBtn" title="Новые заказы" type="button">
🔔<span class="bell-count hidden" id="bellCount">0</span>
</button>
<span class="clock" id="clock">--:--</span>
</div>
</header>
<div class="view" id="viewRoot"></div>
</main>
<!-- автопрокрутка страницы: появляется, когда список не влезает в экран -->
<div class="scroll-nav hidden" id="scrollNav">
<button class="scroll-btn" id="scrollUp" title="Прокрутить вверх" type="button">▲</button>
<button class="scroll-btn" id="scrollDown" title="Прокрутить вниз" type="button">▼</button>
</div>
</div>
<div class="toast-wrap" id="toasts" role="status" aria-live="polite"></div>
<!-- модальные окна подтверждения и ввода: живут вне viewRoot, чтобы
перерисовка (опрос заказов каждые 10 с) не закрывала открытое окно -->
<div class="modal-overlay hidden" id="modalOverlay">
<div class="modal" id="modalBox" role="dialog" aria-modal="true"></div>
</div>
<!-- скрытый выбор фото позиции: живёт вне viewRoot, чтобы перерисовка
списка (опрос заказов каждые 10 с) не отваливала элемент во время
открытого диалога выбора файла -->
<input type="file" id="photoInput" accept="image/avif,image/jpeg,image/png,image/webp,image/gif" style="display:none">
<script src="js/app.js"></script>
</body>
</html>
+3870
View File
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
-- -*- coding: utf-8 -*-
-- ============================================================================
-- НЕОН CRM — доски заметок/задач (общая + приватные «владелец ↔ админ»).
--
-- Идемпотентна: повторный запуск не падает и не меняет данные.
-- CRM применяет этот же DDL автоматически при старте (SCHEMA в storage.py,
-- сид общей доски — boards_seed_shared()); файл нужен для ручного применения
-- и как канонический список объектов.
--
-- Применить вручную:
-- docker compose exec -T db psql -U neon -d neon_crm \
-- -v ON_ERROR_STOP=1 < crm/migrate/notes_boards.sql
--
-- Откат (теряются все заметки и история!):
-- docker compose exec -T db psql -U neon -d neon_crm \
-- -c "DROP TABLE IF EXISTS note_activity, notes, board_members, boards CASCADE"
--
-- Отступление от исходного ТЗ: пользователи в этой базе — crm_users с текстовым
-- PK login (целочисленного users.id не существует), поэтому все ссылки идут
-- на crm_users(login).
-- ============================================================================
CREATE TABLE IF NOT EXISTS boards (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('shared', 'private')),
title TEXT NOT NULL,
created_by_login TEXT NOT NULL REFERENCES crm_users(login),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- участники приватных досок; для kind='shared' не используется —
-- общая доска доступна всем ролям без проверки
CREATE TABLE IF NOT EXISTS board_members (
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
user_login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
PRIMARY KEY (board_id, user_login)
);
-- заметка = задача: заголовок, текст, исполнитель, срок, «важно», статус.
-- К сменам не привязана — доступна в любое время по факту логина.
-- Логины автора/исполнителя без FK (как orders.waiter в storage.py):
-- удаление сотрудника не должно ломать его задачи.
CREATE TABLE IF NOT EXISTS notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
author_login TEXT NOT NULL,
assignee_login TEXT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'done')),
important BOOLEAN NOT NULL DEFAULT FALSE,
due_date DATE,
done_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notes_board ON notes (board_id, status);
CREATE INDEX IF NOT EXISTS idx_notes_assignee ON notes (assignee_login) WHERE status <> 'done';
-- журнал действий: created/status/assigned/edited/deleted/board_created.
-- details: для assigned — логин исполнителя, для deleted — id заметки.
-- note_id REFERENCES ... ON DELETE SET NULL: сама запись переживает удаление
-- заметки, id удалённой сохраняется в details (для синхронизации клиентов).
-- user_login без FK — журнал переживает удаление сотрудника.
CREATE TABLE IF NOT EXISTS note_activity (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
note_id BIGINT REFERENCES notes(id) ON DELETE SET NULL,
user_login TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- одна общая доска на базу; владелец = первый по логину owner (база после
-- seed CRM уже содержит учётки). Повторный запуск ничего не добавляет.
INSERT INTO boards (kind, title, created_by_login)
SELECT 'shared', 'Общая', u.login
FROM crm_users u
WHERE u.role = 'owner'
AND NOT EXISTS (SELECT 1 FROM boards WHERE kind = 'shared')
ORDER BY u.login
LIMIT 1;
+227
View File
@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
"""
НЕОН CRM — доски заметок/задач (HTTP-хендлеры).
Маршруты (все требуют X-Auth-Token):
GET /api/boards — доступные доски
POST /api/boards — приватная доска «владелец ↔ админ»
PATCH /api/boards/<id> — переименовать (owner/admin)
DELETE /api/boards/<id> — удалить (только owner)
GET /api/boards/<id>/notes — заметки (?since= — только изменённые)
POST /api/boards/<id>/notes — создать заметку
GET /api/boards/<id>/activity — лента событий доски
PATCH /api/notes/<id> — правка заметки
DELETE /api/notes/<id> — удаление (owner/admin/автор)
Хендлеры собраны в NotesRoutes — миксин для Handler из server.py, чтобы
точка интеграции маршрутов была одной строкой на метод. Доступ к доске
проверяет storage.board_guard(): чужая приватная доска неотличима от
несуществующей — оба случая отдаются как 404 {"error": "not found"}.
"""
import re
from datetime import datetime
from urllib.parse import parse_qs, urlsplit
import storage
from storage import clean_guest_text # NF-14: staff-тексты без NUL/bidi-мусора
BOARD_ID_RE = re.compile(r"^/api/boards/(\d+)$")
BOARD_NOTES_RE = re.compile(r"^/api/boards/(\d+)/notes$")
BOARD_ACTIVITY_RE = re.compile(r"^/api/boards/(\d+)/activity$")
NOTE_ID_RE = re.compile(r"^/api/notes/(\d+)$")
NOTE_STATUSES = ("open", "in_progress", "done")
# роли с правами администратора — как ADMIN_ROLES в server.py
# (оттуда импортировать нельзя: циклический импорт)
ADMIN_ROLES = ("owner", "admin")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
TITLE_MAX = 200
BODY_MAX = 4000
ACTIVITY_LIMIT_MAX = 200
class NotesRoutes:
"""Хендлеры досок; подключаются к Handler в server.py."""
def _empty(self, code: int = 204):
self._drain_body() # NF-13: 204 без чтения тела ломал бы keep-alive
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
# ---------------- доски ----------------
def _handle_boards_get(self, user: dict):
self._json({"boards": storage.boards_list(user)})
def _handle_board_create(self, user: dict):
if user["role"] != "owner":
self._json({"error": "owner only"}, 403)
return
body = self._read_body() or {}
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
admin_login = str(body.get("admin_login") or "").strip()
if not title:
self._json({"error": "укажите название доски"}, 400)
return
users = {u["login"]: u for u in storage.list_users()}
admin = users.get(admin_login)
if admin is None or admin["role"] != "admin":
self._json({"error": "выберите администратора — участника доски"}, 400)
return
board = storage.board_create(title, admin_login, user["login"])
self._json({"board": board}, 201)
def _handle_board_patch(self, user: dict, board_id: int):
board = storage.board_guard(board_id, user)
if board is None:
self._json({"error": "not found"}, 404)
return
if user["role"] not in ADMIN_ROLES:
self._json({"error": "admin only"}, 403)
return
body = self._read_body() or {}
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
if not title:
self._json({"error": "укажите название доски"}, 400)
return
self._json({"board": storage.board_rename(board_id, title)})
def _handle_board_delete(self, user: dict, board_id: int):
board = storage.board_guard(board_id, user)
if board is None:
self._json({"error": "not found"}, 404)
return
if user["role"] != "owner":
self._json({"error": "owner only"}, 403)
return
if not storage.board_delete(board_id):
self._json({"error": "not found"}, 404)
return
self._json({"ok": True})
# ---------------- заметки ----------------
def _handle_board_notes_get(self, user: dict, board_id: int):
if storage.board_guard(board_id, user) is None:
self._json({"error": "not found"}, 404)
return
since = None
raw_since = (parse_qs(urlsplit(self.path).query).get("since") or [""])[0]
if raw_since:
# '+' в зоне вида +05:00 при разборе query превращается в пробел
raw_since = raw_since.replace(" ", "+")
try:
since = datetime.fromisoformat(raw_since)
except ValueError:
self._json({"error": "bad since"}, 400)
return
self._json(storage.notes_list(board_id, since))
def _handle_note_create(self, user: dict, board_id: int):
board = storage.board_guard(board_id, user)
if board is None:
self._json({"error": "not found"}, 404)
return
body = self._read_body() or {}
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
if not title:
self._json({"error": "укажите заголовок задачи"}, 400)
return
text = clean_guest_text(str(body.get("body") or ""), BODY_MAX)
important = bool(body.get("important"))
assignee = body.get("assignee")
if assignee is not None:
assignee = str(assignee).strip() or None
if assignee and not storage.note_assignee_ok(board, assignee):
self._json({"error": "исполнитель — не участник этой доски"}, 400)
return
due = None
if body.get("due_date"):
if not DATE_RE.match(str(body["due_date"])):
self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400)
return
due = body["due_date"]
note = storage.note_create(board, user, title, text, assignee, important, due)
self._json({"note": note}, 201)
def _handle_note_patch(self, user: dict, note_id: int):
note = storage.note_get(note_id)
board = storage.board_guard(note["board_id"], user) if note else None
if note is None or board is None:
self._json({"error": "not found"}, 404)
return
body = self._read_body() or {}
if not body:
self._json({"error": "нечего изменять"}, 400)
return
fields: dict = {}
if "title" in body:
title = clean_guest_text(str(body["title"] or ""), TITLE_MAX).strip()
if not title:
self._json({"error": "укажите заголовок задачи"}, 400)
return
fields["title"] = title
if "body" in body:
fields["body"] = clean_guest_text(str(body["body"] or ""), BODY_MAX)
if "important" in body:
if not isinstance(body["important"], bool):
self._json({"error": "important (bool) required"}, 400)
return
fields["important"] = body["important"]
if "status" in body:
if body["status"] not in NOTE_STATUSES:
self._json({"error": "статус: open, in_progress или done"}, 400)
return
fields["status"] = body["status"]
if "due_date" in body:
if body["due_date"] is None:
fields["due_date"] = None
elif DATE_RE.match(str(body["due_date"])):
fields["due_date"] = str(body["due_date"])
else:
self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400)
return
if "assignee" in body:
assignee = body["assignee"]
if assignee is not None:
assignee = str(assignee).strip() or None
if assignee and not storage.note_assignee_ok(board, assignee):
self._json({"error": "исполнитель — не участник этой доски"}, 400)
return
fields["assignee_login"] = assignee
updated = storage.note_update(note_id, user, board, fields)
if updated is None:
self._json({"error": "not found"}, 404)
return
self._json({"note": updated})
def _handle_note_delete(self, user: dict, note_id: int):
note = storage.note_get(note_id)
board = storage.board_guard(note["board_id"], user) if note else None
if note is None or board is None:
self._json({"error": "not found"}, 404)
return
if user["role"] not in ADMIN_ROLES and note["author"]["id"] != user["login"]:
self._json({"error": "удалять может автор, админ или владелец"}, 403)
return
if storage.note_delete(note_id, user["login"]) is None:
self._json({"error": "not found"}, 404)
return
self._empty(204)
# ---------------- журнал ----------------
def _handle_board_activity(self, user: dict, board_id: int):
if storage.board_guard(board_id, user) is None:
self._json({"error": "not found"}, 404)
return
qs = parse_qs(urlsplit(self.path).query)
try:
limit = min(ACTIVITY_LIMIT_MAX, max(1, int((qs.get("limit") or ["50"])[0])))
except ValueError:
limit = 50
self._json({"events": storage.board_activity(board_id, limit)})
+1
View File
@@ -0,0 +1 @@
psycopg[binary,pool]>=3.2,<4
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+238
View File
@@ -0,0 +1,238 @@
[
{
"seed_id": "fifa-25",
"kind": "ps5",
"title": "FIFA 25",
"note": "Футбольный симулятор · до 4 игроков",
"cover": "fifa25.jpg"
},
{
"seed_id": "ufc-5",
"kind": "ps5",
"title": "UFC 5",
"note": "Файтинг · до 2 игроков",
"cover": "ufc5.jpg"
},
{
"seed_id": "mortal-kombat-1",
"kind": "ps5",
"title": "Mortal Kombat 1",
"note": "Файтинг · до 2 игроков",
"cover": "mk1.jpg"
},
{
"seed_id": "move-or-die",
"kind": "ps5",
"title": "Move or Die",
"note": "Кооперативная аркада · до 4 игроков",
"cover": "mod.jpg"
},
{
"seed_id": "overcooked",
"kind": "ps5",
"title": "Overcooked",
"note": "Кооперативная аркада · до 4 игроков",
"cover": "over.jpg"
},
{
"seed_id": "god-of-war-ragnarok",
"kind": "ps5",
"title": "God of War Ragnarök",
"note": "Экшен-приключение · до 1 игрока",
"cover": "gowr.jpg"
},
{
"seed_id": "god-of-war",
"kind": "ps5",
"title": "God of War",
"note": "Экшен-приключение · до 1 игрока",
"cover": "gow.jpg"
},
{
"seed_id": "spider-man-remastered",
"kind": "ps5",
"title": "Marvel's Spider-Man Remastered",
"note": "Экшен-приключение · до 1 игрока",
"cover": "smr.jpg"
},
{
"seed_id": "board-mafia",
"kind": "board",
"title": "Мафия",
"note": "Компанейские · 6–16 игроков · 40–90 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "eye",
"accent": "#b026ff"
},
{
"seed_id": "board-bunker",
"kind": "board",
"title": "Бункер",
"note": "Компанейские · 4–16 игроков · 40–60 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "skull",
"accent": "#00f0ff"
},
{
"seed_id": "board-cards",
"kind": "board",
"title": "Игральные карты",
"note": "Карточные · 2–8 игроков · 10–60 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "spade",
"accent": "#ff4d6d"
},
{
"seed_id": "board-chess",
"kind": "board",
"title": "Шахматы",
"note": "Классика · 2–2 игроков · 20–60 мин · сложная",
"cover": null,
"difficulty": 3,
"icon": "crown",
"accent": "#e0e0e0"
},
{
"seed_id": "board-backgammon",
"kind": "board",
"title": "Нарды",
"note": "Классика · 2–2 игроков · 20–40 мин · средняя · хит",
"cover": null,
"difficulty": 2,
"icon": "dices",
"accent": "#f4a261"
},
{
"seed_id": "board-domino",
"kind": "board",
"title": "Домино",
"note": "Классика · 2–4 игроков · 15–30 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "grid",
"accent": "#ffd23d"
},
{
"seed_id": "board-checkers",
"kind": "board",
"title": "Шашки",
"note": "Классика · 2–2 игроков · 10–30 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "circle",
"accent": "#4dff88"
},
{
"seed_id": "board-uno",
"kind": "board",
"title": "UNO",
"note": "Карточные · 2–10 игроков · 15–30 мин · простая · хит",
"cover": null,
"difficulty": 1,
"icon": "hash",
"accent": "#ff3636"
},
{
"seed_id": "board-monopoly",
"kind": "board",
"title": "Монополия",
"note": "Стратегия · 2–6 игроков · 60–120 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "landmark",
"accent": "#2ec27e"
},
{
"seed_id": "board-jenga",
"kind": "board",
"title": "Дженга",
"note": "Компанейские · 2–8 игроков · 10–20 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "layout",
"accent": "#d4af37"
},
{
"seed_id": "board-alias",
"kind": "board",
"title": "Alias",
"note": "Компанейские · 4–12 игроков · 30–60 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "brain",
"accent": "#ff8a00"
},
{
"seed_id": "board-imaginarium",
"kind": "board",
"title": "Имаджинариум",
"note": "Компанейские · 4–7 игроков · 30–60 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "puzzle",
"accent": "#c77dff"
},
{
"seed_id": "board-crocodile",
"kind": "board",
"title": "Крокодил",
"note": "Компанейские · 4–16 игроков · 20–60 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "users",
"accent": "#00c2a8"
},
{
"seed_id": "board-poker",
"kind": "board",
"title": "Покерный набор",
"note": "Карточные · 2–9 игроков · 60–180 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "club",
"accent": "#1fa37a"
},
{
"seed_id": "board-exploding-kittens",
"kind": "board",
"title": "Взрывные котята",
"note": "Карточные · 2–5 игроков · 15 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "ghost",
"accent": "#ff6b9d"
},
{
"seed_id": "board-catan",
"kind": "board",
"title": "Колонизаторы (Catan)",
"note": "Стратегия · 3–4 игроков · 60–90 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "castle",
"accent": "#e9c46a"
},
{
"seed_id": "board-dixit",
"kind": "board",
"title": "Диксит",
"note": "Компанейские · 3–6 игроков · 30 мин · простая",
"cover": null,
"difficulty": 1,
"icon": "heart",
"accent": "#f77f00"
},
{
"seed_id": "board-codenames",
"kind": "board",
"title": "Кодовые имена",
"note": "На логику · 4–8 игроков · 15–30 мин · средняя",
"cover": null,
"difficulty": 2,
"icon": "target",
"accent": "#4895ef"
}
]
+359
View File
@@ -0,0 +1,359 @@
[
{
"id": "blackberry-mint",
"name": "Ежевика-мята",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "raspberry-mint",
"name": "Малина-мята",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "seabuckthorn-orange",
"name": "Облепиха-апельсин",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "seabuckthorn-cinnamon",
"name": "Облепиха-корица",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "forest-berries",
"name": "Лесные ягоды",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "mango-seabuckthorn",
"name": "Манго-облепиха",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "raspberry-ginger",
"name": "Малина-имбирь",
"category": "АВТОРСКИЙ ЧАЙ",
"price": 350,
"portion": "900 мл"
},
{
"id": "bud",
"name": "Bud",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,44 л"
},
{
"id": "kozel-dark",
"name": "Kozel темный",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "stary-melnik-light",
"name": "Старый мельник светлое",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "0,45 л"
},
{
"id": "krone-blanche",
"name": "Krone Blanche",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "erzmann",
"name": "Erzmann",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "el-capulco",
"name": "El Capulco",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,45 л"
},
{
"id": "stella",
"name": "Stella",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,44 л"
},
{
"id": "kozel-light",
"name": "Kozel светлый",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "stary-melnik-dark",
"name": "Старый мельник темное",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "1 шт"
},
{
"id": "pablos-lime",
"name": "Pablo's с соком лайма",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "1 шт"
},
{
"id": "redds",
"name": "Redd's",
"category": "ПИВНАЯ КАРТА",
"price": 200,
"portion": "1 шт"
},
{
"id": "spaten",
"name": "Spatten",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "0,45 л"
},
{
"id": "hoegaarden",
"name": "Hoegaarden",
"category": "ПИВНАЯ КАРТА",
"price": 220,
"portion": "0,45 л"
},
{
"id": "corona-extra",
"name": "Corona Extra",
"category": "ПИВНАЯ КАРТА",
"price": 250,
"portion": "1 шт"
},
{
"id": "onion-rings",
"name": "Луковые кольца",
"category": "ЗАКУСКИ",
"price": 220,
"portion": "8 шт"
},
{
"id": "fries",
"name": "Картофель Фри",
"category": "ЗАКУСКИ",
"price": 210,
"portion": "130 гр"
},
{
"id": "buffalo-wings",
"name": "Крылышки Баффало",
"category": "ЗАКУСКИ",
"price": 350,
"portion": "6 шт"
},
{
"id": "cheese-sticks",
"name": "Сырные палочки",
"category": "ЗАКУСКИ",
"price": 310,
"portion": "6 шт"
},
{
"id": "strips",
"name": "Стрипсы",
"category": "ЗАКУСКИ",
"price": 320,
"portion": "6 шт"
},
{
"id": "nuggets",
"name": "Нагетсы",
"category": "ЗАКУСКИ",
"price": 320,
"portion": "8 шт"
},
{
"id": "black-tea",
"name": "Черный чай",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "900 мл"
},
{
"id": "black-tea-thyme",
"name": "Черный чай с чабрецом",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "900 мл"
},
{
"id": "milk-oolong",
"name": "Молочный Улун",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 350,
"portion": "900 мл"
},
{
"id": "americano",
"name": "Американо",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 150,
"portion": "150 мл"
},
{
"id": "cappuccino",
"name": "Капучино",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 200,
"portion": "200 мл"
},
{
"id": "cheesecake-ny",
"name": "Чизкейк Нью-Йорк",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "red-velvet",
"name": "Торт Красный бархат",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "medovik",
"name": "Торт Медовик",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "cheesecake-strawberry",
"name": "Чизкейк Нью-Йорк клубничный",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "cheesecake-raspberry",
"name": "Чизкейк Нью-Йорк Малина",
"category": "НАПИТКИ И ДЕСЕРТЫ",
"price": 250,
"portion": "120 гр"
},
{
"id": "alpen-gold",
"name": "Alpen Gold",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 210,
"portion": null
},
{
"id": "milka",
"name": "Milka",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 230,
"portion": null
},
{
"id": "leys",
"name": "Чипсы Leys",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 210,
"portion": "90 гр"
},
{
"id": "korochki",
"name": "3 Корочки",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 150,
"portion": "60 гр"
},
{
"id": "hrustim",
"name": "Хрустим team",
"category": "СНЕКИ И СЛАДОСТИ",
"price": 100,
"portion": "40 гр"
},
{
"id": "natakhtari-pear",
"name": "НАТАХТАРИ ГРУША",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 200,
"portion": "1 шт"
},
{
"id": "strawberry-kiwi",
"name": "Клубника Киви",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 150,
"portion": "1 шт"
},
{
"id": "feijoa",
"name": "Фейхоа",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 150,
"portion": "1 шт"
},
{
"id": "natakhtari-feijoa",
"name": "НАТАХТАРИ ФЕЙХОА",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 200,
"portion": "1 шт"
},
{
"id": "natakhtari-saperavi",
"name": "НАТАХТАРИ САПЕРАВИ",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 200,
"portion": "1 шт"
},
{
"id": "burn-original",
"name": "BURN Оригинальный",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
},
{
"id": "adrenaline-rush",
"name": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
},
{
"id": "burn-apple-kiwi",
"name": "BURN Яблоко Киви",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
},
{
"id": "burn-mango",
"name": "BURN Со вкусом МАНГО",
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
"price": 250,
"portion": "1 шт"
}
]
+556
View File
@@ -0,0 +1,556 @@
[
{
"id": "showcase-1",
"name": "Кристальный лайм",
"note": "Лайм держит основу, Помело добавляет характер — свежесть до утра.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Цитрус"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-2",
"name": "Морозная клубника",
"note": "Клубника держит основу, Лайм добавляет характер — свежесть до утра.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-3",
"name": "Морозное помело",
"note": "Помело держит основу, Лайм добавляет характер — прохлада с первой затяжки.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-4",
"name": "Морозная клубника · с холодком",
"note": "Клубника держит основу, Лайм добавляет характер — прохлада с первой затяжки.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-5",
"name": "Морозная груша",
"note": "Груша держит основу, Яблоко добавляет характер — свежесть до утра.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Цитрус",
"Фрукты"
],
"price": "1 400 ₽",
"featured": true
},
{
"id": "showcase-6",
"name": "Кристальная клубника",
"note": "Клубника держит основу, Груша добавляет характер — ледяной и чистый финиш.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус",
"Фрукты"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-7",
"name": "Морозная груша · с холодком",
"note": "Груша держит основу, Клубника добавляет характер — свежесть до утра.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Цитрус",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-8",
"name": "Полярное яблоко",
"note": "Яблоко держит основу, Клубника добавляет характер — прохлада с первой затяжки.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Фрукты",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-9",
"name": "Полярная голубика",
"note": "Голубика держит основу, Фисташковое мороженое добавляет характер — свежесть до утра.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Десерты",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-10",
"name": "Кристальная медовая дыня",
"note": "Медовая дыня держит основу, Ультракислый лимон добавляет характер — свежесть до утра.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Фрукты",
"Цитрус"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-11",
"name": "Ледяной ультракислый лимон",
"note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — ледяной и чистый финиш.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Цитрус",
"Фрукты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-12",
"name": "Ледяная медовая дыня",
"note": "Медовая дыня держит основу, Сочный ананас добавляет характер — свежесть до утра.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Фрукты",
"Тропики",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-13",
"name": "Сливочная манго-ромашка",
"note": "Манго-ромашка держит основу, Помело добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Цитрус",
"Ягоды"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-14",
"name": "Сливочная клубника",
"note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-15",
"name": "Сливочная клубника · с холодком",
"note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-16",
"name": "Бархатная манго-ромашка",
"note": "Манго-ромашка держит основу, Лайм добавляет характер — мягкий десертный дым.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-17",
"name": "Бархатная клубника",
"note": "Клубника держит основу, Груша добавляет характер — мягкий десертный дым.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-18",
"name": "Вельветовое печенье",
"note": "Печенье держит основу, Яблоко добавляет характер — для долгих разговоров.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Десерты",
"Фрукты"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-19",
"name": "Вельветовое печенье · с холодком",
"note": "Печенье держит основу, Клубника добавляет характер — мягкий десертный дым.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Десерты",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-20",
"name": "Ночной шоколадный брауни",
"note": "Шоколадный брауни держит основу, Базилик добавляет характер — густое сладкое послевкусие.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Десерты",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-21",
"name": "Сливочный сочный ананас",
"note": "Сочный ананас держит основу, Медовая дыня добавляет характер — для долгих разговоров.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Фрукты",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-22",
"name": "Вельветовый шоколадный брауни",
"note": "Шоколадный брауни держит основу, Фисташковое мороженое добавляет характер — мягкий десертный дым.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-23",
"name": "Вельветовый банан",
"note": "Банан держит основу, Фисташковое мороженое добавляет характер — для долгих разговоров.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-24",
"name": "Солнечная манго-ромашка · клубника",
"note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-25",
"name": "Закатная манго-ромашка",
"note": "Манго-ромашка держит основу, Лайм добавляет характер — как закат у бара.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-26",
"name": "Тропическая манго-ромашка · клубника",
"note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Ягоды",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-27",
"name": "Тропическая манго-ромашка · помело",
"note": "Манго-ромашка держит основу, Помело добавляет характер — волна спелых фруктов.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-28",
"name": "Закатное яблоко",
"note": "Яблоко держит основу, Клубника добавляет характер — волна спелых фруктов.",
"strength": 3,
"cooling": 0,
"flavor_tags": [
"Фрукты",
"Ягоды"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-29",
"name": "Закатное яблоко · с холодком",
"note": "Яблоко держит основу, Груша добавляет характер — волна спелых фруктов.",
"strength": 3,
"cooling": 2,
"flavor_tags": [
"Фрукты",
"Цитрус",
"лёд"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-30",
"name": "Тропическое сочное манго",
"note": "Сочное манго держит основу, Базилик добавляет характер — волна спелых фруктов.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Пряности",
"Ягоды"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-31",
"name": "Спелое сочное манго",
"note": "Сочное манго держит основу, Медовая дыня добавляет характер — сочный тропический характер.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Тропики",
"Фрукты",
"Цитрус"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-32",
"name": "Спелый сочный ананас",
"note": "Сочный ананас держит основу, Фисташковое мороженое добавляет характер — сочный тропический характер.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-33",
"name": "Солнечное сочное манго",
"note": "Сочное манго держит основу, Фисташковое мороженое добавляет характер — как закат у бара.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Тропики",
"Десерты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-34",
"name": "Ягодная клубника",
"note": "Клубника держит основу, Лайм добавляет характер — сочная ягодная кислинка.",
"strength": 1,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Цитрус"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-35",
"name": "Полночный виноград",
"note": "Белый виноград держит основу, Голубика добавляет характер — сочная ягодная кислинка.",
"strength": 1,
"cooling": 2,
"flavor_tags": [
"Ягоды"
],
"price": "1 400 ₽",
"featured": false
},
{
"id": "showcase-36",
"name": "Ягодная голубика",
"note": "Голубика держит основу, Медовая дыня добавляет характер — спелые ягоды в каждом вдохе.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Фрукты"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-37",
"name": "Ягодный белый виноград",
"note": "Белый виноград держит основу, Сочный ананас добавляет характер — сочная ягодная кислинка.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Ягоды",
"Тропики"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-38",
"name": "Пурпурный белый виноград",
"note": "Белый виноград держит основу, Голубика добавляет характер — спелые ягоды в каждом вдохе.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-39",
"name": "Ягодная голубика · с холодком",
"note": "Голубика держит основу, Медовая дыня добавляет характер — яркая ягодная волна.",
"strength": 5,
"cooling": 2,
"flavor_tags": [
"Ягоды",
"Фрукты",
"лёд"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-40",
"name": "Искрящийся ультракислый лимон",
"note": "Ультракислый лимон держит основу, Базилик добавляет характер — звонкая кислинка на выдохе.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Цитрус",
"Пряности"
],
"price": "1 800 ₽",
"featured": false
},
{
"id": "showcase-41",
"name": "Искрящийся ультракислый лимон · медовая дыня",
"note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — бодрая цитрусовая искра.",
"strength": 5,
"cooling": 0,
"flavor_tags": [
"Цитрус",
"Фрукты",
"Ягоды"
],
"price": "1 800 ₽",
"featured": false
}
]
+706
View File
@@ -0,0 +1,706 @@
[
{
"id": "add-north",
"brand": "Добавка",
"flavor": "Север",
"note": "Бустер",
"strength": 1,
"cooling": true
},
{
"id": "add-cooling",
"brand": "Добавка",
"flavor": "Холодок",
"note": null,
"strength": 1,
"cooling": true
},
{
"id": "sarma-drunk-pear",
"brand": "Сарма",
"flavor": "Дачная груша",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "sarma-corn-paprika",
"brand": "Сарма",
"flavor": "Кукуруза-паприка",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "north-elderberry",
"brand": "Северный",
"flavor": "Бузина",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "north-feijoa",
"brand": "Северный",
"flavor": "Финдеперцовый фейхоа",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "north-flower",
"brand": "Северный",
"flavor": "Цветочный",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "north-sorbet",
"brand": "Северный",
"flavor": "Sorbet",
"note": "Фруктовый",
"strength": 3,
"cooling": false
},
{
"id": "hooligan-flash",
"brand": "Хулиган",
"flavor": "«Флеш»",
"note": "Медовая малина",
"strength": 2,
"cooling": false
},
{
"id": "hooligan-grapefruit",
"brand": "Хулиган",
"flavor": "Грейпфрутовый сок",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "wood-berry-ninja",
"brand": "Хулиган / WOOD",
"flavor": "Берри Ниндзя",
"note": "Лесные ягоды",
"strength": 2,
"cooling": false
},
{
"id": "wood-begemot",
"brand": "Хулиган / WOOD",
"flavor": "Begemot",
"note": "Бергамот-мандарин",
"strength": 2,
"cooling": false
},
{
"id": "wood-boo",
"brand": "Хулиган / WOOD",
"flavor": "Boo",
"note": "Яблоко-гранат",
"strength": 2,
"cooling": false
},
{
"id": "wood-old",
"brand": "Хулиган / WOOD",
"flavor": "Old",
"note": "Лимон с мёдом",
"strength": 2,
"cooling": false
},
{
"id": "wood-sir-hard",
"brand": "Хулиган / WOOD",
"flavor": "Sir Hard",
"note": "Воздушный рис",
"strength": 3,
"cooling": false
},
{
"id": "banger-black-white",
"brand": "Banger",
"flavor": "Black and White",
"note": "Печенье Oreo",
"strength": 2,
"cooling": false
},
{
"id": "banger-cola-bella",
"brand": "Banger",
"flavor": "Cola Bella",
"note": "Ванильная кола",
"strength": 2,
"cooling": false
},
{
"id": "banger-cream-soda",
"brand": "Banger",
"flavor": "Cream Soda",
"note": "Крем-сода",
"strength": 2,
"cooling": false
},
{
"id": "banger-grapefruit",
"brand": "Banger",
"flavor": "Grapefruit",
"note": "Грейпфрут",
"strength": 2,
"cooling": false
},
{
"id": "banger-green-day",
"brand": "Banger",
"flavor": "Green Day",
"note": "Яблоко, киви, кислинка",
"strength": 2,
"cooling": false
},
{
"id": "banger-sexy",
"brand": "Banger",
"flavor": "Sexy",
"note": "Грейпфрут, клубника, малина",
"strength": 2,
"cooling": false
},
{
"id": "bb-brownie",
"brand": "Blackburn",
"flavor": "Брауни",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-grape-lollipop",
"brand": "Blackburn",
"flavor": "Виноградный чупа-чупс",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-fir",
"brand": "Blackburn",
"flavor": "Ёлка",
"note": null,
"strength": 3,
"cooling": true
},
{
"id": "bb-cola",
"brand": "Blackburn",
"flavor": "Классическая кола",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-red-orange",
"brand": "Blackburn",
"flavor": "Красный апельсин",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-forest-raspberry",
"brand": "Blackburn",
"flavor": "Лесная малина",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-honey-melon",
"brand": "Blackburn",
"flavor": "Медовая дыня",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-juicy-mango",
"brand": "Blackburn",
"flavor": "Сочное манго",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-berry-sorbet",
"brand": "Blackburn",
"flavor": "Ягодный сорбет",
"note": "С грейпфрутом",
"strength": 3,
"cooling": false
},
{
"id": "bb-after-8",
"brand": "Blackburn",
"flavor": "After 8",
"note": "Шоколад-мята",
"strength": 3,
"cooling": true
},
{
"id": "bb-ananas-shock",
"brand": "Blackburn",
"flavor": "Ananas Shock",
"note": "Кислый ананас",
"strength": 3,
"cooling": false
},
{
"id": "bb-apple-shock",
"brand": "Blackburn",
"flavor": "Apple Shock",
"note": "Кислое зелёное яблоко",
"strength": 3,
"cooling": false
},
{
"id": "bb-bananini",
"brand": "Blackburn",
"flavor": "Bananini",
"note": "Банан",
"strength": 3,
"cooling": false
},
{
"id": "bb-basilic",
"brand": "Blackburn",
"flavor": "Basilic",
"note": "Базилик",
"strength": 3,
"cooling": false
},
{
"id": "bb-blackberry-lemonade",
"brand": "Blackburn",
"flavor": "Blackberry Lemonade",
"note": "Ежевичный лимонад",
"strength": 3,
"cooling": false
},
{
"id": "bb-blueberry",
"brand": "Blackburn",
"flavor": "Blueberry",
"note": "Голубика",
"strength": 3,
"cooling": false
},
{
"id": "bb-kiwi-stoner",
"brand": "Blackburn",
"flavor": "Kiwi Stoner",
"note": "Смузи из киви",
"strength": 3,
"cooling": false
},
{
"id": "bb-lemon-shock",
"brand": "Blackburn",
"flavor": "Lemon Shock",
"note": "Ультракислый лимон",
"strength": 3,
"cooling": false
},
{
"id": "bb-lulo",
"brand": "Blackburn",
"flavor": "Lulo",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-overdose",
"brand": "Blackburn",
"flavor": "Overdose",
"note": "Лимон-лайм",
"strength": 3,
"cooling": false
},
{
"id": "bb-pear-lemonade",
"brand": "Blackburn",
"flavor": "Pear Lemonade",
"note": "Грушевый лимонад",
"strength": 3,
"cooling": false
},
{
"id": "bb-pina-colada",
"brand": "Blackburn",
"flavor": "Pina Colada",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-pineapple",
"brand": "Blackburn",
"flavor": "Pineapple",
"note": "Сочный ананас",
"strength": 3,
"cooling": false
},
{
"id": "bb-pistachio-ice",
"brand": "Blackburn",
"flavor": "Pistachio Ice Snow",
"note": "Фисташковое мороженое",
"strength": 3,
"cooling": true
},
{
"id": "bb-pomelo",
"brand": "Blackburn",
"flavor": "Pomelo",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "bb-real-pf",
"brand": "Blackburn",
"flavor": "Real P.F.",
"note": "Маракуйя",
"strength": 3,
"cooling": false
},
{
"id": "bb-red-energy",
"brand": "Blackburn",
"flavor": "Red Energy",
"note": "Энергетик",
"strength": 3,
"cooling": false
},
{
"id": "bb-white-grape",
"brand": "Blackburn",
"flavor": "White Grape",
"note": "Белый виноград",
"strength": 3,
"cooling": false
},
{
"id": "bliss-strawberry",
"brand": "Bliss",
"flavor": "Клубника",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-lime",
"brand": "Bliss",
"flavor": "Лайм",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-mango-chamomile",
"brand": "Bliss",
"flavor": "Манго-ромашка",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-peach-yogurt",
"brand": "Bliss",
"flavor": "Персик-йогурт",
"note": null,
"strength": 1,
"cooling": false
},
{
"id": "bliss-love-shot",
"brand": "Bliss",
"flavor": "Love Shot",
"note": "Маракуйя, арбуз, лимон, холодок",
"strength": 1,
"cooling": true
},
{
"id": "chabacco-strawberry-milksake",
"brand": "Chabacco",
"flavor": "Mix",
"note": "Клубничный милкшейк",
"strength": 2,
"cooling": false
},
{
"id": "ds-alpine-trip",
"brand": "Darkside",
"flavor": "Алтайский трип",
"note": "Хвоя, фейхоа, эвкалипт",
"strength": 2,
"cooling": true
},
{
"id": "deus-base",
"brand": "DEUS",
"flavor": "Base",
"note": "Табачный лист",
"strength": 2,
"cooling": false
},
{
"id": "deus-blackberry-wine",
"brand": "DEUS",
"flavor": "Blackberry Wine",
"note": "Ежевичное вино",
"strength": 2,
"cooling": false
},
{
"id": "deus-blueberry-yogurt",
"brand": "DEUS",
"flavor": "Blueberry Yogurt",
"note": "Черничный йогурт",
"strength": 2,
"cooling": false
},
{
"id": "deus-champagne",
"brand": "DEUS",
"flavor": "Champagne",
"note": "Игристое вино",
"strength": 2,
"cooling": false
},
{
"id": "deus-cherry-jelly",
"brand": "DEUS",
"flavor": "Cherry Jelly",
"note": "Вишнёвое желе",
"strength": 2,
"cooling": false
},
{
"id": "deus-guinness",
"brand": "DEUS",
"flavor": "Guinness",
"note": "Тёмное пиво",
"strength": 2,
"cooling": false
},
{
"id": "deus-peonies",
"brand": "DEUS",
"flavor": "Peonies",
"note": "Пионы",
"strength": 2,
"cooling": false
},
{
"id": "deus-pumpkin-pie",
"brand": "DEUS",
"flavor": "Pumpkin Pie",
"note": "Тыквенный пирог с миндалём",
"strength": 2,
"cooling": false
},
{
"id": "deus-violet-zephyr",
"brand": "DEUS",
"flavor": "Violet Zephyr",
"note": "Фиалковый зефир",
"strength": 2,
"cooling": false
},
{
"id": "hupreme-guava",
"brand": "Hupreme",
"flavor": "Гуава",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "hupreme-gooseberry",
"brand": "Hupreme",
"flavor": "Крыжовник",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "hupreme-milk-tea",
"brand": "Hupreme",
"flavor": "Чай с молоком",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "jent-blue-agave",
"brand": "JENT Alcohol Line",
"flavor": "Blue Agave",
"note": "«Текила Бум»",
"strength": 2,
"cooling": false
},
{
"id": "jent-cuba-libre",
"brand": "JENT Alcohol Line",
"flavor": "Cuba Libre",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "jent-havana",
"brand": "JENT Alcohol Line",
"flavor": "Havana 290",
"note": "Мохито",
"strength": 2,
"cooling": true
},
{
"id": "joy-kiwichanga",
"brand": "Joy Kiwichanga",
"flavor": "Киви, фейхоа",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "muassel-cinnamon-gum",
"brand": "Muassel",
"flavor": "Жвачка с корицей",
"note": null,
"strength": 3,
"cooling": false
},
{
"id": "od-black-banano",
"brand": "Overdose",
"flavor": "Black Banano",
"note": "Амбровый банан",
"strength": 3,
"cooling": false
},
{
"id": "od-frustyle",
"brand": "Overdose",
"flavor": "Frustyle",
"note": "Кактус-лайм",
"strength": 3,
"cooling": false
},
{
"id": "od-gin-cucumber",
"brand": "Overdose",
"flavor": "Gin Cucumber",
"note": "Огуречный джин",
"strength": 3,
"cooling": false
},
{
"id": "od-lost-futura",
"brand": "Overdose",
"flavor": "Lost Futura",
"note": "Мускусная вишня",
"strength": 3,
"cooling": false
},
{
"id": "uc-pear-tarragon",
"brand": "Undercoal",
"flavor": "Груша, лимон и тархун",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cactus-kiwi",
"brand": "Undercoal",
"flavor": "Кактус, киви и абсент",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cranberry",
"brand": "Undercoal",
"flavor": "Клюква",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-coconut-shake",
"brand": "Undercoal",
"flavor": "Кокосовый шейк",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-mango-rose",
"brand": "Undercoal",
"flavor": "Манго, маракуйя, личи и роза",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-tropical-juice",
"brand": "Undercoal",
"flavor": "Тропический сок",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-milky-oolong",
"brand": "Undercoal",
"flavor": "Чай «Молочный улун»",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-caribbean-rum",
"brand": "Undercoal",
"flavor": "Caribbean Rum",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cinnamon-roll",
"brand": "Undercoal",
"flavor": "Cinnamon Roll",
"note": null,
"strength": 2,
"cooling": false
},
{
"id": "uc-cookie",
"brand": "Undercoal",
"flavor": "Cookie",
"note": "Печенье",
"strength": 2,
"cooling": false
},
{
"id": "uc-pinkman",
"brand": "Undercoal",
"flavor": "Pinkman",
"note": "Клубника, малина, грейпфрут",
"strength": 2,
"cooling": false
}
]
+2503
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
"""Разовая утилита: задать ВСЕМ действующим учёткам CRM один пароль.
Использование:
python set_all_passwords.py neon2026
Обновляет dev-базу SQLite (crm/data/crm.db) и, если контейнер neon-db
запущен, базу PostgreSQL в docker-стеке. Только для тестового стенда:
на живом сервере пароли меняют в панели (свой — кнопка 🔑, чужой —
владелец в разделе «Сотрудники»). После смены пароля в .env не забудь:
он действует только при ПЕРВОМ создании учётки.
"""
import hashlib
import secrets
import sqlite3
import subprocess
import sys
from pathlib import Path
def hash_password(password: str) -> str:
# тот же формат, что storage.hash_password: pbkdf2_sha256$iter$salt$hex
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000)
return f"pbkdf2_sha256$200000${salt}${digest.hex()}"
def main() -> None:
if len(sys.argv) != 2 or len(sys.argv[1]) < 8:
print("Использование: python set_all_passwords.py <пароль-минимум-8-символов>")
sys.exit(2)
password = sys.argv[1]
new_hash = hash_password(password)
# 1) dev-база SQLite рядом со скриптом
sqlite_path = Path(__file__).resolve().parent / "data" / "crm.db"
if sqlite_path.exists():
with sqlite3.connect(sqlite_path) as conn:
cur = conn.execute(
"UPDATE crm_users SET password_hash = ? WHERE NOT deleted", (new_hash,)
)
print(f"SQLite ({sqlite_path.name}): пароль изменён у {cur.rowcount} учёток")
else:
print(f"SQLite не найдена ({sqlite_path}) — пропускаю")
# 2) PostgreSQL в docker-стеке (контейнер neon-db)
sql = f"UPDATE crm_users SET password_hash = '{new_hash}' WHERE NOT deleted;"
try:
proc = subprocess.run(
["docker", "exec", "neon-db", "psql", "-U", "neon", "-d", "neon_crm", "-c", sql],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
print("docker или контейнер neon-db недоступен — postgres-базу не трогал")
return
if proc.returncode == 0:
print("PostgreSQL (neon-db): пароль изменён у всех действующих учёток")
else:
print(f"postgres UPDATE не прошёл:\n{proc.stderr.strip()}")
if __name__ == "__main__":
main()
+3164
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
name: neon-bar
# Три контейнера: сайт (nginx + статика), CRM (Python API), PostgreSQL.
# Сеть внутренняя: наружу смотрит только сайт; CRM отдаёт ему /api/.
services:
db:
# NF-20: дайджест-пин базового образа — пересборка в другой день даёт
# те же слои (обновление пина — осознанное действие: новый digest + тест)
image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
container_name: neon-db
restart: unless-stopped
stop_grace_period: 10s
mem_limit: 512m
logging: &logging
driver: json-file
options: { max-size: "10m", max-file: "3" }
environment:
# Пароли БЕЗ дефолтов (F-1/F-2): compose упадёт с понятной ошибкой,
# если .env не задан — дефолтные креды в контейнер не попадут никогда.
POSTGRES_DB: ${PG_DB:?Задайте PG_DB в .env}
POSTGRES_USER: ${PG_USER:?Задайте PG_USER в .env}
POSTGRES_PASSWORD: ${PG_PASSWORD:?Задайте PG_PASSWORD в .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
interval: 5s
timeout: 3s
retries: 20
crm:
build: ./crm
container_name: neon-crm
restart: unless-stopped
stop_grace_period: 10s
mem_limit: 256m
logging: *logging
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${PG_USER}:${PG_PASSWORD}@db:5432/${PG_DB}
CRM_PORT: "47613"
# расписание смен, границы суток в отчётах и «новинках» считаются
# по барному времени (фиксированный UTC+5) — TZ важен для SQL-окон
TZ: ${CRM_TZ:-Asia/Yekaterinburg}
CRM_OWNER_LOGIN: ${CRM_OWNER_LOGIN:-owner}
# Пароли только из .env (F-2): дефолтных neon2026 больше не существует.
# Первый старт создаёт учётки с этими паролями; смена .env на живой базе
# не действует — каждому сотруднику меняет пароль в панели (кнопка 🔑).
CRM_OWNER_PASSWORD: ${CRM_OWNER_PASSWORD:?Задайте CRM_OWNER_PASSWORD в .env}
CRM_OWNER_NAME: ${CRM_OWNER_NAME:-Нурлан}
CRM_ADMIN_LOGIN: ${CRM_ADMIN_LOGIN:-admin}
CRM_ADMIN_PASSWORD: ${CRM_ADMIN_PASSWORD:?Задайте CRM_ADMIN_PASSWORD в .env}
CRM_ADMIN_NAME: ${CRM_ADMIN_NAME:-Ирина}
CRM_WAITER_LOGIN: ${CRM_WAITER_LOGIN:-waiter.mark}
CRM_WAITER_PASSWORD: ${CRM_WAITER_PASSWORD:?Задайте CRM_WAITER_PASSWORD в .env}
CRM_WAITER_NAME: ${CRM_WAITER_NAME:-Марк}
CRM_SEED_DEMO_ORDERS: ${CRM_SEED_DEMO_ORDERS:-0}
# 1 — разрешить старт с дефолтными паролями (только dev-стенд!)
CRM_ALLOW_DEFAULT_PASSWORDS: ${CRM_ALLOW_DEFAULT_PASSWORDS:-0}
# Публикуем только на localhost: посетителям CRM не нужна,
# а админ может зайти с сервера через SSH-туннель при желании.
ports:
- "127.0.0.1:47613:47613"
volumes:
# фото позиций, загружаемые владельцем, переживают пересборку
- ./crm/media:/app/media
site:
build: ./site
container_name: neon-site
restart: unless-stopped
stop_grace_period: 10s
mem_limit: 64m
logging: *logging
depends_on:
- crm
ports:
- "${SITE_BIND:-127.0.0.1}:${SITE_PORT:-8080}:80"
volumes:
pgdata:
# NF-10: фиксированная подсеть сети compose — site/nginx.conf доверяет
# X-Forwarded-For только ей и 127.0.0.1 (set_real_ip_from). Меняете здесь —
# меняйте и там.
networks:
default:
ipam:
config:
- subnet: 172.18.0.0/16
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.dockerignore
Dockerfile
.git
*.log
+65
View File
@@ -0,0 +1,65 @@
# Аудит и изменения проекта НЕОН
## Что проверено
- Исходная структура проекта, `package.json`, TypeScript-конфигурация и Vite-конфигурация.
- Страница меню и все три набора данных: авторский чай, пивная карта, барное меню.
- Все 57 файлов в `public/menu` и `public/menu/manifest.json`.
- Поиск по проекту на предмет старого реестра изображений и неиспользуемых ссылок.
- TypeScript-проверка и самотест движка миксов.
## Что изменено
### Фотографии меню
Создан единый `src/data/menuPhotos.ts` с привязкой по `id` позиции меню.
- 51 из 51 текущих позиций меню имеют привязанные фотографии.
- Все 51 файла реально существуют в `public/menu`.
- Для авторского чая использованы соответствующие изображения из того же набора; названия файлов исторически отличаются от текущих названий позиций.
- 6 файлов из `public/menu` намеренно не привязаны: это изображения, которым в текущем каталоге нет соответствующей позиции (`photo`, два кальяна и три прочих позиции/сборы).
Старый `src/data/menuImages.ts` удалён: он ссылался на несуществующие пути `images/tea` и `images/beer` и содержал неиспользуемые fallback-цепочки.
### Карточки меню
- Фото добавлены непосредственно в существующие карточки без смены базовой стилистики.
- Добавлена lazy-loading загрузка изображений.
- При ошибке загрузки карточка автоматически возвращается к исходной иконке.
- Кнопка `В корзину` интегрирована в существующую карточку.
### Корзина и управление количеством
`src/components/MenuCart.tsx` и `src/pages/MenuPage.tsx` получили косметический рефакторинг UX.
- На карточке позиции вместо однонаправленной кнопки теперь используется `В корзину`, а после добавления — полноценный степпер `− / количество / +`.
- Уменьшение количества из меню сразу действует на выбранного гостя и удаляет позицию при снижении количества до нуля.
- Выбор активного гостя в меню переведён с выпадающего списка на компактные переключатели.
- В корзине количество гостей задаётся степпером 1–12, без громоздкого select.
- Активный гость в корзине выбирается отдельными кнопками; новые позиции из меню попадают выбранному гостю.
- Позиции по-прежнему группируются по гостям, но карточки стали компактнее и понятнее; перераспределение позиции между гостями сохранено.
- Итоговая сумма, копирование заказа, очистка, `Esc`, блокировка прокрутки и хранение в `localStorage` сохранены.
- Заказ никуда автоматически не отправляется — это рабочий список для оформления у официанта.
### Очистка проекта
- Из моделей `teaMenu`, `beerMenu`, `barMenu` убраны старые поля/импорты изображений, которые больше не нужны.
- Удалена дублирующаяся папка `public/images/menu`: приложение её не использовало, а сами актуальные фото находятся в `public/menu`.
- Контейнерный Caddy удалён: Caddy работает на уровне хоста.
- Docker-инфраструктура приложения возвращена отдельным `docker-compose.yml` + `Dockerfile`; HTTPS и доменная маршрутизация остаются на хостовом Caddy.
- В контейнере используется nginx только как HTTP-сервер для собранного Vite `dist`.
- `src/index.css`, `package.json` и `package-lock.json` не изменялись.
## Проверка
- Статически проверены изменённые файлы и связи между карточками меню, активным гостем и корзиной.
- `docker-compose.yml` и `Dockerfile` физически присутствуют в финальном архиве.
- Полный `vite build` в этой среде не удалось выполнить: пакетные зависимости из `package-lock.json` не удалось восстановить через npm из-за недоступного registry/cache-пакета, поэтому результат сборки здесь не выдаётся за проверенный.
- Финальный архив не содержит `node_modules` и `dist`; зависимости и production-сборка должны создаваться в целевой среде.
## Docker runtime
- `docker-compose.yml` restored for the frontend container; Caddy is not containerized.
- `Dockerfile` restored as a multi-stage build: Node builds the Vite app, nginx serves the resulting static files.
- The app is published only on `127.0.0.1:8080`, so host-level Caddy can reverse-proxy to `http://127.0.0.1:8080` without exposing the app directly to the network.
- `nginx.conf` contains SPA fallback and a `/healthz` endpoint.
- `.dockerignore` excludes build/dependency artifacts from the Docker build context.
+23
View File
@@ -0,0 +1,23 @@
# Build the Vite app in Node, then serve the static bundle with a minimal web server.
# NF-20: дайджест-пины базовых образов (воспроизводимая сборка).
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --spider --quiet http://127.0.0.1/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="НЕОН — кальянный лаунж в Нягани. Авторские миксы, PlayStation 5, авторский чай и кофе. Атмосфера, которую видно в темноте."
/>
<meta name="theme-color" content="#0a0a0f" />
<title>НЕОН — кальянный лаунж · Атмосфера, которую видно в темноте</title>
<link rel="canonical" href="https://neon-bar86.ru/" />
<!-- Open Graph: превью в соцсетях и мессенджерах -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="НЕОН — кальянный лаунж" />
<meta property="og:title" content="НЕОН — кальянный лаунж в Нягани" />
<meta
property="og:description"
content="Авторские миксы, PlayStation 5, авторский чай и кофе. Проспект Нефтяников, 15. Бронь: +7 (912) 901-72-17."
/>
<meta property="og:url" content="https://neon-bar86.ru/" />
<meta property="og:image" content="https://neon-bar86.ru/images/og-cover.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="ru_RU" />
<meta name="twitter:card" content="summary_large_image" />
<!-- JSON-LD: карточка организации для Яндекс/Google/Bing.
BarOrPub — подтип LocalBusiness (DrinkingEstablishment): для лаунжа
точнее ресторанных типов, все локальные поля наследуются. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BarOrPub",
"name": "НЕОН — кальянный лаунж",
"url": "https://neon-bar86.ru/",
"telephone": "+7 912 901-72-17",
"image": "https://neon-bar86.ru/images/og-cover.jpg",
"description": "Кальянный лаунж-бар НЕОН в Нягани: авторские кальяны и миксы, PlayStation 5, настольные игры, авторский чай и кофе. Бронь столов и аренда VIP-зала.",
"priceRange": "₽₽",
"currenciesAccepted": "RUB",
"address": {
"@type": "PostalAddress",
"streetAddress": "проспект Нефтяников, 15",
"addressLocality": "Нягань",
"addressRegion": "Ханты-Мансийский автономный округ — Югра",
"addressCountry": "RU"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "[ВПИСАТЬ: широта, напр. 60.9449]",
"longitude": "[ВПИСАТЬ: долгота, напр. 65.6510]"
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Tuesday", "Wednesday", "Thursday", "Sunday"],
"opens": "19:00",
"closes": "01:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Friday", "Saturday"],
"opens": "19:00",
"closes": "03:00"
}
],
"sameAs": ["https://vk.ru/shelby_nyagan"]
}
</script>
<!-- Верификация вебмастер-панелей: раскомментируй строку и впиши код,
полученный в панели (какой код откуда — см. SEO-REGISTRATIONS.md).
До активации панель верификацию не пройдёт — это ожидаемо. -->
<!-- <meta name="yandex-verification" content="[ВПИСАТЬ_КОД_ЯНДЕКС]" /> -->
<!-- <meta name="google-site-verification" content="[ВПИСАТЬ_КОД_GOOGLE]" /> -->
<!-- <meta name="msvalidate.01" content="[ВПИСАТЬ_КОД_BING]" /> -->
<!-- <meta name="mailru-verification" content="[ВПИСАТЬ_КОД_MAILRU]" /> -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<!-- Favicon реальными файлами, а не data-URI: Яндекс и Google забирают
иконку по адресу /favicon.ico (data-URI поисковики не читают —
в выдаче вместо иконки серый глобус). Файлы генерит
scripts/make-favicon.py, лежат в public/ и попадают в корень сборки. -->
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head>
<body>
<!-- Статический H1 + SEO-абзац: виден поисковику и гостю без JS.
React (createRoot) полностью заменяет содержимое #root при
монтировании, поэтому на живой странице блока не будет.
Маркеры SEO:FALLBACK_* нужны scripts/prerender.tsx: между ними
в собранный index.html вставляется пререндер-снапшот лендинга. -->
<div id="root"><!--SEO:FALLBACK_START-->
<section
style="max-width: 52rem; margin: 0 auto; padding: 96px 24px; font-family: system-ui, sans-serif; color: #f2f3f8; background: #0a0a0f; min-height: 100vh"
>
<h1>Кальянный лаунж НЕОН в Нягани</h1>
<p>
Кальянный лаунж-бар НЕОН (Нягань, проспект Нефтяников, 15) — авторские
миксы и кальяны, PlayStation 5, настольные игры, авторский чай и кофе.
Бронь столов и аренда VIP-зала: +7 (912) 901-72-17. Часы работы:
вт–чт и вс 19:00–01:00, пт–сб 19:00–03:00, понедельник — выходной.
</p>
<noscript>
<p>Для интерактивного меню и заказа включите JavaScript. Телефон: +7 (912) 901-72-17.</p>
</noscript>
</section>
<!--SEO:FALLBACK_END--></div>
<!-- Счётчики аналитики: раскомментируй после получения идентификатора.
Яндекс.Метрика: metrika.yandex.ru → создать счётчик → заменить
[ВПИСАТЬ_ИД_МЕТРИКИ] на номер счётчика (число). Не добавляй два
одинаковых счётчика: один сайт = один код на страницу.
<script type="text/javascript">
(function (m, e, t, r, i, k, a) {
m[i] = m[i] || function () { (m[i].a = m[i].a || []).push(arguments); };
m[i].l = 1 * new Date();
for (var j = 0; j < document.scripts.length; j++) { if (document.scripts[j].src === r) { return; } }
k = e.createElement(t), a = e.getElementsByTagName(t)[0], k.async = 1, k.src = r, a.parentNode.insertBefore(k, a);
})(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym([ВПИСАТЬ_ИД_МЕТРИКИ], "init", { clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true });
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/[ВПИСАТЬ_ИД_МЕТРИКИ]" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
-->
<!-- Google Analytics 4: analytics.google.com → Администратор → Потоки
данных → Идентификатор G-XXXXXXX. Вставить вместо [ВПИСАТЬ_ИД_GA4].
<script async src="https://www.googletagmanager.com/gtag/js?id=[ВПИСАТЬ_ИД_GA4]"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "[ВПИСАТЬ_ИД_GA4]");
</script>
-->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
# Брауз-тормоз на флуд заказами: с одного IP не чаще 10 заказов в минуту
# (burst 10 сглаживает «двойной клик»). Работает ДО запроса в CRM.
limit_req_zone $binary_remote_addr zone=orders_zone:1m rate=10r/m;
# Общий тормоз на прочее /api/ (NF-04): флуд catalog/stoplist выедал пул БД
# CRM. Легитимный сайт опрашивает эти эндпоинты раз в минуту-десять,
# 120r/m с burst 60 — с большим запасом. Панель персонала ходит на CRM
# напрямую (127.0.0.1:47613) и этот лимит не замечает.
limit_req_zone $binary_remote_addr zone=api_zone:1m rate=120r/m;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Реальный IP гостя для лимитеров CRM. Доверие к X-Forwarded-For —
# только 127.0.0.1 и docker-подсеть compose (NF-10): раньше верили всем
# RFC1918-пирам, и LAN-клиент при экспоузе сайта мог вращать per-IP
# лимитеры подделкой заголовка. Подсеть зафиксирована в
# docker-compose.yml (networks.default.ipam) — при смене её там
# обновите и здесь.
set_real_ip_from 127.0.0.1;
set_real_ip_from 172.18.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# single-file сборка (~750 KB html) без gzip = лишний трафик гостям
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
server_tokens off;
# Базовые заголовки безопасности. Внимание nginx: location со своим
# add_header (например /media/) НЕ наследует их — там заголовки повторены.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# CSP: бандл инлайнен в single-file index.html (поэтому 'unsafe-inline' у
# script-src), плюс Метрика/GA и Google Fonts. object/base/frame-ancestors
# наглухо закрыты — внедрённый в страницу тег не подгрузит и не отправит.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
# Заказы и стоп-лист живут в CRM-контейнере (см. docker-compose.yml).
# Точный location = /api/orders ниже переопределяет этот для POST заказов
# со своей зоной (приоритет nginx: точный бьёт префиксный).
location /api/ {
limit_req zone=api_zone burst=60 nodelay;
limit_req_status 429;
proxy_pass http://crm:47613;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
# Точка создания заказа — отдельный тормоз поверх лимитеров CRM
# (nginx отвечает 429 раньше, чем запрос дойдёт до Python).
location = /api/orders {
limit_req zone=orders_zone burst=10 nodelay;
limit_req_status 429;
proxy_pass http://crm:47613;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
# Входная точка SPA: ТОЛЬКО no-cache — в single-file сборке внутри
# index.html инлайнен весь бандл, закэшировать его на 7 дней = гости
# не увидят обновлений сайта до конца срока кэша. (Точное совпадение
# бьёт регэксп-локейшен расширений ниже; прочие .html честно 404.)
location = /index.html {
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
}
# SEO-файлы: точный Content-Type и кэш не больше часа
# (types {} сбрасывает mime.types — иначе .xml ушёл бы как text/xml)
location = /robots.txt {
types { }
default_type text/plain;
expires 1h;
}
location = /sitemap.xml {
types { }
default_type application/xml;
expires 1h;
}
# Загруженные владельцем фото позиций — из CRM (/media/<id>.<ext>).
# ^~ обязателен: без него регэксп-локейшен расширений (*.jpg, *.avif…)
# перехватывал бы запросы, и все фото отдавали 404 вместо прокси в CRM.
location ^~ /media/ {
proxy_pass http://crm:47613;
proxy_http_version 1.1;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, max-age=604800";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
}
# Vite SPA fallback: client-side routes should resolve to index.html.
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 'ok\n';
}
# Файлы с расширениями отдаются только с диска: несуществующие — честный
# 404, а не index.html (soft-404 вреден для поисковиков). SPA-fallback
# выше работает только для путей без расширения.
location ~* \.(?:css|js|mjs|cjs|json|map|webmanifest|txt|xml|svg|ico|png|jpg|jpeg|gif|webp|avif|jfif|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|ogg|pdf|html)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
try_files $uri =404;
}
}
+2655
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "react-vite-tailwind",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && tsx scripts/prerender.tsx",
"prerender": "tsx scripts/prerender.tsx",
"preview": "vite preview",
"export:crm": "tsx scripts/export-crm-seed.ts",
"test:engine": "tsx scripts/engine-selftest.ts"
},
"dependencies": {
"clsx": "2.1.1",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.34.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.17",
"@types/node": "22.19.17",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"tailwindcss": "4.1.17",
"tsx": "^4.23.15",
"typescript": "5.9.3",
"vite": "7.3.7",
"vite-plugin-singlefile": "2.3.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='#0A0A0F'/><text x='32' y='45' font-family='Arial Black, Arial' font-size='34' font-weight='900' text-anchor='middle' fill='#00F0FF'>Н</text></svg>

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+287
View File
@@ -0,0 +1,287 @@
[
{
"file": "menu/photo.avif",
"title": "photo",
"source": "https://media.dodostatic.com/image/11eeb86b25350d0eadad0aec31155793.avif"
},
{
"file": "menu/kalyan-lyuboy-kreposti.avif",
"title": "Кальян любой крепости",
"source": "https://media.dodostatic.com/image/019c413a46397891b34db8e49083915b.avif"
},
{
"file": "menu/retro-kalyan-khalil-mamoon.avif",
"title": "Ретро кальян: khalil mamoon",
"source": "https://media.dodostatic.com/image/019c4813920f7666900e24d50a0a9ea2.avif"
},
{
"file": "menu/pryanyy-apelsin.avif",
"title": "Пряный апельсин",
"source": "https://media.dodostatic.com/image/019c413cfc6071fea8a2c4ec8b641fb9.avif"
},
{
"file": "menu/myatnaya-ezhevika.avif",
"title": "Мятная ежевика",
"source": "https://media.dodostatic.com/image/019c2b9f74757209a2aa263b65df27e5.avif"
},
{
"file": "menu/malinovyy-s-imbirem.avif",
"title": "Малиновый с имбирем",
"source": "https://media.dodostatic.com/image/019c2b9fdc00768ba05ff316f8d622cd.avif"
},
{
"file": "menu/myatnaya-malina.avif",
"title": "Мятная малина",
"source": "https://media.dodostatic.com/image/019c2f32f55071f0ba37559a8e3b0206.avif"
},
{
"file": "menu/osennyaya-oblepikha.avif",
"title": "Осенняя облепиха",
"source": "https://media.dodostatic.com/image/019c281096fe7710809269443a7225fd.avif"
},
{
"file": "menu/yagodnyy-vzryv.avif",
"title": "Ягодный взрыв",
"source": "https://media.dodostatic.com/image/019c20ad560979c1b2074a1e1acf60a3.avif"
},
{
"file": "menu/vishnevyy-sad.avif",
"title": "Вишневый сад",
"source": "https://media.dodostatic.com/image/019c2f31bcf875acbceb7a6f61ae0194.avif"
},
{
"file": "menu/mangovyy-ray.avif",
"title": "Манговый рай",
"source": "https://media.dodostatic.com/image/019c41528cfc78dd81f72f40ce843aae.avif"
},
{
"file": "menu/staryy-melnik-svetloe.avif",
"title": "Старый мельник светлое",
"source": "https://media.dodostatic.com/image/019c20aedd9d7883abc396bff921d760.avif"
},
{
"file": "menu/staryy-melnik-temnoe.avif",
"title": "Старый мельник темное",
"source": "https://media.dodostatic.com/image/019c20af48177999b63855e09d42230e.avif"
},
{
"file": "menu/kozel-svetlyy.avif",
"title": "Kozel светлый",
"source": "https://media.dodostatic.com/image/019c20afbec970edb4dab7fcbe931230.avif"
},
{
"file": "menu/kozel-temnyy.avif",
"title": "Kozel темный",
"source": "https://media.dodostatic.com/image/019c20b0aed478129986b27612ce598b.avif"
},
{
"file": "menu/bud.avif",
"title": "Bud",
"source": "https://media.dodostatic.com/image/019c20b1083d72cc939ad919afe83989.avif"
},
{
"file": "menu/stella.avif",
"title": "Stella",
"source": "https://media.dodostatic.com/image/019c20ae14e3769b9cef85f063c0ea80.avif"
},
{
"file": "menu/krone-blanche.avif",
"title": "Krone Blanche",
"source": "https://media.dodostatic.com/image/019c20b1705470b699f9622d41cbbe21.avif"
},
{
"file": "menu/erzmann.avif",
"title": "Erzmann",
"source": "https://media.dodostatic.com/image/019c5205b1fb7623b40922be846d1be5.avif"
},
{
"file": "menu/el-capulco.avif",
"title": "El Capulco",
"source": "https://media.dodostatic.com/image/019c21d0b739773e812f5335fd98e5c5.avif"
},
{
"file": "menu/spatten.avif",
"title": "Spatten",
"source": "https://media.dodostatic.com/image/019c20b2fa6a72fca73c653d52e9d1c6.avif"
},
{
"file": "menu/hoegaarden.avif",
"title": "Hoegaarden",
"source": "https://media.dodostatic.com/image/019c20b37d0278ef88419f1879912261.avif"
},
{
"file": "menu/corona-extra.avif",
"title": "Corona Extra",
"source": "https://media.dodostatic.com/image/019c20b3d8677935bd60a789260a7144.avif"
},
{
"file": "menu/redd-s.avif",
"title": "Redd's",
"source": "https://media.dodostatic.com/image/019c20b66c7a7948a337569742166027.avif"
},
{
"file": "menu/pablo-s-s-sokom-layma.avif",
"title": "Pablo's с соком лайма",
"source": "https://media.dodostatic.com/image/019c388bd52575f5841f61a9d7b324a7.avif"
},
{
"file": "menu/lukovye-koltsa.avif",
"title": "Луковые кольца",
"source": "https://media.dodostatic.com/image/019c5cf1ac96718980e6e71591fc11d7.avif"
},
{
"file": "menu/kartofel-fri.avif",
"title": "Картофель Фри",
"source": "https://media.dodostatic.com/image/019c41687d447256a1d04a957f905d4d.avif"
},
{
"file": "menu/krylyshki-baffalo.avif",
"title": "Крылышки Баффало",
"source": "https://media.dodostatic.com/image/019c48143b66725cb9a3680801523673.avif"
},
{
"file": "menu/syrnye-palochki.avif",
"title": "Сырные палочки",
"source": "https://media.dodostatic.com/image/019c48148e8073bab97dc8caa6100c9b.avif"
},
{
"file": "menu/stripsy.avif",
"title": "Стрипсы",
"source": "https://media.dodostatic.com/image/019c4814f0ed7520802b4f729d2236ee.avif"
},
{
"file": "menu/nagetsy.avif",
"title": "Нагетсы",
"source": "https://media.dodostatic.com/image/019c4815a75175099263d2b610c0ba71.avif"
},
{
"file": "menu/chernyy-chay.avif",
"title": "Черный чай",
"source": "https://media.dodostatic.com/image/019c20b4459874dab1f68c8e2dab7bab.avif"
},
{
"file": "menu/chernyy-chay-s-chabretsom.avif",
"title": "Черный чай с чабрецом",
"source": "https://media.dodostatic.com/image/019c4816ce22743ea114cce7cba1c18d.avif"
},
{
"file": "menu/molochnyy-ulun.avif",
"title": "Молочный Улун",
"source": "https://media.dodostatic.com/image/019c20b4bcbb729d865667412da59cea.avif"
},
{
"file": "menu/amerikano.avif",
"title": "Американо",
"source": "https://media.dodostatic.com/image/019c9f5ef3d2785a9728fdf2e30663a3.avif"
},
{
"file": "menu/kapuchino.avif",
"title": "Капучино",
"source": "https://media.dodostatic.com/image/019c9f60418470678bccabd479e205fd.avif"
},
{
"file": "menu/chizkeyk-nyu-york.avif",
"title": "Чизкейк Нью-Йорк",
"source": "https://media.dodostatic.com/image/019c28113de37748aaf16491fc5cb061.avif"
},
{
"file": "menu/chizkeyk-nyu-york-klubnichnyy.avif",
"title": "Чизкейк Нью-Йорк клубничный",
"source": "https://media.dodostatic.com/image/019c335d507571838b4855a5f5e6c79a.avif"
},
{
"file": "menu/chizkeyk-nyu-york-malina.avif",
"title": "Чизкейк Нью-Йорк Малина",
"source": "https://media.dodostatic.com/image/019c2811f09e70d6b6bff47e85e69808.avif"
},
{
"file": "menu/tort-krasnyy-barkhat.avif",
"title": "Торт Красный бархат",
"source": "https://media.dodostatic.com/image/019c20b57c56743896b879e290de3a9a.avif"
},
{
"file": "menu/tort-medovik.avif",
"title": "Торт Медовик",
"source": "https://media.dodostatic.com/image/019c20b5ef56790ebeb1f747e79d02f5.avif"
},
{
"file": "menu/alpen-gold.avif",
"title": "Alpen gold",
"source": "https://media.dodostatic.com/image/019c5cf23ec977548b241b9ac6c14df5.avif"
},
{
"file": "menu/milka.avif",
"title": "Milka",
"source": "https://media.dodostatic.com/image/019c5cf2aa3f71eeb997c74b4cea9f9d.avif"
},
{
"file": "menu/chipsy-leys.avif",
"title": "Чипсы Leys",
"source": "https://media.dodostatic.com/image/019d61b7f4247566a6e64be7736f3389.avif"
},
{
"file": "menu/3-korochki.avif",
"title": "3 Корочки",
"source": "https://media.dodostatic.com/image/019c52063b3177debd91e3afab378ba5.avif"
},
{
"file": "menu/khrustim-team.avif",
"title": "Хрустим team",
"source": "https://media.dodostatic.com/image/019c9f943dbf72be84d4e741078dd41c.avif"
},
{
"file": "menu/natakhtari-grusha.avif",
"title": "НАТАХТАРИ ГРУША",
"source": "https://media.dodostatic.com/image/019c38923a277682909a25bf8749f841.avif"
},
{
"file": "menu/natakhtari-feykhoa.avif",
"title": "НАТАХТАРИ ФЕЙХОА",
"source": "https://media.dodostatic.com/image/019c41407e7b7804bff17d671c715123.avif"
},
{
"file": "menu/natakhtari-saperavi.avif",
"title": "НАТАХТАРИ САПЕРАВИ",
"source": "https://media.dodostatic.com/image/019c414a38e07168bd4b646b9fa2ed4e.avif"
},
{
"file": "menu/klubnika-kivi.avif",
"title": "Клубника Киви",
"source": "https://media.dodostatic.com/image/019c4817cf2e73f2bfacb43c0706eabf.avif"
},
{
"file": "menu/feykhoa.avif",
"title": "Фейхоа",
"source": "https://media.dodostatic.com/image/019c4818bd6e76779f4a8df44cb1d8b2.avif"
},
{
"file": "menu/burn-originalnyy.avif",
"title": "BURN Оригинальный",
"source": "https://media.dodostatic.com/image/019c481b4eb27671ac6f73da19a0c793.avif"
},
{
"file": "menu/burn-yabloko-kivi.avif",
"title": "BURN Яблоко Киви",
"source": "https://media.dodostatic.com/image/019c520715f07505919d92eab9d0c67d.avif"
},
{
"file": "menu/burn-so-vkusom-mango.avif",
"title": "BURN Со вкусом МАНГО",
"source": "https://media.dodostatic.com/image/019c5208afea73c3ada99f755b97c9b2.avif"
},
{
"file": "menu/adrenaline-rush-absolyutnaya-energiya.avif",
"title": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
"source": "https://media.dodostatic.com/image/019c520a306d769e9e0592c5c087567f.avif"
},
{
"file": "menu/probkovyy-sbor.avif",
"title": "Пробковый сбор",
"source": "https://media.dodostatic.com/image/019c9f56a5df7489ac257e70bd892a4b.avif"
},
{
"file": "menu/porcha-imuschestva.avif",
"title": "Порча имущества",
"source": "https://media.dodostatic.com/image/019c9f570847798ebf8992ddd71b59c7.avif"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more