Загрузки панели в S3
Файлы, которые пользователи загружают в редактор (фото, видео, документы узлов), можно хранить в S3-совместимом бакете вместо папки uploads/ на сервере. Тогда панель и боты не зависят от диска конкретной машины: проект можно перенести на другой сервер, в Kubernetes или на Railway.
Как это устроено
- Адрес файла не меняется:
/uploads/<projectId>/<дата>/<файл>. Он записан в узлах проектов и вmedia_files, переписывать их не нужно. - Ключ объекта в бакете совпадает с путём внутри
uploads/:3/2026-08-19/photo.jpg. - Для зарегистрированного адреса панель выбирает источник по записи
media_files, включая исходное хранилище, даже если активное хранилище изменилось. Неоднозначный адрес не выдаётся. Незарегистрированные локальные файлы продолжают раздаваться статически. Зарегистрированные файлы с признаком публичности доступны без сессии; для приватных файлов требуется доступ к проекту. Незарегистрированные локальные файлы сохраняют прежнюю публичную статическую раздачу. - Чтение из S3 доступно только для зарегистрированного адреса. Сборки ботов, бэкапы и произвольные ключи бакетов через
/uploadsне открываются. /api/media/s3-proxy/<configId>/<ключ>отдаёт объект приватного бакета владельцу или коллаборатору проекта. Без личности — 401. Строкаmedia_filesищется по хранилищу и ключу: нет строки — 404, нет доступа к проекту — 403. СовпадениеconfigIdсUPLOADS_STORAGE_IDсамо по себе доступ не даёт. Ответ сCache-Control: private.- Новые загрузки в хранилище загрузок тоже получают адрес
/uploads/.... - Удаление файла в панели удаляет объект из бакета и оставшуюся локальную копию.
Доступ бота к оригиналам и экспорт
Для получения оригиналов из панели задайте постоянный секрет MEDIA_RUNTIME_SECRET на сервере панели и MEDIA_RUNTIME_BASE_URL с адресом панели, доступным процессу бота. В Docker и на удалённом исполнителе localhost обычно указывает на сам контейнер или исполнитель. Для удалённого исполнителя используйте HTTPS; HTTP разрешён только при явном MEDIA_RUNTIME_ALLOW_INSECURE_REMOTE=true для доверенного защищённого канала. Бот получает отдельный токен проекта, а не ключи S3. Не меняйте секрет без планирования перезапуска ботов: ранее выданные токены перестанут действовать.
Для автономного запуска доступен ZIP через POST /api/projects/:id/export?format=zip с авторизацией проекта. Архив содержит bot.py, оригиналы в uploads/ и manifest.json; при недоступности файла экспорт завершается ошибкой. Общий размер оригиналов ограничен 128 МиБ. Обычный экспорт кода без format=zip не включает файлы.
Для серверного чтения локальных файлов на Windows используется PowerShell и проверка открытого файлового дескриптора; скрипт windows-media-read.ps1 должен находиться рядом с модулем при развёртывании. Перед выпуском проверьте доступность всех нужных файлов и не удаляйте локальные копии до проверки целевого режима. Запуск бота не подтверждает фактическую отправку медиа.
Поля бакета и UPLOADS_STORAGE_ID задаются в админке, раздел «Хранилища» (настройки рантайма). Пока значение не сохранено, читается переменная окружения. Сохранение обновляет строку s3-default.
Настройка
- Создайте бакет (Railway Bucket, Cloudflare R2, MinIO, SeaweedFS).
- Добавьте хранилище в панель — «Хранилища» в интерфейсе или API:
curl -X POST https://<панель>/api/storage-configs \
-H 'Content-Type: application/json' -b <cookie сессии> \
-d '{"id":"s3-uploads","name":"S3: загрузки","backend":"s3",
"config":{"bucket":"tbb-uploads","region":"auto","endpointUrl":"https://...","forcePathStyle":false},
"s3AccessKeyId":"...","s3SecretAccessKey":"..."}'
curl -X POST https://<панель>/api/storage-configs/s3-uploads/test -b <cookie сессии>
Для ключей нужен STORAGE_ENCRYPTION_KEY.
- Пропишите в
.envи перезапустите панель:
UPLOADS_STORAGE_ID=s3-uploads
Перенос существующих файлов
npm run storage:migrate-uploads -- --dry-run # что будет залито
npm run storage:migrate-uploads # залить и переключить media_files на S3
npm run storage:migrate-uploads -- --delete-local # удалить локальные копии (см. ограничение)
| Флаг | Назначение |
|---|---|
--target <id> | Хранилище (по умолчанию UPLOADS_STORAGE_ID) |
--dir <папка> | Папка загрузок (по умолчанию uploads) |
--dry-run | Только показать план |
--delete-local | Удалять файл с диска после проверки размера в бакете |
- Переносится вся папка, включая файлы без записи в
media_files(на них могут ссылаться узлы). - Файлы в корне
uploads/(README) и скрытые файлы пропускаются. - Повторный запуск безопасен: файлы с тем же размером в бакете пропускаются.
- После заливки размер проверяется; при несовпадении файл считается ошибкой и не удаляется.
- Скрипт завершается с кодом 1, если были ошибки.
Перед переносом на проде снимите бэкап базы (DB_BACKUPS.md).
Чтобы новые загрузки шли в S3
Сделайте хранилище активным: PATCH /api/storage-configs/s3-uploads с {"isActive": true}. Откат — сделать активным local-default; адреса в проектах при этом не меняются.
Локальная разработка на SeaweedFS
./weed server -dir=data -s3 -s3.port=8333 -s3.config=s3.json \
-master.volumeSizeLimitMB=256 -volume.max=64 -ip=127.0.0.1
echo "s3.bucket.create -name tbb-uploads" | ./weed shell -master=127.0.0.1:9333
Каждому бакету нужны свои тома. При малом -volume.max (например, 8) запись в новый бакет падает с failed to find writable volumes, а S3 отвечает «We encountered an internal error».
Хранилище для SeaweedFS: endpointUrl: http://127.0.0.1:8333, forcePathStyle: true, region: us-east-1.
Кэш удаляет старые файлы, когда их больше не удерживает задача отправки. Если предел занят используемыми файлами, бот сообщает об ошибке. По умолчанию предел составляет 512 МиБ на процесс; штатное завершение удаляет временный каталог. После аварийной остановки оставшиеся каталоги кэша можно удалить после остановки соответствующих ботов. Для проверки доступности выбранного S3 нужна возможность HeadBucket; результат успешной проверки кэшируется на пять секунд.
Для картинки счёта Telegram задайте публичный HTTPS-адрес панели в MEDIA_PUBLIC_BASE_URL: сервер выдаёт ссылку на один файл сроком на пять минут. Поддерживаются JPEG и PNG до 10 МиБ. Автономному экспорту без публичного сервера такой адрес недоступен.