Files
WhatIDo/README.md
T
dev 70b0c7ae9b feat: env-driven upload limits + request timeout, inline video playback, range requests
- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
2026-10-03 12:00:48 +03:00

49 KiB
Raw Blame History

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 (для публикации по ссылке)

Запуск

# 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

Управление:

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 и статику не подхватят.

./scripts/deploy.sh              # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)

Скрипт проверяет рабочую копию, обновляет ветку (fetch + checkout + pull --ff-only), собирает образ с версией коммита, перезапускает app, затем сверяет server.js в контейнере с рабочей копией и печатает /version.json.

Вручную то же самое:

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

Проверка:

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.

# 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). Ручные проверки сервиса:

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 покажет статус:

docker exec -it whatido-tailscale-1 tailscale status

Если авторизация по какой-то причине не прошла, выполните вход вручную:

docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido
# откроется ссылка вида https://login.tailscale.com/a/... — войдите в браузер

2. Включите функции и найдите адрес

После авторизации узел получит имя вида whatido и адрес:

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

Контейнер при старте сам выполняет:

tailscale funnel --bg --yes https://127.0.0.1:3443

(funnel включает и serve-часть; флаг --bg — работа в фоне, --yes — не спрашивать подтверждения.)

Если сервис уже запущен и команды в compose не отработали (например, функции только что включили в консоли), выполните вручную:

docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443

Проверить состояние:

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:

    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 требует привязать карту).

Узнать текущий адрес для доступа:

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 не включается, туннель стартует сразу, как в базовой схеме.

Проверка:

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 не только факт, но и сами изменения:

{
  "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

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 подключается через оверрайд и образ из доступного вам зеркала:
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 (миграция)

Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.

# 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 и перезапустить приложение:

# в .env: STORAGE_DRIVER=s3
docker compose up -d app

Новые загрузки уходят в бакет (локальная копия удаляется, если STORAGE_KEEP_LOCAL=0), старые файлы ещё читаются из uploads/ благодаря STORAGE_LOCAL_FALLBACK=1. Когда всё проверено — удалите локальные копии:

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 не отвечает).

Команды

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 (включая поведение при недоступном сервере):

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 отдаёт тот же архив сразу.

Также доступны скрипты на хосте:

./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/             # локальные бэкапы