Лимиты были захардкожены в четырёх местах фронтенда и в константах multer, из-за чего расходились с текстами ошибок на сервере. - UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env; оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты ошибок собираются из тех же констант вместо литералов - UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают - GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb, фронтенд читает их вместо собственных констант Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и 225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
606 lines
48 KiB
Markdown
606 lines
48 KiB
Markdown
# 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.
|
||
- **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/ # локальные бэкапы
|
||
``` |