Files
dev b931c0a760 feat(lesson-ai): проверка отчёта о занятии по шаблону + история версий
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели:
совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается
в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель,
оригинал тьютора сохраняется в text_original.

- схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в
  lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable()
- настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai),
  значения только 'true'/'false'
- worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED,
  до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker
  уже занят будильником
- server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff,
  уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты
  /versions, /versions/:id/restore и /ai/revert
- aiComplete вместо aiCorrectText: общий вызов модели с таймаутом
- бэкап/восстановление: lesson_reports и lesson_report_versions в payload
- фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи
  статусов в lessons.js, лейблы аудита, renderAuditPager
- docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README
- тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
2026-10-04 00:12:24 +03:00

608 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WhatIDo
Учётная система для образовательного центра: журнал посещений, проектные работы воспитанников, галерея групп, откреплённые файлы и публичные страницы-витрины (share-ссылки).
## Возможности
- **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина; в режиме карточек у записи показывается бейдж группы поверх фото и иконка статуса AI-проверки
- **AI-проверка (воркер)** — фоновый авто-чек текста записей (`worker.js`); статус каждой записи (очередь / проверка / проверено / пропущено / ошибка) отображается компактной иконкой в журнале с всплывающей подсказкой
- **Файлы** — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые»
- **Группы** — учебные группы, расписание (день недели, время), фотохроника группы
- **Воспитанники** — справочник с привязкой к группам
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
## Технологии
- Node.js + Express
- PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы)
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
- Lucide (иконки UI)
- Docker / Docker Compose
- Tailscale (Serve / Funnel) — публикация по HTTPS
## Быстрый старт
### Требования
- Linux-хост с Docker и плагином `docker compose`
- Free порта `443` на хосте (его займёт tailscale для Funnel)
- Аккаунт Tailscale (для публикации по ссылке)
### Запуск
```bash
# 1) создайте .env из примера и задайте свои пароли
cp .env.example .env
$EDITOR .env
# 2) соберите и поднимите стек
docker compose up -d --build
```
После старта (без публикации через tailscale):
- **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
Управление:
```bash
docker compose ps # статус
docker compose logs -f app # логи приложения
docker compose down # остановка (данные сохраняются)
```
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
## Обновление на сервере (деплой)
Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят.
```bash
./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)
```
Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`.
Вручную то же самое:
```bash
git checkout master && git pull --ff-only origin master
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app
docker compose up -d app
```
Проверка:
```bash
curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита
docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js
```
Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой.
## Конфигурация
Переменные окружения (`.env`):
| Переменная | По умолчанию | Назначение |
|------------------|--------------------|-------------------------------------|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
Пример `.env` (в репозитории — `.env.example`):
```
ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка
REDIS_PASSWORD=случайная-длинная-строка
```
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
## ИИ-улучшение фото (photo-ai)
Сервис `photo-ai` (Real-ESRGAN + GFPGAN) поднимается вместе со стеком и **включён по умолчанию**:
`PHOTO_AI_URL` в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна,
а задания обрабатывает фоновый воркер. Базовая сборка работает на CPU, GPU не требуется.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `PHOTO_AI_URL` | `http://photo-ai:8080` | Адрес сервиса. **Пустое значение = сервис выключен**: кнопка «🤖 ИИ» скрыта, `POST /api/entries/:id/photo/enhance-ai` отвечает `503`, приложение при этом полностью работоспособно |
| `PHOTO_AI_MAX_PIXELS` | `4000000` | Максимум пикселей входного изображения, вход большего размера уменьшается |
| `PHOTO_AI_DEVICE` | `auto` | Устройство инференса: `auto` (CUDA, если контейнеру выдан GPU, иначе CPU), `cuda`, `cpu`. Явный `cuda` без CUDA не роняет сервис: WARN в лог и работа на CPU |
| `PHOTO_AI_TILE` | `256` | Размер тайла инференса (`0` — без тайлов): меньше тайл — меньше памяти, но медленнее |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | Модель восстановления лиц: `gfpgan`; `codeformer` доступен только при вендоринге модуля в `photo-ai/vendor` |
| `PHOTO_AI_LOAD_ALL` | `0` | Загружать все модели при старте (`1`) или лениво по требованию (`0`) |
| `PHOTO_AI_JPEG_QUALITY` | `92` | Качество JPEG результата, 70..100 |
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | Таймаут заданий с восстановлением лиц (мс) |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
### Запуск на NVIDIA GPU
GPU не обязателен: без него сервис работает на CPU. Чтобы включить GPU-вариант, нужен драйвер NVIDIA
и NVIDIA Container Toolkit.
```bash
# 1. Toolkit (один раз, требует sudo)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
# 2. GPU-образ (для устройств с поддержкой CDI спеку генерирует сам toolkit)
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
sudo systemctl restart docker
# 3. Запуск
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
curl http://127.0.0.1:8081/health
```
`docker-compose.gpu.yml` собирает отдельный тег `whatido-photo-ai:cu126` (torch из индекса `cu126` —
те же версии, что и в CPU-образе, отличаются только CUDA-библиотеки), поэтому CPU-образ
`whatido-photo-ai:latest` не перетирается и переключение обратно — обычный `docker compose up -d photo-ai`.
GPU выдаётся контейнеру через CDI (`device_ids: nvidia.com/gpu=all`), поэтому править
`/etc/docker/daemon.json` и перезапускать демон не нужно. Если nvidia-runtime уже зарегистрирован в
демоне (`nvidia-ctk runtime configure --runtime=docker`), в оверрайде можно заменить это на
классическое резервирование `driver: nvidia, count: 1` — результат тот же.
Проверка результата: в `/health` должны быть `device: cuda:0`, `half: true`, непустые
`vram_total_mb`/`vram_free_mb`. На 4 ГБ (например, RTX 3050 Laptop) реально держатся одновременно
`x2plus` и `gfpgan`: `vram_free_mb` после двух моделей — около 100–300 МБ, поэтому `PHOTO_AI_TILE`
оставьте небольшим (`256`), а `PHOTO_AI_LOAD_ALL=1` на 4 ГБ лучше не включать — предзагрузка всех
моделей подряд исчерпает VRAM. Если памяти не хватило, сервис сам проходит лестницу тайлов
(`PHOTO_AI_TILE` → /2 → /4), затем переключается на CPU и возвращает результат с предупреждением
в `warnings` — задание при этом не падает.
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
`text-corrector`). Ручные проверки сервиса:
```bash
curl http://127.0.0.1:8081/health
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
curl -H "Accept: application/json" -F "image=@photo.jpg" -F "face=face" http://127.0.0.1:8081/enhance \
| python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('device','faces_found','elapsed_ms','warnings')})"
```
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
(`configured`, `reachable`, `device`, `device_name`, `half`, `tile`, `vram_total_mb`, `vram_free_mb`,
`models`, `face_models`, `loaded`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
`worker` — состояние очереди и конфигурация воркера.
Параметры `/enhance`: `image` (файл), `scale` (`2`..`4`), `model` (`x2plus`|`general-x4v3`|`animevideo-v3`),
`face` (`off`|`face`|`all`), `face_model` (`gfpgan`|`codeformer`), `strength` (`0..1`, только CodeFormer),
`jpeg_quality` (`70..100`). По умолчанию отдаётся сырой `image/jpeg`; заголовок
`Accept: application/json` переключает на JSON с `image_base64`, `faces_found`, `device`, `elapsed_ms`
и `warnings` (малое разрешение входа, лица не найдены, не хватило памяти, CodeFormer на CPU).
## Публичный доступ через Tailscale
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
Цепочка:
```
Интернет / tailnet → https://whatido.<tailnet>.ts.net (TLS от Tailscale)
→ localhost:443 контейнера tailscale
→ https://127.0.0.1:3443 (приложение, самоподписанный cert)
```
Контейнер доверяет самоподписанному сертификату приложения через `SSL_CERT_FILE=/etc/tailscale/app-certs/cert.pem` (файл монтируется из `./certs/cert.pem`).
### 1. Вход в tailnet при первом запуске
При первом старте контейнер автоматически выполняет `tailscale up` и печатает ссылку для авторизации — откройте её в браузере и добавьте устройство в аккаунт. Ввести устройство вручную там не нужно: `tailscale status` покажет статус:
```bash
docker exec -it whatido-tailscale-1 tailscale status
```
Если авторизация по какой-то причине не прошла, выполните вход вручную:
```bash
docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido
# откроется ссылка вида https://login.tailscale.com/a/... — войдите в браузер
```
### 2. Включите функции и найдите адрес
После авторизации узел получит имя вида `whatido` и адрес:
```bash
docker exec -it whatido-tailscale-1 tailscale status
# что-то вроде: whatido.taile47725.ts.net 100.x.x.x receiver online
```
Чтобы публиковать сайт, для tailnet должны быть включены:
- **HTTPS Certificates** — автоматически выдается при первом Serve/Funnel
- **Serve** (доступ из tailnet) и **Funnel** (доступ из интернета) — включаются в админ-консоли Tailscale, например по прямой ссылке на узел:
`https://login.tailscale.com/f/serve?node=<node-id>` и `https://login.tailscale.com/funnel?node=<node-id>`
(id узла берётся из `tailscale status`)
### 3. Запуск Serve / Funnel
Контейнер при старте сам выполняет:
```bash
tailscale funnel --bg --yes https://127.0.0.1:3443
```
(`funnel` включает и serve-часть; флаг `--bg` — работа в фоне, `--yes` — не спрашивать подтверждения.)
Если сервис уже запущен и команды в compose не отработали (например, функции только что включили в консоли), выполните вручную:
```bash
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443
```
Проверить состояние:
```bash
docker exec whatido-tailscale-1 tailscale funnel status
# https://whatido.<tailnet>.ts.net/ (Funnel on)
```
### 4. Готово
- Из любого устройства вашей tailnet: `https://whatido.<tailnet>.ts.net/`
- Из интернета (при включённом Funnel): тот же адрес
- Админка: `https://whatido.<tailnet>.ts.net/admin`
### Важные замечания
- **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3003/3443`).
- **Сертификат приложения**: контейнер приложения генерирует self-signed `cert.pem` при сборке образа (это не секрет — публичный сертификат). Он монтируется в tailscale через `./certs/cert.pem`. Если образ приложения пересобирали впервые на новом хосте — скопируйте сертификат и перезапустите tailscale:
```bash
docker compose up -d --build app
docker cp $(docker compose ps -q app):/app/certs/cert.pem certs/cert.pem
docker compose up -d tailscale
```
- **`/lib/modules`** монтируется в контейнер tailscale, чтобы на LC/дистрибутивах без авто-загрузки модулей корректно инициализировался netfilter (иначе `tailscaled` падает с `Table does not exist` и узел мигает online/offline).
- **Ограничения сети**: если у провайдера нет глобального IPv6 и соединения к ПК-ядрам Tailscale нестабильные (CGNAT), отвечать наружу узел может с перебоями — классический признак: `tailscale status` показывает `online`, а из интернета URL не открывается. Внутри tailnet сайт работает всегда.
## Альтернативная публикация (Caddy)
Исторически проект публиковался через reverse-proxy Caddy + Let's Encrypt (файлы `Caddyfile.example`, закомментированный сервис в старых версиях compose). Если нужно классическое публичное HTTPS на собственном домене с проброшенными портами 80/443 — этот вариант остаётся возможным: раскомментируйте/восстановите сервис `caddy`, укажите домен в `Caddyfile` и уберите сетевые блокировки. По умолчанию сейчас рекомендуется Tailscale-схема выше.
## Публикация наружу через Cloudflare (Quick Tunnel)
Помимо Tailscale, стек умеет публиковать приложение через контейнер `cloudflared` (сервис в `docker-compose.yml`). **Quick Tunnel** даёт случайный публичный адрес `*.trycloudflare.com` через Cloudflare — без своего домена, банковской карты и проброса портов (подойдёт, если Zero Trust требует привязать карту).
Узнать текущий адрес для доступа:
```bash
docker compose logs cloudflared | grep trycloud
```
### Опциональный WireGuard для эгресса Cloudflare
`cloudflared` собирается из `Dockerfile.cloudflared`, в который добавлена поддержка **опционального WireGuard** — полезно, когда Cloudflare не работает с прямого IP хоста, и исход туннеля нужно пустить через VPN.
- Положите рабочий конфиг провайдера в `wg/wg0.conf` (папка `wg/` монтируется в контейнер как `/etc/wireguard`). Пример-заглушка уже лежит в `wg/wg0.conf` — замените значения на свои.
- Если `wg/wg0.conf` есть → контейнер **сначала поднимает WireGuard и ждёт handshake** (весь эгресс Cloudflare идёт через VPN). Если handshake не установился за `WG_HANDSHAKE_TIMEOUT` сек (по умолчанию 60) — **fallback: туннель стартует напрямую без VPN**, а в логах пишется предупреждение (сайт не лежит из-за недоступного VPN-провайдера).
- Если `wg0.conf` нет или папка пустая → VPN не включается, туннель стартует сразу, как в базовой схеме.
Проверка:
```bash
docker compose logs -f cloudflared # в идеале сначала "WireGuard is up", затем URL
docker compose exec cloudflared wg show # есть handshake — VPN поднят
```
Примечания:
- `wg/wg0.conf` содержит приватный ключ, поэтому папка `wg/` добавлена в `.gitignore` (в репозиторий не попадёт).
- При полном туннеле (`AllowedIPs = 0.0.0.0/0`) существующий маршрут docker-сети сохраняется, поэтому `cloudflared` по-прежнему достаёт `app` локально, а наружу уходит через wg0. Если вдруг `app` станет недоступен из-за VPN — переключите `CLOUDFLARE_TUNNEL_URL` в `.env` на docker-шлюз (`http://<gateway-ip>:3003`).
Адрес Quick Tunnel меняется при каждом перезапуске контейнера `cloudflared`. Для постоянного адреса используйте именованный туннель (для этого в `start-cloudflared.sh` замените последнюю команду на `exec cloudflared --no-autoupdate tunnel run --token "$CLOUDFLARE_TUNNEL_TOKEN"` и задайте токен; токен — из Cloudflare Zero Trust **Networks → Tunnels**) или схему через Tailscale выше.
## Структура данных
- `groups` — группы, расписание (`day_of_week`, `time_start`, `time_end`)
- `students` — воспитанники
- `entries` — записи журнала (фото `photo_path`, описание, мягкое удаление `deleted_at`)
- `project_files` — файлы записей (`token`, `path`, привязка `entry_id`, отметка `detached_at`)
- `group_photos` — фотохроника групп
- `share_links` — публичные ссылки-витрины
- `settings` — пары ключ/значение (анти-спам интервал, футер)
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
## Аудит изменений текста
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
```json
{
"id": 363,
"source": "ai",
"changed": true,
"fields": ["description", "group_id"],
"changes": [
{ "field": "description", "label": "Текст работы",
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
]
}
```
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
## Хранилище файлов
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
Дополнительно поддерживается **S3-совместимое хранилище** (сервис `s3` в compose, драйвер `s3`). Все обращения к файлам идут через приложение: URL (`/uploads/...`, `/uploads/thumb/...`, `/api/files/:token`, share-ссылки) и записи в БД (`/uploads/<файл>`) не меняются, поэтому переключение драйвера не требует миграции данных в БД.
### Сервис `s3`
```bash
docker compose up -d s3 # поднимает S3-хранилище (том s3-data)
```
- **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети.
- **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала:
```bash
S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
```
Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit.
### Переменные окружения
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете |
| `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети |
| `S3_BUCKET` | `whatido` | Бакет для объектов |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) |
| `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) |
| `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета |
| `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет |
| `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 |
| `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) |
### Переход на S3 (миграция)
Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.
```bash
# 1) поднять хранилище
docker compose up -d s3
# 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта)
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
docker compose exec -T app node scripts/migrate-to-s3.js
# 3) проверить, что все объекты на месте (ничего не меняет)
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
```
Дальше включить драйвер `s3` и перезапустить приложение:
```bash
# в .env: STORAGE_DRIVER=s3
docker compose up -d app
```
Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии:
```bash
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
```
Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены).
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
## Redis (кэш и pub/sub)
Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно
быть общим и быстрым:
| Что | Ключи | TTL |
|---|---|---|
| Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с |
| Кэш сессий | `session:<token>` | 30 с |
| Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % |
| Баны IP | `ban:<ip>` | до `banned_until` |
| Счётчики неудачных попыток входа | `fail:<kind>:<ip>` | 15 мин |
Инвалидация кэша — по префиксу (`SCAN` + `DEL`), поэтому после правки настроек, группы или записи
новое значение видно сразу. Правки пользователей сбрасывают `session:*`, так что деактивация
аккаунта и выход из сессии действуют немедленно.
Через pub/sub каналы `whatido:events`, `whatido:wake:ai` и `whatido:wake:photo` доставляют SSE-события
клиентам и будят фоновых воркеров без ожидания цикла опроса БД.
### Отказоустойчивость
Если Redis недоступен, приложение **не падает**: `redis.js` прозрачно переключается на
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
`GET /api/system-info` → `cache.driver` (`redis` или `memory`) и в блоке «Кэш» на странице
Настроек → Стек (там же — «нет связи — в памяти», если Redis не отвечает).
### Команды
```bash
docker compose up -d redis # поднять только Redis
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings'
```
Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики
переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`.
Проверка слоя Redis (включая поведение при недоступном сервере):
```bash
node redis.selftest.js # юнит-тесты redis.js
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
```
## Уведомления
Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`.
| Тип | Событие | Кому видно |
|-----|---------|------------|
| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы |
| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы |
| `entry.ai.error` | авто-проверка текста не удалась | филиал группы |
| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы |
| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы |
| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ |
| `backup.restore` | восстановление из бэкапа | только админ |
| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ |
Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback.
Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления.
Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`.
## Бэкапы
В админке (Настройки → Бэкап) можно:
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
- Восстановить из файла бэкапа
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
Также доступны скрипты на хосте:
```bash
./scripts/backup.sh # дамп БД + фото в backups/whatido-backup-<дата>.tar.gz
./scripts/restore.sh # восстановление из архива
```
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу.
## Безопасность
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
- **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
- **Видеофайлы** (`.mp4`, `.m4v`, `.webm`, `.ogv`) играются прямо в журнале: `GET /api/files/:token?play=1` отдаёт файл **inline** с `Accept-Ranges: bytes` и поддержкой `Range` (`206`), поэтому перемотка работает без скачивания целиком. Остальные форматы (`.mov`, `.mkv`, `.avi` и пр.) браузер не играет — они остаются ссылками на скачивание.
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
- **TLS**: снаружи HTTPS терминируется Tailscale (сертификат Let's Encrypt для `*.ts.net`); между tailscale и приложением используется самоподписанный сертификат приложения.
## Основные API
| Метод | Путь | Назначение |
|-------|------|------------|
| `GET` | `/api/entries` | Записи журнала (с фильтрами) |
| `POST` | `/api/entries` | Создать запись (photo + files) |
| `PUT/DELETE` | `/api/entries/:id` | Обновить / мягко удалить |
| `GET` | `/api/files` | Все файлы (фильтры) |
| `GET` | `/api/files/detached` | Откреплённые файлы |
| `POST` | `/api/files/:id/detach` | Открепить файл от записи |
| `GET` | `/api/files/:token` | Скачать/показать файл по токену (публично) |
| `GET` | `/api/groups` | Список групп |
| `POST/PUT/DELETE` | `/api/groups/:id(?)` | CRUD групп |
| `GET/POST` | `/api/groups/:id/photos` | Фотохроника группы |
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
| `GET` | `/api/backup` | Скачать бэкап |
| `POST` | `/api/restore` | Восстановить из бэкапа |
| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) |
| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) |
| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) |
| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным |
| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) |
| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) |
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
Авторизация — по сессиям, не по статическому токену:
1. `POST /api/auth/login` с `username` и `password` возвращает `{ token, expires_at }`.
2. Токен передаётся в заголовке `X-Auth-Token` во все защищённые запросы; `POST /api/auth/logout` удаляет сессию.
3. `GET /api/auth/me` — текущий пользователь (`id`, `username`, `role`, `is_active`, `branch_ids`).
Заголовок `X-Admin-Token` больше не поддерживается. Маршруты помечены `requireAuth` (любой активный пользователь) или `requireAdmin` (только `role = admin`); филиалы не-admin ограничены его `user_branches`.
Защищённые маршруты:
| Метод | Путь | Доступ |
|---|---|---|
| `GET/POST/PUT/DELETE` | `/api/users`, `/api/users/:id` | admin |
| `GET/POST/DELETE` | `/api/bans` | admin |
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
| `GET/POST/DELETE` | `/api/settings` | admin |
| `GET` | `/api/audit` | admin |
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
| `GET` | `/api/backup`, `POST /api/restore` | admin |
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
## Структура проекта
```
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
├── server.js # Express-приложение
├── storage.js # абстракция хранилища: драйверы local и s3
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
├── diff.js # пословный diff текста и сборка изменений записи для аудита
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
├── api.smoketest.js # сквозная проверка API по поднятому стеку
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
├── db/
│ ├── init.sql # схема при первом запуске
│ └── migration.sql # миграции существующей БД
├── public/ # статика (HTML/CSS/JS админки и витрин)
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
└── backups/ # локальные бэкапы
```