dev 88dbff0136 chore(photo-ai): Stage 0 закрыт, решения D1–D6 зафиксированы
Stage 0: чекбоксы отмечены, приёмка перепроверена — эталон воспроизводится
байт-в-байт (5/5 MATCH), /health -> {"ok":true}, сценарий «🤖 ИИ» -> done.

D1 — порт photo-ai на 127.0.0.1:8081 (loopback), применён и отражён в
README/.env.example.
D2 — отдельная photoAiHealth() вместо aiHealthCheck() в статусе заданий,
контракт service = {configured, reachable, latency_ms, error} + passthrough
полей photo-ai; правка и проверка в предыдущем коммите.
D3 — PHOTO_JOB_ACTIONS выносится на уровень модуля и используется в restore
и в валидации enhance-ai; CHECK в БД не добавляем (I6).
D4 — вендорить gfpgan/facexlib не нужно: пакеты есть на PyPI и уже в образе
(реalesrgan тянет их транзитивно). Зафиксированы проверенные URL весов и
расхождение: пин numpy<2 в Dockerfile не действует (в образе 2.2.6).
D5 — CodeFormer внедряется только при явной необходимости; на PyPI лишь
сторонняя обёртка, дефолт gfpgan, при отсутствии модуля -> 400 с текстом.
D6 — дефолт PHOTO_AI_URL не меняем (фото-ИИ включено из коробки на CPU).
2026-09-28 23:38:48 +03:00
2026-09-10 11:26:51 +03:00
2026-09-19 13:06:21 +03:00

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) поднимается вместе со стеком и включён по умолчанию: 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_SOFT_MAX_RETRIES 60 Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка
PHOTO_AI_SOFT_BACKOFF_MS 10000 Первая пауза перед мягким повтором
PHOTO_AI_SOFT_BACKOFF_MAX_MS 300000 Потолок паузы (задержка растёт вдвое)

Порт 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

Состояние сервиса и воркера — в GET /api/photo-jobs/status (admin): service отдаёт health фото-сервиса (configured, reachable, latency_ms, error), ai_configured — задан ли PHOTO_AI_URL, worker — состояние очереди и конфигурация воркера.

Публичный доступ через 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 мин.
  • Загрузки ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (.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/             # локальные бэкапы
S
Description
No description provided
Readme
2.2 GiB
Languages
JavaScript 69.9%
HTML 18%
CSS 5.6%
PLpgSQL 3.3%
Python 2.3%
Other 0.9%