- storage.js: из экспорта убраны неиспользуемые publicPath, localExists и dir - server.js: убран неиспользуемый импорт mimeFor и параметр originalsDir в воркере - worker.js: убран неиспользуемый параметр originalsDir - scripts/deploy.sh: up -d --force-recreate app, чтобы пересобранный образ гарантированно применялся (compose не всегда пересоздаёт контейнер при неизменном конфиге сервиса)
WhatIDo
Учётная система для образовательного центра: журнал посещений, проектные работы воспитанников, галерея групп, откреплённые файлы и публичные страницы-витрины (share-ссылки).
Возможности
- Журнал записей — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина; в режиме карточек у записи показывается бейдж группы поверх фото и иконка статуса AI-проверки
- AI-проверка (воркер) — фоновый авто-чек текста записей (
worker.js); статус каждой записи (очередь / проверка / проверено / пропущено / ошибка) отображается компактной иконкой в журнале с всплывающей подсказкой - Файлы — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые»
- Группы — учебные группы, расписание (день недели, время), фотохроника группы
- Воспитанники — справочник с привязкой к группам
- Share-ссылки — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- Дашборд — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- Резервное копирование — экспорт/импорт полного дампа (БД + файлы) в
tar.gz - Хранилище файлов — локальный каталог
uploads/или S3-совместимый сервис (s3: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - Настройки — тексты футера, анти-спам интервал
- Публикация через Tailscale — приложение открывается по постоянному адресу
https://whatido.<tailnet>.ts.netбез проброса портов, внешнего IP и reverse-proxy
Технологии
- Node.js + Express
- PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы)
- S3-совместимое хранилище (AWS SDK v3): сервис
s3(SeaweedFS / MinIO) - 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-сети (наружу не публикуется)
Управление:
docker compose ps # статус
docker compose logs -f app # логи приложения
docker compose down # остановка (данные сохраняются)
Приложение не запустится без
ADMIN_PASSWORD(защита от пароля по умолчанию).DB_PASSWORDзадаёт пароль пользователяappв PostgreSQL.
Обновление на сервере (деплой)
Код приложения находится внутри образа: 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 |
— (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
DB_PASSWORD |
— (обязательно) | Пароль пользователя app в PostgreSQL |
Пример .env (в репозитории — .env.example):
ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка
DB_PASSWORD подставляется в docker-compose.yml в POSTGRES_PASSWORD и DATABASE_URL. Если БД уже была инициализирована ранее, значение DB_PASSWORD должно совпадать с фактическим паролем пользователя app в БД (иначе приложение не подключится).
Имя узла Tailscale задаётся в docker-compose.yml (tailscale.hostname, по умолчанию whatido).
Публичный доступ через 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— пары ключ/значение (анти-спам интервал, футер)
Схема инициализируется при первом запуске из db/init.sql; миграции существующей БД — в db/migration.sql.
Хранилище файлов
По умолчанию загруженные фото и файлы хранятся в каталоге 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 (пока локальные копии не удалены).
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
Бэкапы
В админке (Настройки → Бэкап) можно:
- Скачать полный бэкап —
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нет. - 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/dashboard, /api/stats |
Статистика |
Защищённые админ-маршруты требуют заголовок X-Admin-Token с ADMIN_PASSWORD.
Структура проекта
├── docker-compose.yml # сервисы: app + db + s3 (+ опционально tailscale)
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
├── server.js # Express-приложение
├── storage.js # абстракция хранилища: драйверы local и s3
├── 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/ # локальные бэкапы