# WhatIDo Учётная система для образовательного центра: журнал посещений, проектные работы воспитанников, галерея групп, откреплённые файлы и публичные страницы-витрины (share-ссылки). ## Возможности - **Журнал записей** — отметки о занятиях с фото и прикреплёнными файлами (проектные работы), мягкое удаление и корзина; в режиме карточек у записи показывается бейдж группы поверх фото и иконка статуса AI-проверки - **AI-проверка (воркер)** — фоновый авто-чек текста записей (`worker.js`); статус каждой записи (очередь / проверка / проверено / пропущено / ошибка) отображается компактной иконкой в журнале с всплывающей подсказкой - **Файлы** — централизованный раздел со всеми загруженными файлами, фильтры (имя воспитанника, группа, даты, поиск) и вкладка «Откреплённые» - **Группы** — учебные группы, расписание (день недели, время), фотохроника группы - **Воспитанники** — справочник с привязкой к группам - **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Настройки** — тексты футера, анти-спам интервал - **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido..ts.net` без проброса портов, внешнего IP и reverse-proxy ## Технологии - Node.js + Express - PostgreSQL (pg) - Multer (загрузка файлов), Tar (бэкапы) - S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO) - Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры) - Lucide (иконки UI) - Docker / Docker Compose - Tailscale (Serve / Funnel) — публикация по HTTPS ## Быстрый старт ### Требования - Linux-хост с Docker и плагином `docker compose` - Free порта `443` на хосте (его займёт tailscale для Funnel) - Аккаунт Tailscale (для публикации по ссылке) ### Запуск ```bash # 1) создайте .env из примера и задайте свои пароли cp .env.example .env $EDITOR .env # 2) соберите и поднимите стек docker compose up -d --build ``` После старта (без публикации через tailscale): - **HTTP** `http://localhost:3003` — редирект на HTTPS - **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера) - **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется) - **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379` Управление: ```bash docker compose ps # статус docker compose logs -f app # логи приложения docker compose down # остановка (данные сохраняются) ``` > Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию). > `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL. > `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml` > или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше. ## Обновление на сервере (деплой) Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят. ```bash ./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент) ``` Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`. Вручную то же самое: ```bash git checkout master && git pull --ff-only origin master docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app docker compose up -d app ``` Проверка: ```bash curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js ``` Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой. ## Конфигурация Переменные окружения (`.env`): | Переменная | По умолчанию | Назначение | |------------------|--------------------|-------------------------------------| | `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует | | `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`). ## Публичный доступ через Tailscale Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете. Цепочка: ``` Интернет / tailnet → https://whatido..ts.net (TLS от Tailscale) → localhost:443 контейнера tailscale → https://127.0.0.1:3443 (приложение, самоподписанный cert) ``` Контейнер доверяет самоподписанному сертификату приложения через `SSL_CERT_FILE=/etc/tailscale/app-certs/cert.pem` (файл монтируется из `./certs/cert.pem`). ### 1. Вход в tailnet при первом запуске При первом старте контейнер автоматически выполняет `tailscale up` и печатает ссылку для авторизации — откройте её в браузере и добавьте устройство в аккаунт. Ввести устройство вручную там не нужно: `tailscale status` покажет статус: ```bash docker exec -it whatido-tailscale-1 tailscale status ``` Если авторизация по какой-то причине не прошла, выполните вход вручную: ```bash docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido # откроется ссылка вида https://login.tailscale.com/a/... — войдите в браузер ``` ### 2. Включите функции и найдите адрес После авторизации узел получит имя вида `whatido` и адрес: ```bash docker exec -it whatido-tailscale-1 tailscale status # что-то вроде: whatido.taile47725.ts.net 100.x.x.x receiver online ``` Чтобы публиковать сайт, для tailnet должны быть включены: - **HTTPS Certificates** — автоматически выдается при первом Serve/Funnel - **Serve** (доступ из tailnet) и **Funnel** (доступ из интернета) — включаются в админ-консоли Tailscale, например по прямой ссылке на узел: `https://login.tailscale.com/f/serve?node=` и `https://login.tailscale.com/funnel?node=` (id узла берётся из `tailscale status`) ### 3. Запуск Serve / Funnel Контейнер при старте сам выполняет: ```bash tailscale funnel --bg --yes https://127.0.0.1:3443 ``` (`funnel` включает и serve-часть; флаг `--bg` — работа в фоне, `--yes` — не спрашивать подтверждения.) Если сервис уже запущен и команды в compose не отработали (например, функции только что включили в консоли), выполните вручную: ```bash docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443 ``` Проверить состояние: ```bash docker exec whatido-tailscale-1 tailscale funnel status # https://whatido..ts.net/ (Funnel on) ``` ### 4. Готово - Из любого устройства вашей tailnet: `https://whatido..ts.net/` - Из интернета (при включённом Funnel): тот же адрес - Админка: `https://whatido..ts.net/admin` ### Важные замечания - **Порт 443 на хосте должен быть свободен** — tailscale слушает его напрямую (поэтому у сервиса `network_mode: host`, а приложение опубликовано на `127.0.0.1:3003/3443`). - **Сертификат приложения**: контейнер приложения генерирует self-signed `cert.pem` при сборке образа (это не секрет — публичный сертификат). Он монтируется в tailscale через `./certs/cert.pem`. Если образ приложения пересобирали впервые на новом хосте — скопируйте сертификат и перезапустите tailscale: ```bash docker compose up -d --build app docker cp $(docker compose ps -q app):/app/certs/cert.pem certs/cert.pem docker compose up -d tailscale ``` - **`/lib/modules`** монтируется в контейнер tailscale, чтобы на LC/дистрибутивах без авто-загрузки модулей корректно инициализировался netfilter (иначе `tailscaled` падает с `Table does not exist` и узел мигает online/offline). - **Ограничения сети**: если у провайдера нет глобального IPv6 и соединения к ПК-ядрам Tailscale нестабильные (CGNAT), отвечать наружу узел может с перебоями — классический признак: `tailscale status` показывает `online`, а из интернета URL не открывается. Внутри tailnet сайт работает всегда. ## Альтернативная публикация (Caddy) Исторически проект публиковался через reverse-proxy Caddy + Let's Encrypt (файлы `Caddyfile.example`, закомментированный сервис в старых версиях compose). Если нужно классическое публичное HTTPS на собственном домене с проброшенными портами 80/443 — этот вариант остаётся возможным: раскомментируйте/восстановите сервис `caddy`, укажите домен в `Caddyfile` и уберите сетевые блокировки. По умолчанию сейчас рекомендуется Tailscale-схема выше. ## Публикация наружу через Cloudflare (Quick Tunnel) Помимо Tailscale, стек умеет публиковать приложение через контейнер `cloudflared` (сервис в `docker-compose.yml`). **Quick Tunnel** даёт случайный публичный адрес `*.trycloudflare.com` через Cloudflare — без своего домена, банковской карты и проброса портов (подойдёт, если Zero Trust требует привязать карту). Узнать текущий адрес для доступа: ```bash docker compose logs cloudflared | grep trycloud ``` ### Опциональный WireGuard для эгресса Cloudflare `cloudflared` собирается из `Dockerfile.cloudflared`, в который добавлена поддержка **опционального WireGuard** — полезно, когда Cloudflare не работает с прямого IP хоста, и исход туннеля нужно пустить через VPN. - Положите рабочий конфиг провайдера в `wg/wg0.conf` (папка `wg/` монтируется в контейнер как `/etc/wireguard`). Пример-заглушка уже лежит в `wg/wg0.conf` — замените значения на свои. - Если `wg/wg0.conf` есть → контейнер **сначала поднимает WireGuard и ждёт handshake** (весь эгресс Cloudflare идёт через VPN). Если handshake не установился за `WG_HANDSHAKE_TIMEOUT` сек (по умолчанию 60) — **fallback: туннель стартует напрямую без VPN**, а в логах пишется предупреждение (сайт не лежит из-за недоступного VPN-провайдера). - Если `wg0.conf` нет или папка пустая → VPN не включается, туннель стартует сразу, как в базовой схеме. Проверка: ```bash docker compose logs -f cloudflared # в идеале сначала "WireGuard is up", затем URL docker compose exec cloudflared wg show # есть handshake — VPN поднят ``` Примечания: - `wg/wg0.conf` содержит приватный ключ, поэтому папка `wg/` добавлена в `.gitignore` (в репозиторий не попадёт). - При полном туннеле (`AllowedIPs = 0.0.0.0/0`) существующий маршрут docker-сети сохраняется, поэтому `cloudflared` по-прежнему достаёт `app` локально, а наружу уходит через wg0. Если вдруг `app` станет недоступен из-за VPN — переключите `CLOUDFLARE_TUNNEL_URL` в `.env` на docker-шлюз (`http://: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` ```bash docker compose up -d s3 # поднимает S3-хранилище (том s3-data) ``` - **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети. - **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала: ```bash S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3 ``` Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit. ### Переменные окружения | Переменная | По умолчанию | Назначение | |---|---|---| | `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете | | `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети | | `S3_BUCKET` | `whatido` | Бакет для объектов | | `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) | | `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) | | `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета | | `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет | | `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 | | `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) | ### Переход на S3 (миграция) Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться. ```bash # 1) поднять хранилище docker compose up -d s3 # 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта) docker compose exec -T app node scripts/migrate-to-s3.js --dry-run docker compose exec -T app node scripts/migrate-to-s3.js # 3) проверить, что все объекты на месте (ничего не меняет) docker compose exec -T app node scripts/migrate-to-s3.js --verify-only ``` Дальше включить драйвер `s3` и перезапустить приложение: ```bash # в .env: STORAGE_DRIVER=s3 docker compose up -d app ``` Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии: ```bash docker compose exec -T app node scripts/migrate-to-s3.js --delete-local ``` Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены). Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»). ## Redis (кэш и pub/sub) Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно быть общим и быстрым: | Что | Ключи | TTL | |---|---|---| | Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с | | Кэш сессий | `session:` | 30 с | | Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % | | Баны IP | `ban:` | до `banned_until` | | Счётчики неудачных попыток входа | `fail::` | 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`). ### Команды ```bash docker compose up -d redis # поднять только Redis docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*' docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings' ``` Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`. Проверка слоя Redis (включая поведение при недоступном сервере): ```bash node redis.selftest.js # юнит-тесты redis.js node api.smoketest.js # сквозная проверка API (нужен запущенный стек) ``` ## Бэкапы В админке (Настройки → Бэкап) можно: - Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/` - Восстановить из файла бэкапа Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу. Также доступны скрипты на хосте: ```bash ./scripts/backup.sh # дамп БД + фото в backups/whatido-backup-<дата>.tar.gz ./scripts/restore.sh # восстановление из архива ``` Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать. Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу. ## Безопасность - **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет. - **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 + redis + s3 (+ опционально tailscale) ├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS ├── .env.example # шаблон переменных окружения ├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата) ├── server.js # Express-приложение ├── storage.js # абстракция хранилища: драйверы local и s3 ├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback) ├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере ├── 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/ # локальные бэкапы ```