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).
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,targetJSONB,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 |
Статистика |
Авторизация — по сессиям, не по статическому токену:
POST /api/auth/loginсusernameиpasswordвозвращает{ token, expires_at }.- Токен передаётся в заголовке
X-Auth-Tokenво все защищённые запросы;POST /api/auth/logoutудаляет сессию. 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/ # локальные бэкапы