Тема
ТЗ — Объектное хранилище (S3) (cms/storage-s3)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Драйвер S3-совместимых объектных хранилищ (Selectel, Yandex Object Storage) для медиа, бэкапов и экспортов. Регистрируется как поставщик backend-а хранения — модули (медиатека ядра, cms/backup) выбирают его как диск через конфигурацию, не завязываясь на конкретного провайдера напрямую.
- Драйвер S3-совместимого диска (Selectel S3, Yandex Object Storage, любой S3 API);
provides: storage-backend— медиатека и бэкапы используют его как обычный Laravel-диск;- команда миграции файлов
local → S3и обратно с прогресс-баром и возобновлением; - подписанные (signed) временные URL для приватных файлов;
- проверка доступности и прав бакета в рамках health-чека;
- раздельная конфигурация бакетов для медиа и бэкапов (разные политики retention).
Зависимости и выключение
requires: — · provides: storage-backend · выключение возвращает диски на локальное хранилище (если так сконфигурировано) — файлы, уже загруженные в S3, не удаляются и остаются доступны по прямому URL до следующей миграции обратно.
Поведение при выключении: медиатека и бэкапы продолжают работать на диске local, если он сконфигурирован как fallback; ранее загруженные в S3 файлы не теряются, но новые операции идут на локальный диск.
Стоимость внешних API. Egress-трафик и число запросов к API провайдера (Selectel, Yandex Object Storage) тарифицируются — без CDN перед бакетом заметная статья расходов при большом объёме; расход вне контроля модуля (биллинг провайдера), но модуль обязан рекомендовать public_base_url/CDN для снижения egress («UX-требования»).
Модель данных
Своих таблиц нет — использует стандартную модель Laravel-дисков (config/filesystems.php) и таблицу медиатеки ядра для хранения пути/диска файла. Модуль регистрируется не как значение enum-столбца, а как именованный диск через provides: storage-backend + DI — ядро резолвит реализацию Filesystem по имени диска через DI-контейнер, отдельного DiskRegistry в каноническом реестре контрактов нет и не будет (закрыто ревизией ядра 14.07.2026, п.9).
ПДн-паспорт. Своего реестра ПДн модуль не ведёт — не знает семантику файла (документ, фото пользователя, обезличенный ассет), это ответственность медиатеки ядра и модулей-владельцев контента. Физически хранит байты файлов, часть которых может быть ПДн, и участвует в каскаде «забыть по запросу» (152-ФЗ) на правах исполнителя: см. MediaDeleted в «События и обмен» и «Безопасность».
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
GET/PUT /api/v1/admin/settings/storage-s3 (settings-store ядра) | default_disk, backup_disk, signed_url_ttl_minutes, endpoint, bucket, region | схема настроек ядра, право storage-s3.manage (studio); ключ/секрет доступа через этот эндпоинт не принимаются — таких полей в схеме нет, только .env |
cms:storage-s3:migrate {--to=s3-или-local} (CLI/Filament) | направление миграции (to) | enum-валидация s3/local, только studio-роль, повторный запуск при активной джобе для той же пары дисков отклоняется (409) |
cms:storage-s3:doctor (CLI/scheduler) | — | health-чек: реальная запись/чтение тестового файла в бакет |
Вызов storage-backend (канал 3) от MediaService ядра | файловый поток + путь на загрузку/чтение/удаление | вызывающая сторона (медиатека) валидирует MIME/размер до вызова — модуль исполняет операцию диска, не переоткрывает валидацию содержимого |
Вызов storage-backend (канал 3) от cms/backup | архив бэкапа (поток) + путь | cms/backup формирует путь/имя файла, модуль пишет на backup_disk |
GET по подписанному (signed) URL приватного файла | path, expires, signature в query | HMAC-подпись с TTL signed_url_ttl_minutes; путь файла не последовательный (UUID/hash в медиатеке), подпись и срок проверяются при каждом обращении |
Примечание: всё, что не перечислено выше как вход, отвергается — попытка передать ключ/секрет доступа через API настроек не проходит валидацию схемы (поля не существует), операции с диском вне контрактов storage-backend (например, прямой вызов SDK S3 в обход модуля из другого пакета) — запрещённый анти-паттерн («скрытая зависимость», см. data-exchange.md).
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
MediaService/медиатека ядра | файл (stream) или публичный/подписанный URL | Laravel Filesystem-контракт (get, url, temporaryUrl) |
cms/backup | файл архива, записанный на backup_disk | тот же Filesystem-контракт |
cms/health / GET /api/v1/system/health | доступность бакета, права записи | health-чек JSON |
| Filament (прогресс миграции) | события StorageMigrationStarted/StorageMigrationCompleted | payload события, опрос статуса джобы |
cms/audit (если включен) | событие StorageDiskSwitched | запись критичной операции в аудит-журнал |
| Посетитель (приватный файл) | временная подписанная ссылка на файл | прямой URL к S3-совместимому провайдеру с подписью и TTL, не проксируется через приложение |
Настройки (группа storage-s3)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
storage-s3.default_disk | string | "local" | — | Диск по умолчанию для новых файлов медиатеки (local/s3) |
storage-s3.backup_disk | string | "local" | — | Диск для бэкапов (может отличаться от медиа) |
storage-s3.signed_url_ttl_minutes | int | 15 | — | Время жизни подписанного URL для приватных файлов |
storage-s3.endpoint, .bucket, .region | string | — | — | Не секреты сами по себе, но ключ/секрет доступа — только .env |
storage-s3.migrate_chunk_size | int | 200 | — | Размер батча файлов за один чекпоинт миграции |
storage-s3.health_check_interval_minutes | int | 10 | — | Периодичность фонового health-чека доступности бакета |
storage-s3.public_base_url | string | null | — | Переопределение публичного URL (например, если перед бакетом стоит CDN/свой домен) |
storage-s3.disk_switch_confirmation_required | bool | true | — | Требовать подтверждение при смене диска по умолчанию (диалог, не аварийный выключатель) |
storage-s3.max_file_size_mb | int | 100 | — | Максимальный размер одного загружаемого файла на диск |
storage-s3.storage_quota_gb | int, nullable | null | — | Мягкая квота суммарного объёма бакета; null/0 — без квоты |
storage-s3.emergency_local_fallback | bool | false | — | Kill-switch: мгновенный принудительный возврат всех операций на локальный диск без похода в S3, без изменения default_disk (см. «Крайние случаи») |
Лимиты и квоты. max_file_size_mb — отказ до похода в SDK, понятная ошибка («Файл превышает допустимый размер {max} МБ»), не 500 и не обрезание потока. storage_quota_gb — мягкий лимит: существующие файлы не блокируются, новая загрузка при достижении квоты получает «Хранилище заполнено, обратитесь к администратору» и метрику использования в health-чеке, без тихой записи сверх лимита.
Kill-switch. disk_switch_confirmation_required — диалог подтверждения, не аварийный механизм. emergency_local_fallback — собственно kill-switch: при деградации бакета мгновенно переключает все операции на локальный диск, не меняя default_disk — выключение флага возвращает штатное поведение без миграции.
API
Отдельного публичного API нет — админ-CRUD через Filament (выбор диска в настройках, запуск миграции файлов). Работа с файлами — через существующее API медиатеки ядра (/api/v1/admin/media/…), этот модуль не добавляет собственных путей.
Компоненты
Filament: страница выбора активного диска (с подтверждением смены), прогресс миграции local ↔ S3 (прогресс-бар, доступен как обычной странице, так и через --json команды — единый источник состояния джобы). Команды: cms:storage-s3:migrate {--to=s3|local} --json (перенос файлов с прогрессом и возобновлением), cms:storage-s3:doctor --json (проверка доступности бакета и прав записи).
Демо-контент: не применимо — модуль не имеет собственных блоков/виджетов для галереи /_gallery, демо-сидер не требуется.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
StorageMigrationStarted | Запущена миграция файлов между дисками | from_disk, to_disk, total_files |
StorageMigrationCompleted | Миграция файлов завершена | from_disk, to_disk, migrated_count, failed_count |
StorageDiskSwitched | Изменён диск по умолчанию в настройках | disk |
Реализует канонический контракт storage-backend — потребляется медиатекой ядра и cms/backup через DI (модули не знают, что за диском конкретный провайдер S3). FilterBus не используется. Слушает собственные команды миграции файлов и канонические события удаления ядра.
Слушает MediaDeleted (ревизия ядра 14.07.2026, п.3): обязан реально удалить объект в бакете, а не только позволить медиатеке убрать запись — иначе файл (потенциально с ПДн) физически остаётся в S3 после каскада «забыть по запросу» (152-ФЗ). Обработка идемпотентна; отсутствие объекта в бакете не блокирует каскад.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
core: медиатека (MediaService) | provides-контракт storage-backend (канал 3) | in | медиатека резолвит диск через DI-реализацию модуля, аплоад/чтение/удаление файла |
cms/backup | provides-контракт storage-backend (канал 3) | in | бэкап пишет архив на выбранный backup_disk через тот же Filesystem-контракт |
core: настройки (SettingChanged, группа storage-s3) | событие (канал 1) | in | модуль перечитывает default_disk/backup_disk (кеш настроек сброшен ядром) |
core: медиатека (MediaDeleted) | событие (канал 1) | in | модуль удаляет физический объект в бакете (участие в каскаде «забыть по запросу», см. ПДн-паспорт) |
cms/health | health-чек / сервис-вызов (канал 4) | out | доступность бакета и права записи попадают в /api/v1/system/health |
cms/audit (если включен) | событие StorageDiskSwitched (канал 1) | out | смена диска по умолчанию фиксируется в аудит-журнале как критичная операция |
| Filament / CLI (админ) | события StorageMigrationStarted/Completed (канал 1) | out | прогресс миграции отображается в UI и в --json-выводе команды |
Любая связь вне этой таблицы — скрытая зависимость и анти-паттерн (например, прямой вызов S3 SDK из другого модуля в обход storage-backend — запрещён).
Фоновая работа
Очередь storage-s3: миграция файлов между дисками — джоба с чекпоинтами (возобновляема после обрыва, идемпотентна через журнал уже перенесённых файлов, батчами по storage-s3.migrate_chunk_size). Внешние вызовы к S3 API — только из очереди, кроме health-чека (короткий синхронный запрос с таймаутом).
Миграция без даунтайма (двойное чтение). Запись новых файлов с момента старта сразу идёт на целевой диск (не после завершения переноса — иначе окно, где новые файлы пишутся на диск, который скоро станет неактуальным). Чтение сначала пробует целевой диск; при промахе — падает на исходный и переносит файл лениво по факту обращения либо оставляет плановому батчу.
Целостность (integrity). После переноса каждого файла джоба сверяет контрольную сумму (ETag S3-объекта или локальный hash) с исходной; файл считается перенесённым только при совпадении, расхождение помечает его failed в отчёте миграции без удаления на исходном диске.
Производительность и кеш
Ожидаемые объёмы: медиатека парка сайтов студии — порядок 10⁴–10⁶ файлов суммарно, ежедневный прирост — сотни файлов; бэкапы — один архив на сайт в сутки, от десятков МБ до нескольких ГБ на архив.
Горячие пути и бюджет: генерация URL файла (
url()/temporaryUrl()) не добавляет запросов сверх обычного обращения к медиатеке — путь и диск уже в записи; чтение диска по умолчанию — из кеша settings-store ядра (0 запросов на горячем пути записи файла).Индексы: собственных таблиц у модуля нет; критичен индекс по колонке
diskв таблице медиатеки ядра — используется миграцией для батчевой выборки файлов на перенос (ответственность за индекс у ядра, модуль только потребляет выборку черезMediaService/ContentRepository, не raw SQL).Что кешируется: факт доступности бакета не кешируется —
cms:storage-s3:doctorделает реальный запрос при каждом вызове (не полагается на устаревший статус, чтобы не скрыть деградацию). Публичные URL файлов кешируются на уровне CDN/браузера (Cache-Controlот провайдера), не этим модулем.Теги и инвалидация: собственных тегов кеша нет — диск не кешируемая сущность; смена
default_diskне требует инвалидации чужого кеша, применяется только к новым файлам (уже загруженные хранят свой диск в записи медиатеки).Lifecycle-политики: если провайдер поддерживает классы хранения (Standard/IA/ холодный, аналог Glacier) — настройка перевода старых файлов бэкапов (старше N дней) в холодный класс для экономии; для медиафайлов сайта не применяется — нужны для мгновенной отдачи посетителю, задержка/доплата за восстановление недопустима.
Ранбук (эксплуатация, §15):
Симптом Диагностика/команда Health красный (бакет недоступен) cms:storage-s3:doctor --json— реальная запись/чтение тестового файлаМиграция зависла на чекпоинте Проверить статус джобы через cms:storage-s3:migrate --to=... --json, при обрыве — повторный запуск возобновляет с последнего чекпоинтаФайлы не открываются у посетителей при живом сайте Проверить storage-s3.public_base_url/CDN и доступность бакета напрямую (health отделён от факта «сайт жив», см. «Крайние случаи»)Ошибочно переключён диск по умолчанию Откат через настройку default_disk+ при необходимости обратная миграция (переключение диска не переносит уже загруженные файлы)Массовая деградация бакета (провайдер лёг) Включить storage-s3.emergency_local_fallback(kill-switch, без измененияdefault_disk), выключить после восстановления
Безопасность
Filament: выбор диска — только studio-роль (влияет на весь сайт). Signed URL — подпись с TTL, приватный файл недоступен после истечения срока. Секреты доступа (ключ/секрет S3) — только .env → config(), никогда в settings-store и не логируются. Health-чек делает реальную запись/чтение тестового файла в бакет, не полагается только на код ответа API.
Векторы атак, специфичные для модуля:
- Энумерация приватных файлов — путь файла в медиатеке строится как UUID/hash, не последовательный ID, поэтому перебор путей неосуществим без утечки самого пути; подпись signed URL — HMAC с TTL, попытка подобрать валидную подпись по времени — вычислительно неосуществима при достаточной длине ключа.
- Шаринг подписанной ссылки за пределами системы — если пользователь переслал signed URL третьему лицу, доступ действует до истечения
signed_url_ttl_minutes, после чего ссылка перестаёт работать для всех, у кого она есть, без возможности отзыва раньше срока (компромисс TTL-подхода — короткий TTL снижает окно риска, но не даёт мгновенного отзыва). - Path traversal при формировании S3-ключа — путь файла приходит от медиатеки/бэкапа, модуль обязан нормализовать и проверять, что итоговый ключ не выходит за пределы сконфигурированного префикса (
media/,backups/), даже если вызывающая сторона передала путь с../. - Утечка кредов через настройки/API — ключ/секрет доступа никогда не возвращаются в ответе
GET /api/v1/admin/settings/storage-s3(только.env, замаскировано или вовсе отсутствует в схеме группы) и не попадают в логи очереди/health-чека. - Публичный ACL приватного бакета — health-чек проверяет не только доступность (запись/чтение), но и что бакет для приватных файлов не отдаёт листинг/файлы анонимно; несоответствие — красный health, а не молчаливая утечка.
ПДн-контекст (паспорт — «Модель данных»): реестра ПДн модуль не ведёт, но обязан реально удалять объект в бакете по MediaDeleted — иначе ПДн физически остаются в S3 после «забыть по запросу» (152-ФЗ), даже если запись в медиатеке уже исчезла.
Матрица ролей.
| Роль | storage-s3.view | storage-s3.manage |
|---|---|---|
| Гость / посетитель | нет | нет |
| Пользователь (кабинет) | нет | нет |
| Менеджер | нет | нет — настройки диска не относятся к операционному контенту |
| Редактор | нет | нет |
| Studio | да | да — единственная роль с доступом |
Только studio: смена диска влияет на весь сайт целиком (не на отдельную сущность контента) и требует доверия уровня «может сломать доступность файлов всем посетителям» — осознанно вне компетенции менеджера/редактора, в отличие от типового контентного CRUD.
UX-требования
Админ:
- Пустое состояние — миграция ещё не запускалась: «Файлы хранятся локально. Включите S3 в настройках и запустите миграцию, чтобы перенести существующие файлы».
- Массовые действия на списках — не применимо: модуль не имеет собственных списков сущностей (только процесс миграции с прогрессом и журнал бэкапов принадлежит
cms/backup); единственное «массовое» действие — сама миграция, которая уже по умолчанию батчевая. - Человеческие ошибки: вместо трассировки SDK — «Не удалось подключиться к хранилищу. Проверьте
endpoint/ключи доступа в.envи повторите». При загрузке файла редактором во время недоступности S3 — «Хранилище временно недоступно, попробуйте загрузить файл ещё раз через пару минут» (см. «Крайние случаи»). - Подтверждение необратимых по последствиям операций: смена диска по умолчанию — подтверждение с пояснением «Новые файлы будут сохраняться на {disk}. Уже загруженные файлы останутся на прежнем диске и продолжат открываться — откат настройки не переносит файлы автоматически, для переноса нужна отдельная миграция» (гейтится
storage-s3.disk_switch_confirmation_required). - Подсказка при включении S3 без
public_base_url: «Рекомендуем подключить CDN/свой домен перед бакетом — это снижает egress-трафик и его стоимость».
Посетитель: прямого взаимодействия с админкой модуля нет, но есть косвенное влияние — скорость отдачи файлов (S3 напрямую или через CDN — обычно быстрее и не создаёт нагрузку на origin-сервер приложения) и доступность приватных файлов по временным ссылкам. При истечении TTL подписанной ссылки посетитель должен увидеть понятную страницу отказа («Ссылка на файл истекла»), а не голый XML/JSON-ответ провайдера S3.
Крайние случаи и типовые баги
- Недоступность S3 при новой загрузке файла → редактор видит понятную ошибку «Хранилище недоступно, попробуйте позже» до создания записи в медиатеке; частично загруженный/орфанный файл в бакете и осиротевшая запись без файла — недопустимы (запись создаётся только после подтверждения успешной записи в бакет).
- Недоступность S3 для уже опубликованных файлов → приложение это не примечает напрямую: публичные файлы отдаются посетителю по прямому URL провайдера/CDN, минуя приложение, поэтому обычный HTTP-запрос страницы отработает штатно, а вот сам файл (изображение/документ) не откроется у посетителя; health-чек фиксирует деградацию бакета и алертит админа отдельно от факта, что сайт «жив».
- Гонка миграции и редактирования файла — файл перезаписан или удалён редактором, пока джоба миграции ставила его в очередь на перенос → джоба перед переносом сверяет контрольную сумму/mtime с зафиксированными на момент постановки в очередь; при расхождении файл пропускается с warning в журнале миграции (не перезаписывает новую версию старой), актуальная версия подхватывается следующим прогоном.
- Двойной запуск миграции (два клика «Мигрировать», параллельный CLI и Filament) → повторный запуск при уже активной джобе для той же пары дисков отклоняется (409/«миграция уже выполняется»), не создаёт вторую параллельную джобу поверх той же очереди файлов.
- Выключение модуля посреди миграции → активная джоба доигрывает текущий чекпоинт (не обрывается на полпути файла), при следующем включении модуля миграция возобновляется с последнего сохранённого чекпоинта, не с нуля.
- Огромный объём при первой миграции (миллионы файлов) → список файлов на перенос выбирается и обрабатывается батчами по
storage-s3.migrate_chunk_size, не загружается в память целиком; прогресс виден админке по мере обработки батчей, не только по завершении. - Истёкшая подписанная (signed) ссылка, расшаренная за пределами системы → посетитель получает явную страницу отказа (403 с человеческим текстом «Ссылка истекла»), не голый XML-ответ S3; повторный доступ требует новой подписанной ссылки, сгенерированной заново тем, у кого есть право на файл (перебор старого пути не помогает — см. «Безопасность»).
- Противоречивая комбинация настроек (
default_disk = s3, но креды не заданы в.env) → health-чек красный сразу при попытке применить настройку (self-test), новая загрузка файла отдаёт понятную ошибку «хранилище не сконфигурировано» до похода в SDK, не падает необработанным исключением на середине запроса. - Смена
backup_diskво время выполнения бэкапа → текущий бэкап дописывается на диск, с которым он был начат (снапшот настройки на момент старта job), не переключается на лету на новый диск; следующий запуск бэкапа уже использует обновлённыйbackup_disk. - Достигнута
storage_quota_gb→ новая загрузка отдаёт «Хранилище заполнено» до похода в SDK, health показывает деградацию по метрике использования; уже загруженные файлы не удаляются и продолжают открываться. - Файл превышает
max_file_size_mb→ отклоняется валидацией на входе (до записи в медиатеке и похода в SDK), редактор видит размер лимита в тексте ошибки. - Расхождение ETag/hash после переноса файла миграцией → файл
failedв отчёте, не считается перенесённым, остаётся на исходном диске до следующего прогона. - Включён
emergency_local_fallbackво время активной миграции → kill-switch приоритетнее миграции: операции мгновенно уходят на локальный диск, джоба приостанавливается на чекпоинте (не падает), возобновляется после выключения флага.
Донорский код
Донор: — (новая разработка); легаси-импорт в смысле §16 не применим — нет исходной системы хранения с внешней схемой для маппинга. Перенос local ↔ S3 покрыт штатной командой cms:storage-s3:migrate {--to=} (см. «Фоновая работа»), которая играет ту же роль без внешней схемы данных для сопоставления.
Тесты и приёмка
- [ ] Контрактные тесты: health-чек проверяет реальную запись/чтение тестового файла в бакет;
- [ ] деградация при выключении — медиатека и бэкапы падают на локальный диск без потери уже загруженных файлов;
- [ ] миграция файлов возобновляема после обрыва (не начинает с нуля);
- [ ] signed URL не даёт доступ к приватному файлу после истечения TTL;
- [ ] секреты доступа (ключ/секрет S3) не попадают в settings-store, только в
.env; - [ ] смена диска по умолчанию не ломает ссылки на уже загруженные файлы (путь хранит свой диск);
- [ ] гонка «миграция vs редактирование»: файл, изменённый после постановки в очередь, не перезаписывается старой версией — миграция пропускает его и логирует расхождение;
- [ ] двойной запуск миграции для одной пары дисков отклоняется, не создаёт вторую джобу;
- [ ] новая загрузка при недоступном S3 не создаёт осиротевшую запись в медиатеке без файла;
- [ ] попытка передать ключ/секрет доступа через API настроек отклоняется схемой (поля нет);
- [ ] путь файла с
../не выходит за пределы сконфигурированного префикса бакета; - [ ] контрактный набор
cms-testingзелёный, пакет протестирован в testbench-изоляции; - [ ] feature-тест на каждый роут API; тестовая БД только
storage-s3_test,migrate:fresh/refresh/resetзапрещены; - [ ]
MediaDeletedприводит к реальному удалению объекта в бакете (не только записи в медиатеке), повторная доставка события — идемпотентна; - [ ]
emergency_local_fallbackмгновенно переключает операции на локальный диск и не изменяетdefault_diskв настройках; - [ ] ETag-сверка ловит расхождение после переноса — файл
failed, не считается перенесённым, не удаляется на исходном диске; - [ ] загрузка файла больше
max_file_size_mbотклоняется понятной ошибкой до похода в SDK, не создаёт запись в медиатеке; - [ ] достижение
storage_quota_gbотдаёт понятную ошибку на новой загрузке (не 500), не блокирует существующие файлы, отражается в health-чеке.