Skip to content

ТЗ — Объектное хранилище (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 в queryHMAC-подпись с TTL signed_url_ttl_minutes; путь файла не последовательный (UUID/hash в медиатеке), подпись и срок проверяются при каждом обращении

Примечание: всё, что не перечислено выше как вход, отвергается — попытка передать ключ/секрет доступа через API настроек не проходит валидацию схемы (поля не существует), операции с диском вне контрактов storage-backend (например, прямой вызов SDK S3 в обход модуля из другого пакета) — запрещённый анти-паттерн («скрытая зависимость», см. data-exchange.md).

Выходы

ПотребительДанныеФормат
MediaService/медиатека ядрафайл (stream) или публичный/подписанный URLLaravel Filesystem-контракт (get, url, temporaryUrl)
cms/backupфайл архива, записанный на backup_diskтот же Filesystem-контракт
cms/health / GET /api/v1/system/healthдоступность бакета, права записиhealth-чек JSON
Filament (прогресс миграции)события StorageMigrationStarted/StorageMigrationCompletedpayload события, опрос статуса джобы
cms/audit (если включен)событие StorageDiskSwitchedзапись критичной операции в аудит-журнал
Посетитель (приватный файл)временная подписанная ссылка на файлпрямой URL к S3-совместимому провайдеру с подписью и TTL, не проксируется через приложение

Настройки (группа storage-s3)

КлючТипДефолтaffectsPageCacheОписание
storage-s3.default_diskstring"local"Диск по умолчанию для новых файлов медиатеки (local/s3)
storage-s3.backup_diskstring"local"Диск для бэкапов (может отличаться от медиа)
storage-s3.signed_url_ttl_minutesint15Время жизни подписанного URL для приватных файлов
storage-s3.endpoint, .bucket, .regionstringНе секреты сами по себе, но ключ/секрет доступа — только .env
storage-s3.migrate_chunk_sizeint200Размер батча файлов за один чекпоинт миграции
storage-s3.health_check_interval_minutesint10Периодичность фонового health-чека доступности бакета
storage-s3.public_base_urlstringnullПереопределение публичного URL (например, если перед бакетом стоит CDN/свой домен)
storage-s3.disk_switch_confirmation_requiredbooltrueТребовать подтверждение при смене диска по умолчанию (диалог, не аварийный выключатель)
storage-s3.max_file_size_mbint100Максимальный размер одного загружаемого файла на диск
storage-s3.storage_quota_gbint, nullablenullМягкая квота суммарного объёма бакета; null/0 — без квоты
storage-s3.emergency_local_fallbackboolfalseKill-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/backupprovides-контракт storage-backend (канал 3)inбэкап пишет архив на выбранный backup_disk через тот же Filesystem-контракт
core: настройки (SettingChanged, группа storage-s3)событие (канал 1)inмодуль перечитывает default_disk/backup_disk (кеш настроек сброшен ядром)
core: медиатека (MediaDeleted)событие (канал 1)inмодуль удаляет физический объект в бакете (участие в каскаде «забыть по запросу», см. ПДн-паспорт)
cms/healthhealth-чек / сервис-вызов (канал 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) — только .envconfig(), никогда в 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.viewstorage-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-чеке.

Внутренняя база знаний студии