Skip to content

ТЗ — Многосайтовость (cms/multisite)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Несколько логических сайтов в одной установке ядра с общей БД: контент, настройки и меню получают измерение site_id, домен резолвится в сайт на входе запроса, пользователи общие для всех сайтов. В отличие от cms/multitenancy данные не изолируются схемой — это витрины одного бизнеса, а не отдельные клиенты.

  • Резолвинг текущего сайта по домену/поддомену на раннем этапе запроса;
  • site_id как измерение в контенте, настройках, меню, виджетах;
  • общие пользователи и роли между сайтами (без дублирования аккаунтов);
  • per-site тема и набор активных модулей (если это разрешено манифестом модуля);
  • переключатель сайта в админке; fallback-контент для сайта без своей версии записи.

Зависимости и выключение

requires: — · provides: multisite · conflicts: одновременное включение с cms/multitenancy требует явного выбора режима (см. cms/multitenancy) — оба модуля не работают в одном контуре без ручной интеграции. При выключении установка возвращается к единственному сайту: site_id перестаёт учитываться в запросах (данные не удаляются, но становятся недоступны как отдельные сайты).

Модель данных

ТаблицаКлючевые поляПримечание
cms_sitesdomain, name, theme, is_default, settings_overrides (json)Реестр сайтов установки
cms_site_domain_aliasessite_id, domainДоп. домены/поддомены на один сайт
cms_site_editorssite_id, user_idPer-site права: кто из нештатно-глобальных ролей допущен к конкретному сайту — см. «Безопасность»

Измерение site_id (nullable FK) добавляется в таблицы контент-движка ядра (pages, content_entries, menus, widgets) миграцией модуля при включении. settings_overrides — JSON-столбец с cast array. site_idnullable()->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_overridesFormRequest: domainrequired|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, domainFormRequest: домен уникален по cms_site_domain_aliases.domain и не пересекается с cms_sites.domain любого сайта; формат домена (RFC 1035), без протокола/пути
HTTP-запрос, каждый запрос (не форма)заголовок HostMiddleware резолвинга сверяет исключительно со значениями из кеша 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_idFormRequest: target_site_idexists:cms_sites,id, отличен от текущего site_id страницы; право — multisite.manage либо per-site право через SiteScope::authorize() (см. «Безопасность»)

Всё, что не входит в этот список (произвольные query-параметры, лишние поля тела запроса, недекларированные ключи settings_overrides), отвергается на уровне FormRequest — whitelist-принцип §11 стандарта.

Выходы:

ПотребительДанныеФормат
Ядро — RequestContextsite_id резолвленного сайтаprovides-контракт multisite, вычисляется на каждый запрос до контроллеров
cms/domainssite_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 / SiteDomainRemovedpayload — см. «События и обмен»

Настройки (группа multisite)

КлючТипДефолтaffectsPageCacheОписание
multisite.fallback_to_defaultbooltrueПоказывать контент дефолтного сайта при отсутствии своей версии
multisite.shared_media_librarybooltrueОбщая медиатека между сайтами
multisite.strict_domain_matchboolfalseТребовать точное совпадение домена (без fallback на дефолтный сайт)
multisite.max_sitesint20Лимит числа сайтов на установку (kill-switch от неконтролируемого роста)
multisite.emergency_single_site_modeboolfalseАварийный kill-switch: игнорировать site_id и отдавать всем доменам дефолтный сайт без выключения модуля (данные о сайтах сохраняются)

API

МетодПутьДоступНазначение
GET/api/v1/admin/multisite/sitesmultisite.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 резолвинга).

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
Ядро — RequestContextprovides-контракт multisiteoutмодуль резолвит домен → site_id, ядро подставляет его в контекст запроса до контроллеров и до попадания в ключи кеша
Ядро — страницы/записи/меню/виджетыизмерение site_id (nullable FK, §4 стандарта)outвладелец таблиц (ядро) скоупит свои репозитории по site_id из RequestContext; сам модуль в чужие таблицы не пишет и не читает напрямую
Ядро — SettingsStoreизмерение site_id для settings_overridesoutper-site override читается из кеша группы настроек по текущему site_id, без похода в БД
cms/domainsсервис-вызов (requires у domains) + provides-контракт multisiteincms/domains резолвит принадлежность домена сайту через сервис модуля, не через собственный SQL к cms_sites
cms/multitenancyconflicts в манифесте (декларативно, не рантайм-проверка)совместное включение блокируется на уровне cms:doctor/lifecycle, не событием — см. «Крайние случаи»
Шина событий (cms/audit, если включён)событие SiteCreated/SiteDomainChanged/SiteDomainRemovedoutаудит изменений реестра сайтов (кто добавил/изменил/удалил домен)

Любая связь вне этой таблицы — скрытая зависимость (анти-паттерн §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_idnullable()->constrained('cms_sites')->index() на каждой таблице-потребителе измерения — без индекса скоуп по сайту превращается в полнотабличный скан.
  • Что кешируется: реестр cms_sites + cms_site_domain_aliases целиком (тег multisite.sites); per-site settings_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.viewmultisite.managemultisite.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.

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