Тема
ТЗ — Многосайтовость (cms/multisite)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Несколько логических сайтов в одной установке ядра с общей БД: контент, настройки и меню получают измерение site_id, домен резолвится в сайт на входе запроса, пользователи общие для всех сайтов. В отличие от cms/multitenancy данные не изолируются схемой — это витрины одного бизнеса, а не отдельные клиенты.
- Резолвинг текущего сайта по домену/поддомену на раннем этапе запроса;
site_idкак измерение в контенте, настройках, меню, виджетах;- общие пользователи и роли между сайтами (без дублирования аккаунтов);
- per-site тема и набор активных модулей (если это разрешено манифестом модуля);
- переключатель сайта в админке; fallback-контент для сайта без своей версии записи.
Зависимости и выключение
requires: — · provides: multisite · conflicts: одновременное включение с cms/multitenancy требует явного выбора режима (см. cms/multitenancy) — оба модуля не работают в одном контуре без ручной интеграции. При выключении установка возвращается к единственному сайту: site_id перестаёт учитываться в запросах (данные не удаляются, но становятся недоступны как отдельные сайты).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_sites | domain, name, theme, is_default, settings_overrides (json) | Реестр сайтов установки |
cms_site_domain_aliases | site_id, domain | Доп. домены/поддомены на один сайт |
cms_site_editors | site_id, user_id | Per-site права: кто из нештатно-глобальных ролей допущен к конкретному сайту — см. «Безопасность» |
Измерение site_id (nullable FK) добавляется в таблицы контент-движка ядра (pages, content_entries, menus, widgets) миграцией модуля при включении. settings_overrides — JSON-столбец с cast array. site_id — nullable()->constrained('cms_sites')->index() на всех таблицах-потребителях измерения; cms_site_domain_aliases.domain — уникальный индекс (домен не может принадлежать двум сайтам); cms_site_editors — обе колонки constrained()->index(), уникальная пара (site_id, user_id) — повторное назначение того же редактора тому же сайту не создаёт дубль.
Общая медиатека vs изолированная. multisite.shared_media_library=true (дефолт) — измерение site_id в медиатеке не применяется, файлы видны на всех сайтах одинаково; false — измерение обязано появиться в самой медиатеке (данные и миграция cms/media, не этого модуля): cms/multisite лишь публикует текущий site_id через RequestContext, скоуп реализует владелец по пятиканальному правилу обмена (§5), без raw SQL к чужим таблицам отсюда.
ПДн: модуль не хранит персональных данных — только домены и user_id как FK на пользователя ядра, не сами ПДн (152-ФЗ хуки «выгрузить/забыть» не применимы).
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Filament / API — создание/редактирование сайта (POST/PUT .../sites) | domain, name, theme, is_default, settings_overrides | FormRequest: domain — required|string|max:255|unique:cms_sites,domain (себя же исключая при update); theme — существует в реестре тем ядра (GET /api/v1/admin/themes); settings_overrides — валидируется схемой SettingsStore группы multisite, не произвольный JSON |
Filament / API — домен-алиас (в составе POST/PUT .../sites/{id}) | site_id, domain | FormRequest: домен уникален по cms_site_domain_aliases.domain и не пересекается с cms_sites.domain любого сайта; формат домена (RFC 1035), без протокола/пути |
| HTTP-запрос, каждый запрос (не форма) | заголовок Host | Middleware резолвинга сверяет исключительно со значениями из кеша multisite.sites (whitelist доменов и алиасов) — сырое значение Host никогда не доверяется напрямую (см. «Безопасность») |
Команда cms:multisite:assign-site-id --site={id} --table={t} (миграция legacy-контента) | целевой site_id, диапазон сущностей | Существование site_id в cms_sites; идемпотентность повторного запуска (уже проставленный site_id не перезаписывается без --force) |
DELETE /api/v1/admin/multisite/sites/{id} | id сайта | Проверка отсутствия уникального контента без fallback (409, не 500) |
POST /api/v1/admin/multisite/sites/{id}/pages/{page_id}/copy-to/{target_site_id} | id, page_id, target_site_id | FormRequest: target_site_id — exists:cms_sites,id, отличен от текущего site_id страницы; право — multisite.manage либо per-site право через SiteScope::authorize() (см. «Безопасность») |
Всё, что не входит в этот список (произвольные query-параметры, лишние поля тела запроса, недекларированные ключи settings_overrides), отвергается на уровне FormRequest — whitelist-принцип §11 стандарта.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
Ядро — RequestContext | site_id резолвленного сайта | provides-контракт multisite, вычисляется на каждый запрос до контроллеров |
cms/domains | site_id привязки домена | сервис-вызов по requires (домены читают сайт через сервис модуля, не raw SQL) |
| Filament — переключатель контекста сайта | список сайтов, текущий выбранный | компонент шапки админки, JSON для UI |
cms:multisite:resolve --json | результат резолвинга домена → site_id (или причина отказа) | JSON |
GET /api/v1/admin/multisite/sites | список сайтов установки | конверт {data, meta}, keyset-пагинация |
| Шина событий | SiteCreated / SiteResolved / SiteDomainChanged / SiteDomainRemoved | payload — см. «События и обмен» |
Настройки (группа multisite)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
multisite.fallback_to_default | bool | true | ✅ | Показывать контент дефолтного сайта при отсутствии своей версии |
multisite.shared_media_library | bool | true | — | Общая медиатека между сайтами |
multisite.strict_domain_match | bool | false | ✅ | Требовать точное совпадение домена (без fallback на дефолтный сайт) |
multisite.max_sites | int | 20 | — | Лимит числа сайтов на установку (kill-switch от неконтролируемого роста) |
multisite.emergency_single_site_mode | bool | false | ✅ | Аварийный kill-switch: игнорировать site_id и отдавать всем доменам дефолтный сайт без выключения модуля (данные о сайтах сохраняются) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/multisite/sites | multisite.manage | Список сайтов установки (keyset-пагинация) |
| POST/PUT | /api/v1/admin/multisite/sites, /sites/{id} | multisite.manage | Создание/редактирование сайта, доменов-алиасов |
| DELETE | /api/v1/admin/multisite/sites/{id} | multisite.manage | Удаление сайта (с проверкой отсутствия уникального контента) |
| POST | /api/v1/admin/multisite/sites/{id}/pages/{page_id}/copy-to/{target_site_id} | multisite.manage либо per-site право | Копирование страницы на другой сайт установки (см. «Компоненты», «Крайние случаи») |
Компоненты
Filament: ресурс сайтов (домены, тема, дефолтность), переключатель контекста сайта в шапке админки, скоуп списков контента по текущему сайту, действие «скопировать на другой сайт» в ресурсе страниц (новая запись с site_id целевого сайта, копия ревизии/блоков, без site-специфичных полей — канонический домен, settings_overrides; конфликт слага см. «Крайние случаи»). Команды: cms:multisite:resolve --json (диагностика резолвинга домена в сайт), cms:multisite:doctor --json, cms:multisite:assign-site-id (легаси-миграция, см. «Донорский код»). Демо-сидеры: 2–3 демо-сайта с разными доменами/темами в playground/_gallery — переключатель контекста виден без ручного создания сайтов админом.
Мини-ранбук (§15): «посетитель получил не тот сайт» → cms:multisite:resolve --json <domain> → сверить с cms_site_domain_aliases, при недавнем удалении алиаса — проверить, что издан SiteDomainRemoved и тег multisite.sites инвалидирован; «дубли слагов между сайтами перед выключением модуля» → cms:multisite:doctor --json → развести конфликты до disable, иначе после выключения коллизия ломает уникальный индекс ядра. Бэкап/рестор: в бэкап входят cms_sites, cms_site_domain_aliases, cms_site_editors; кеш реестра (multisite.sites) не бэкапится — пересоздаётся из БД при первом промахе после рестора, отдельной команды не требует.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
SiteCreated | Создан новый сайт | site_id, domain |
SiteResolved | Запрос успешно определил сайт | site_id, domain |
SiteDomainChanged | Заменён домен/алиас на другой (не удаление) | site_id, old_domain, new_domain |
SiteDomainRemoved | Домен-алиас удалён без замены | site_id, domain |
Реализует канонический контракт multisite — потребляется ядром на раннем этапе запроса (резолвинг RequestContext.site_id) и модулем cms/domains (привязка домена к сайту). FilterBus не используется — резолвинг однократный, не конвейерный. Слушает только собственные HTTP-запросы (middleware резолвинга).
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро — RequestContext | provides-контракт multisite | out | модуль резолвит домен → site_id, ядро подставляет его в контекст запроса до контроллеров и до попадания в ключи кеша |
| Ядро — страницы/записи/меню/виджеты | измерение site_id (nullable FK, §4 стандарта) | out | владелец таблиц (ядро) скоупит свои репозитории по site_id из RequestContext; сам модуль в чужие таблицы не пишет и не читает напрямую |
Ядро — SettingsStore | измерение site_id для settings_overrides | out | per-site override читается из кеша группы настроек по текущему site_id, без похода в БД |
cms/domains | сервис-вызов (requires у domains) + provides-контракт multisite | in | cms/domains резолвит принадлежность домена сайту через сервис модуля, не через собственный SQL к cms_sites |
cms/multitenancy | conflicts в манифесте (декларативно, не рантайм-проверка) | — | совместное включение блокируется на уровне cms:doctor/lifecycle, не событием — см. «Крайние случаи» |
Шина событий (cms/audit, если включён) | событие SiteCreated/SiteDomainChanged/SiteDomainRemoved | out | аудит изменений реестра сайтов (кто добавил/изменил/удалил домен) |
Любая связь вне этой таблицы — скрытая зависимость (анти-паттерн §5 стандарта).
Фоновая работа
Фоновой работы нет — резолвинг сайта синхронный, на каждом запросе через middleware.
Производительность и кеш
- Ожидаемые объёмы. Типичная установка — от 2 до ~15 логических сайтов (витрины одного бизнеса, не SaaS-мультитенантность на тысячи клиентов — для этого
cms/multitenancy); контент на сайт — от десятков до тысяч страниц. Реестр сайтов и доменов-алиасов — маленькая таблица, целиком помещается в кеш. - Горячий путь — резолвинг домена →
site_idна каждом публичном запросе, доRequestContextи до генерации ключей кеша: 0 запросов к БД — домен ищется в целиком закешированном реестре (тегmultisite.sites), не SQL-запросом на каждый хит. Промах кеша (сразу после инвалидации) — единственный источник SQL здесь, и он не должен попадать на горячий путь чаще, чем раз на инвалидацию. - Бюджет запросов: резолвинг — 0 запросов (кеш); список сайтов в админке — 1 запрос, keyset-пагинация (без
OFFSET); скоупинг контента поsite_id— использует индекс измерения, не добавляет отдельного запроса сверх обычного запроса ядра. - Критичные индексы (см. «Модель данных»): уникальный индекс на
cms_sites.domainиcms_site_domain_aliases.domain(целостность, не только производительность);site_id—nullable()->constrained('cms_sites')->index()на каждой таблице-потребителе измерения — без индекса скоуп по сайту превращается в полнотабличный скан. - Что кешируется: реестр
cms_sites+cms_site_domain_aliasesцеликом (тегmultisite.sites); per-sitesettings_overrides— через кеш группы настроекSettingsStore(§6 стандарта, 0 запросов на горячем пути). - Инвалидация: событиями
SiteCreated,SiteDomainChanged,SiteDomainRemoved(последнее — удаление алиаса без замены, см. «Крайние случаи»); точечная, не сброс кеша целиком. - ⚠️ Критично: все ключи page-cache и любого другого кеша контента обязаны включать
site_id. Модуль объявляет измерениеsite_id, но ключи кеша конкретных страниц и списков формирует владелец контента (ядро/другие модули) — безsite_idв ключе два сайта установки увидят закешированную страницу друг друга (кросс-сайтовая утечка, конкретный пример — см. «Крайние случаи»). Тот же принцип, что и включениеlocale/city_idв ключи при активныхcms/multilang/cms/multicity.
Безопасность
Middleware резолвинга домена — санитизация Host-заголовка, защита от подмены домена без записи в cms_site_domain_aliases; Filament — FormRequest на CRUD сайтов. Удаление сайта блокируется при наличии уникального контента без fallback.
Конкретные векторы и как модуль от них закрывается:
- Host header injection. Заголовок
Host— данные клиента, не источник истины: middleware резолвинга сверяет его исключительно со whitelist-значениями из кешаmultisite.sites(cms_sites.domain+cms_site_domain_aliases.domain); неизвестное значение не создаёт сайт «на лету» и не используется напрямую в URL/ключах кеша/письмах — дальше идёт только резолвленныйsite_id. - Подмена домена без записи в
cms_site_domain_aliases. Запрос с неизвестнымHostобрабатывается как «сайт не определён»:strict_domain_match=true— 404 на уровне ядра;false— fallback наis_default-сайт, но не создание неявной привязки. - Cache poisoning через
Host. Ключ page-cache из сырогоHostвместоsite_idпозволил бы закешировать вредоносный ответ под легитимным доменом — ключи кеша обязаны использоватьsite_idизRequestContext(уже сверенный с whitelist), никогда сырую строку заголовка.
Per-site права (переиспользуемый паттерн). RBAC ядра плоский (Shield + spatie/permission, Q11), роль не привязана к сайту. cms/multisite, не меняя ядро (§0), добавляет скоуп Policy-слоем: базовый permission (multisite.manage) — обычное spatie-право «способность вообще»; принадлежность к site_id проверяет реестр cms_site_editors (site_id, user_id). Глобальные роли (studio, админ) не нуждаются в записи — трактуются как «все сайты»; редактор без глобального флага обязан иметь запись на каждый доступный сайт, иначе 403 даже при базовом permission. Точка проверки — сервис Cms\Multisite\Services\SiteScope::authorize(User $user, string $permission, int $siteId): bool (комбинирует spatie-permission и cms_site_editors; ability для логов — <permission>:site:{site_id}, например multisite.manage:site:42, не отдельная строка в permissions spatie). Сервис — сервис-вызов (канал 4, §5) для переиспользования другими per-site модулями: cms/domains вызывает его вместо своей модели прав, чтобы domains.manage давалось редактору только его сайта (до паттерна — только глобальным ролям, см. ТЗ cms/domains); при выключенном cms/multisite — деградация к «только глобальные роли».
Матрица ролей:
| Роль | multisite.view | multisite.manage | multisite.switch-context | Скоуп |
|---|---|---|---|---|
| studio | ✓ | ✓ | ✓ | все сайты установки (глобальная роль, без записи в cms_site_editors) |
| админ (клиента) | ✓ | ✓ | ✓ | все сайты установки |
| менеджер | ✓ | ✓ | ✓ | все сайты, либо подмножество по cms_site_editors, если назначен точечно |
| редактор | ✓ (назначенные) | — (не даётся по умолчанию) | ✓ (назначенные) | строго по cms_site_editors; без записи для сайта — 403 |
Права: multisite.view, multisite.manage, multisite.switch-context.
UX-требования
Админ:
- переключатель контекста сайта закреплён в шапке админки и виден на каждом экране редактирования контента — админ всегда видит, «в каком сайте» он сейчас правит (риск без индикатора — правка не в тот сайт);
- пустое состояние при первом создании сайта — список сайтов до первой записи показывает подсказку «добавьте первый сайт» с кнопкой создания, не голую таблицу (модуль после
enableтребует минимум одинis_default-сайт — см. «Крайние случаи»); - предупреждение перед удалением сайта с уникальным контентом — операция необратима (данные с этим
site_idстановятся осиротевшими); подтверждение с явным числом затрагиваемых страниц/записей, не голое «удалить?»; - массовое назначение
site_idпри миграции с однросайтовой установки — отдельный экран/команда (cms:multisite:assign-site-id) с прогрессом и dry-run-режимом, не ручная простановка по одной записи.
Посетитель:
- переключение между доменами одной установки прозрачно — общие пользователи не видят «прыжка» сессии между сайтами (см. «Крайние случаи»);
- при недоступности резолвинга (см. «Безопасность») — понятная страница «сайт не найден» вместо технической ошибки, не 500.
Крайние случаи и типовые баги
- Страница/запись/меню без
site_id(NULL) на мультисайтовой установке → по правилу измерений §4 nullable-измерение не создаёт отдельную ветку кода:site_id IS NULLтрактуется как «сайт-независимая» сущность и показывается одинаково на всех сайтах (общий контент) — отдельный случай отmultisite.fallback_to_default, который управляет показом версии дефолтного сайта при отсутствии записи с конкретным (не NULL)site_id. - Общие пользователи и роли между сайтами → базовая роль назначается пользователю, не паре пользователь-сайт, и видна одинаково на всех сайтах — ожидаемое поведение по «Назначению и возможностям», не баг, но должно быть явно показано в UI (см. «UX-требования»); тонкая настройка через
cms_site_editors(см. «Безопасность») не отменяет этого правила для глобальных ролей. - Кросс-сайтовая утечка через кеш → ключ page-cache вида
page:home(безsite_id) — запрос к сайту А кеширует главную под этим ключом, следующий запрос к сайту Б получает из кеша версию сайта А. Обязательный формат —page:{site_id}:home; то же для кешейSettingsStoreи любых списков контента, скоупленных поsite_id. - Домен-алиас удалён из
cms_site_domain_aliases→ издаётся отдельное событиеSiteDomainRemoved(site_id,domain), не перегруженныйSiteDomainChanged: последнее зарезервировано только за заменой одного домена на другой (old_domain → new_domain), удаление без замены — самостоятельный факт со своим слушателем инвалидации. Гонка между удалением записи и инвалидацией кешаmultisite.sitesзакрывается тем, что слушательSiteDomainRemovedинвалидирует тег синхронно в той же транзакции, что иDELETE, — запрос по старому домену сразу после коммита уже не резолвится в прежний сайт. - Два домена одновременно указывают на один сайт (алиасы) → штатный случай, но требует канонического URL для SEO:
cms_sites.domain— канон, алиасы отдают canonical-тег/редирект на него, не индексируются как отдельные копии контента. - Удаление сайта с общей медиатекой (
multisite.shared_media_library=true) → файлы не удаляются вместе с сайтом — они принадлежатcms/media, неcms/multisite(владение данными §5). Удаляется только записьcms_sitesи связанные алиасы/уникальный контент; проверка «есть ли уникальный контент» (API DELETE) намеренно не покрывает медиафайлы. - Выключение модуля посреди активного мультисайтового контура → по §3 стандарта деградация, не падение:
site_idперестаёт учитываться в скоупах, посетитель на любом из бывших доменов видит общий контент установки как единый сайт. Риск — коллизия уникальных слагов между бывшими сайтами (/aboutсуществовал на сайте А и на сайте Б независимо);cms:multisite:doctor --jsonобязан выявлять такие дубли до выключения и предупреждать админа, а не молча ронять уникальный индекс ядра после. - Конфликт с одновременным включением
cms/multitenancy→ манифестныйconflictsне проверяется рантаймом, только декларативно; практический сценарий — студия переводит клиента с multisite на multitenancy, оба модуля резолвятsite_id/tenant_idпо домену на одном этапе запроса без определённогоload_after. Обязательное поведение:enableвторого модуля при активном первом завершается ошибкой self-test (§3), а не «включились оба, разбирайтесь сами». - Сайт без назначенной темы (
themeпусто или ссылается на удалённую тему) → fallback на тему ядра по умолчанию (группа настроекsite) вместо 500 при рендере — прямое следствие инварианта §0 «модуль не может уронить сайт». - Пустая установка сразу после
enable(cms_sitesпуста) → резолвингу не на что резолвить; self-test при enable (§3) обязан либо создатьis_default-сайт с доменом текущей установки автоматически, либо оставить модуль вinstalledс ошибкой «создайте первый сайт», но не пускать публичные запросы в состояние «нет сайтов». - Копирование страницы на другой сайт при конфликте слага (действие/API из «Компоненты») → целевой
site_idуже имеет запись с тем же slug → не 500: 409 с человеческой ошибкой в Filament-действии либо автопереименование (-copy/-2) при массовом копировании через API, с уведомлением о присвоенном слаге; site-специфичные поля (канонический домен,settings_overrides) не переносятся — копия получает дефолты целевого сайта.
Донорский код
Донор: — (новая разработка). Форма legacy-миграции модуля (§16 стандарта) — не донор с внешними данными, а перевод однорайтовой установки на мультисайтовость: команда cms:multisite:assign-site-id --site={id} --table={t} переносит существующий контент ядра в измерение site_id, идемпотентна (без --force уже проставленный site_id не перезаписывается), поддерживает --dry-run с отчётом «сколько строк получит site_id».
Тесты и приёмка
- [ ] Контрактные тесты: резолвинг домена → корректный
site_idдля всех сценариев (алиас, fallback, strict); - [ ] health-чек модуля проверяет отсутствие дублирующихся доменов между сайтами;
- [ ] деградация при выключении — единственный сайт продолжает отдавать контент без ошибок;
- [ ] ключи page-cache включают
site_id— нет утечки контента между сайтами; - [ ] права разделяют управление сайтами и переключение контекста редактирования;
- [ ] нет N+1 при скоупинге контент-запросов по
site_id; - [ ] инвалидация page-cache учитывает конкретный
site_id, не сбрасывает все сайты разом; - [ ] контрактный тест «оба режима измерения» (§4 стандарта): сущности с
site_idзаполненным на всех записях и сущности сsite_id = NULL(одиночный сайт) проходят один и тот же контрактный набор без ветвления кода; - [ ] регрессионный тест на утечку контента между двумя сайтами через кеш — ключи page-cache и кешей настроек содержат
site_id(см. «Крайние случаи»); - [ ] удаление домена-алиаса издаёт
SiteDomainRemoved(не путается сSiteDomainChanged) и синхронно инвалидирует кеш резолвинга — нет окна, где удалённый алиас ещё резолвится; - [ ] резолвинг с неизвестным/подделанным
Hostне создаёт сайт неявно и не отравляет кеш — используется только whitelist изcms_sites/cms_site_domain_aliases; - [ ] попытка
enableпри уже активномcms/multitenancy(и наоборот) завершается ошибкой self-test, а не тихим совместным включением; - [ ]
multisite.max_sitesблокирует создание сайта сверх лимита понятной ошибкой (422/409), не 500; - [ ]
multisite.emergency_single_site_modeпереключает все домены на дефолтный сайт без потери данных оcms_sitesи без выключения модуля; - [ ]
cms:multisite:doctor --jsonобнаруживает дублирующиеся слаги между сайтами до выключения модуля; - [ ] контрактный набор
cms-testingзелёный, пакет протестирован в testbench-изоляции; - [ ] feature-тест на каждый роут API; тестовая БД только
multisite_test,migrate:fresh/refresh/resetзапрещены; - [ ] per-site права: пользователь с записью
cms_site_editorsтолько для сайта А получает 403 черезSiteScope::authorize()при попытке править сайт Б; - [ ] копирование страницы между сайтами: конфликт слага на целевом сайте обрабатывается как 409/автопереименование, не падает 500.