Skip to content

ТЗ — Скрипты в head/body (cms/head-scripts)

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

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

Единственный модуль-«отдушина» для инъекции произвольного доверенного кода клиента в <head> и перед </body> страницы: счётчики верификации (Яндекс.Вебмастер, Google Search Console — метатег-подтверждение), онлайн-чаты, кастомные скрипты/стили, любые сторонние вставки, для которых в CMS нет специализированного модуля. Границу назначения задаёт сверка настроек сайта: реквизиты/контакты/соцсети — группа настроек ядра и виджеты (не этот модуль); пиксели и аналитика (VK Pixel, Top.Mail.Ru, GTM, Я.Метрика, GA4) — свои модули cms/pixels, cms/integration-google, cms/integration-yandex с собственной consent-логикой и CSP-регистрацией доменов. cms/head-scripts — то, что остаётся «бесхозным»: любой прочий сторонний тег, вставляемый вручную студией.

  • Именованные снипеты кода с позицией вставки: head (внутри <head>) или body_end (перед </body>).
  • Условия показа: конкретная страница/раздел (page_path), город (при cms/multicity), локаль — снипет может быть глобальным или узко нацеленным.
  • Включение/отключение снипета без удаления, ручной порядок вывода внутри позиции.
  • Вставка происходит через швы ядра, не собственным механизмом: layout.head / layout.body_end (FilterBus) для точки вставки, security.csp для nonce инлайн-кода — модуль не имеет права писать в разметку layout в обход этих швов.
  • Редактирование кода — только под ролью studio: код полностью доверенный, ядро его не санитизирует (в отличие от rich-text контента), поэтому это тот же класс риска, что и {!! !!} в контенте (§11 стандарта).
  • Аварийный kill_switch, отключающий все вставки без выключения модуля.

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

requires: ядро (cms/core-contracts: швы layout.head/layout.body_end FilterBus, security.csp для nonce — см. «Крайние случаи», п. 10 про текущий статус этих швов в каноническом реестре) · suggests: cms/multicity (условие показа по городу из RequestContext) · suggests: cms/audit (журнал CRUD-правок доверенного кода — критично именно для этого модуля больше, чем для любого другого: изменение снипета — изменение исполняемого на каждой странице JS).

Без cms/multicity измерение city_id — nullable-поле (правило измерений §4 стандарта): снипет с city_id = null показывается во всех городах, деградация без ветвления кода.

Поведение при выключении модуля: ни один снипет не вставляется ни в head, ни перед </body>, публичный сайт рендерится штатно без сторонних вставок; данные снипетов не удаляются — повторное включение восстанавливает прежнее поведение без переввода кода.

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

ТаблицаКлючевые поляПримечание
cms_head_scripts_snippetsid, title, position enum(head|body_end), code (longText), is_active, sort_order, page_path (nullable), city_id (nullable), locale (nullable), nonce_required (bool), lock_version, external_id (nullable)доверенный снипет с условиями показа

codelongText, доверенный HTML/JS/CSS без санитизации (только studio-роль пишет); position — PHP Enum (Head, BodyEnd); составной индекс (position, is_active, sort_order) под горячую выборку активных снипетов позиции; city_idconstrained()->nullOnDelete()->index() (nullable — правило измерений §4); page_path — строка с точным путём или префиксом раздела (whitelist-формат условия, не произвольный regex — ReDoS-риска нет намеренно, см. «Крайние случаи»); nonce_required — снипет сам содержит <script> и должен получить nonce ядра при вставке; lock_version — optimistic lock (§4 стандарта) на конкурентное редактирование; external_id — уникален, только для legacy-импорта (§16 стандарта).

ПДн-паспорт: собственные таблицы модуля ПДн не хранят (title, code, условия показа — не персональные данные). Явное предупреждение: содержимое code может собирать ПДн посетителей (сторонний трекер, чат с формой контактов) — это ответственность кода, вставленного студией, а не модуля; журналирование правок самого code (кто/когда изменил) — задача cms/audit (suggests), не собственная таблица модуля. Декларация: «ПДн в своих таблицах не храню, код в снипетах может — вне периметра модуля».

Входные и выходные данные

Вход:

ИсточникПоляВалидация
Admin CRUD снипета (Filament, только studio)title, position, code, is_active, sort_order, page_path, city_id, locale, nonce_required, lock_versionFormRequest-whitelist; position — enum; code — лимит head-scripts.max_snippet_size_kb, не проходит rich-text санитайзер (доверенный ввод studio, §11 стандарта); lock_version сверяется — иначе 409
Рендер layout.head/layout.body_end (FilterBus, внутренний вызов)RequestContext (page_path текущего запроса, city/locale)внутренний сервис-вызов HeadScriptsService, не форма — фильтр не читает БД напрямую, только кеш группы
Legacy-импорт (cms:head-scripts:import-legacy)строки донорской таблицы: позиция, код, условие (страница/раздел/город)маппинг профиля источника; идемпотентность по external_id; --dry-run без записи

Выход:

ПотребительДанныеФормат
layout.head (FilterBus)HTML активных снипетов позиции head с учётом условий и nonceдобавка к разметке <head>, порядок по sort_order/id
layout.body_end (FilterBus)HTML активных снипетов позиции body_endдобавка перед </body>
Filament adminсписок снипетов, превью в песочницеkeyset-JSON через API
Legacy-импортотчёт прогона--json: создано/обновлено/пропущено/ошибки построчно

Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта) — в частности, позиция и условие показа принимают только объявленные значения, произвольный page_path-regex не поддерживается намеренно (см. «Крайние случаи»).

Настройки (группа head-scripts)

КлючТипДефолтaffectsPageCacheОписание
head-scripts.enabledbooltrueдаОбщее включение вставки снипетов
head-scripts.admin_zone_kill_switchbooltrueнетНе вставлять снипеты в admin-зоне (/admin/*) — риск инъекции в саму админку
head-scripts.max_snippet_size_kbint64нетМаксимальный размер одного снипета (code)
head-scripts.kill_switchboolfalseдаАварийное отключение всех вставок без выключения модуля

Лимиты и квоты: единственный количественный лимит — размер снипета (max_snippet_size_kb); внешних платных API нет, квот на число снипетов на сайт нет (ограничение — только человеческий здравый смысл и ревью studio).

API

МетодПутьДоступНазначение
GET/POST/PUT/DELETE/api/v1/admin/head-scripts/snippets…studio (head-scripts.manage)CRUD снипетов
GET/api/v1/admin/head-scripts/snippetsadmin (head-scripts.view)Только просмотр списка, без права правки

Публичного API нет — снипеты не отдаются наружу как ресурс, вывод только инлайн в разметку страницы через layout.head/layout.body_end. Мутации принимают lock_version — рассинхрон отдаёт 409 (конвенции ядра §7 стандарта).

Компоненты

Filament: ресурс снипетов (позиция, условия показа, код в редакторе с подсветкой синтаксиса, превью в изолированном <iframe sandbox> — снипет не выполняется в контексте самой админки при просмотре), afterSave() → инвалидация тега head-scripts; при сохранении с <script в коде и nonce_required = false — предупреждение «похоже на скрипт, включить nonce_required?»; авторизация — только роль studio на запись, admin — только чтение. Демо-контент: HeadScriptsDemoSeeder создаёт один демо-снипет позиции head (безопасный HTML-комментарий, is_active = false) — галерея/playground показывают структуру ресурса без риска исполнения чужого кода в demo-окружении. Команды: cms:head-scripts:import-legacy --source=<профиль> --json.

Фронтенд-бюджет: модуль не переписывает и не добавляет атрибуты async/defer к <script> внутри code — код полностью доверенный, автор (studio) сам управляет собственным тегом; ответственность за отсутствие CLS/блокировки рендера — на авторе снипета, Filament лишь предупреждает эвристикой (см. выше). Собственный HTML модуля (обёртка позиции) — пустой, вставляет ровно содержимое code, без лишней разметки.

События и обмен

Модуль не издаёт доменных событий: снипет — статическая конфигурация вставки, не факт бизнес-процесса. Слушает: RequestContext (city_id, locale, текущий page_path) — сервис-вызов cms/core-contracts. Provides-контрактов не реализует.

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

Сущность/модульКаналНаправлениеЧто происходит
layout.head (FilterBus)2 — FilterBushead-scripts → ядромодуль регистрирует обработчик, добавляющий HTML активных снипетов позиции head в разметку
layout.body_end (FilterBus)2 — FilterBushead-scripts → ядрото же для позиции body_end
security.csp (FilterBus)2 — FilterBushead-scripts → ядроснипет с nonce_required = true получает nonce ядра на вставляемый <script>; источники внешних доменов, к которым обращается код снипета, регистрируются студией через тот же фильтр отдельно (модуль их не выводит автоматически из code)
RequestContext (city/locale/page_path)3 — сервис-вызов cms/core-contractsядро → head-scriptsвыборка активных снипетов учитывает текущий контекст запроса
cms/multicity (suggests)head-scriptsmulticityвключён — условие по city_id активно; не установлен — измерение nullable, показ всем городам
cms/audit (suggests)3 — сервис-вызовhead-scriptsauditCRUD доверенного кода логируется с diff «было/стало» — повышенный риск требует полной прослеживаемости правок
Настройка head-scripts.kill_switchsettings-storehead-scripts → рендервключена — ни один снипет не вставляется независимо от is_active каждого

Фоновая работа

Джобов и расписания нет: выборка активных снипетов синхронна на каждом рендере страницы, из кеша группы (см. «Производительность»). Legacy-импорт — разовая CLI-команда, не очередь (объём донорских данных мал, десятки-сотни строк).

Производительность и кеш

Ожидаемые объёмы: единицы-десятки снипетов на сайт. Горячий путь — вызов layout.head/layout.body_end на каждом рендере страницы: бюджет — 1 закешированный запрос на позицию (не на снипет), фильтрация по условиям (page_path/city_id/locale) выполняется в PHP над уже выбранным закешированным набором, без дополнительных запросов к БД. Ключ кеша группы настроек включает измерения RequestContext; поскольку page_path — часть условия показа, набор активных снипетов для страницы участвует в ключе page-cache самой страницы (см. ниже).

Критичный индекс: составной (position, is_active, sort_order) под выборку активных снипетов позиции.

Тег кеша head-scripts объявляется через CacheTags (§10 стандарта); инвалидируется при создании/правке/удалении снипета и при изменении настроек группы (enabled, kill_switch, admin_zone_kill_switch). Настройки и список снипетов помечены affectsPageCache: да — правка снипета сбрасывает page-cache страниц, где он мог показываться (по условиям показа, а не весь кеш сайта целиком).

Безопасность

Центральное решение модуля: code — доверенный HTML/JS/CSS, который ядро не санитизирует (в отличие от rich-text блоков контента, где двойной барьер санитайзера обязателен). Поэтому редактирование снипетов доступно только роли studio — тот же гейт, что стандарт требует для {!! !!} в контенте (§11). Компрометация studio-аккаунта или ошибка ревью здесь эквивалентны произвольному JS на каждой странице сайта (кража сессии/cookie, фишинг форм, майнинг в браузере посетителя) — поэтому обязательны 2FA для ролей studio (см. cms-v2/security.md) и запись каждой правки в cms/audit при его наличии.

Вставка идёт исключительно через швы ядра layout.head/layout.body_end (точка вставки) и security.csp (nonce инлайн-кода) — модуль не пишет в шаблон layout напрямую и не управляет собственным nonce. В режиме CSP enforce снипет с nonce_required = true и некорректным/отсутствующим nonce не исполнится браузером — это ожидаемая деградация (защита от инъекции через частично скомпрометированный флоу), не баг модуля; в head при admin_zone_kill_switch = true снипеты не вставляются на /admin/* — исключение на уровне middleware ядра, не на уровне разметки конкретного admin-вида (нельзя забыть в новом виде).

Права: head-scripts.view, head-scripts.manage.

Матрица ролей:

ДействиеАдминистраторМенеджерРедакторStudio
Просмотр списка снипетов (view)
Просмотр содержимого code конкретного снипета
Создание/правка/удаление снипета (manage)
head-scripts.kill_switch (аварийное отключение)

Менеджер и редактор не имеют доступа вовсе: доверенный произвольный код — не контентная задача, права на него не расширяются «по умолчанию для админов сайта».

UX-требования

Админ:

  • Пустой список снипетов — подсказка «Скриптов не добавлено» с кнопкой «Добавить снипет» и явным пояснением риска перед первым созданием.
  • Предупреждение при сохранении снипета: «Код исполняется на каждой странице сайта без проверки — убедитесь в происхождении и безопасности источника» (первое подтверждение при создании, повторно — при значимом изменении code).
  • Превью снипета — в изолированном <iframe sandbox>, снипет не выполняется в контексте реальной страницы админки при простом просмотре.
  • Ошибка превышения max_snippet_size_kb — конкретный текст с фактическим и допустимым размером, не generic «Error».
  • Конфликт редактирования (lock_version) — «Снипет изменён другим пользователем, обновите страницу», не молчаливая перезапись.
  • Подсказка при обнаружении <script без nonce_required = true: «похоже на скрипт — включить nonce?».

Посетитель:

  • Снипет не блокирует рендер сервером: вставка — чистая конкатенация HTML на этапе рендера layout, без синхронных внешних вызовов на сервере.
  • Клиентское поведение (async/defer, CLS) — на совести автора снипета; модуль ничего не переписывает в теге, чтобы не сломать порядок инициализации стороннего кода.

Крайние случаи и типовые баги

  1. Снипет ломает вёрстку или блокируется CSP на клиенте → серверный рендер страницы не зависит от исполнения снипета в браузере: сайт остаётся доступным, сбой виден только в консоли разработчика у посетителя (изоляция клиент/сервер).
  2. Превышение max_snippet_size_kb при сохранении → человеческая ошибка с конкретными цифрами, не молчаливое обрезание кода.
  3. Снипет обращается к внешнему домену при активном CSP enforce → браузер блокирует запрос домена, не зарегистрированного через security.csp; Filament подсказывает «добавьте домен через настройки CSP, иначе внешние вызовы снипета будут заблокированы» (сам домен модуль автоматически из code не парсит и не регистрирует).
  4. head-scripts.kill_switch включён → ни один снипет не вставляется независимо от собственного is_active, без выключения модуля и без удаления данных.
  5. Конкурентное редактирование снипета двумя studio-пользователями → lock_version, конфликт — 409, не «последний победил» молча.
  6. Условие показа по городу без cms/multicitycity_id nullable, снипет с пустым городом показывается везде (контрактный тест гоняется в режимах «с модулем» и «без»).
  7. page_path не совпадает ни с одной реальной страницей (опечатка) → снипет просто не показывается нигде, не ошибка сохранения; это человеческая ошибка конфигурации, а не баг модуля.
  8. Несколько снипетов одной позиции с одинаковым sort_order → детерминированный вторичный порядок по id, чтобы порядок вывода был воспроизводим между запросами при одинаковом кеше.
  9. Выключение модуля с активными снипетами → снипеты не вставляются, данные и условия показа сохраняются, повторное включение восстанавливает прежнее поведение без повторного ввода кода.
  10. ⚠️ Противоречие: канонический реестр фильтров FilterBus (phase0-three-axes.md) на момент написания ТЗ перечисляет только content.render, menu.items, seo.meta, block.render.{type}, sitemap.urls, mail.recipients, security.csp — швов layout.head и layout.body_end, на которые опирается этот модуль, в реестре нет. Разрешение: ревизия ядра, вводящая эти два фильтра в канонический реестр (по образцу ревизии №2 provides-контрактов) — обязательное условие перед стартом разработки модуля; пункт дублируется в открытых вопросах.
  11. Легаси-снипет ссылается на домен, не зарегистрированный в CSP целевого сайта → --dry-run легаси-импорта не блокирует перенос, но помечает строку предупреждением в отчёте — домен нужно зарегистрировать вручную после переноса.
  12. Снипет с nonce_required = false, но содержащий <script> → в режиме CSP enforce браузер блокирует тег без nonce; это ожидаемое поведение конфигурации, а не баг — Filament заранее предупреждает при сохранении (см. «UX-требования»), но не запрещает сохранение (учебные/статические примеры без исполняемого кода — валидный случай).

Донорский код

Донор: — (новая разработка; функциональность типовая для многих CMS, но конкретный исходный код для переиспользования на площадках студии не выявлен).

Legacy-импорт: cms:head-scripts:import-legacy --source=<профиль> — маппинг старых таблиц «произвольный код в head/footer» донорских проектов (позиция/код/условие страницы/города) на cms_head_scripts_snippets; идемпотентен по external_id (повторный прогон обновляет, не дублирует); --dry-run выводит отчёт расхождений и предупреждений (см. «Крайние случаи», п. 11) без записи. Прогон на копии боевых данных донора — часть приёмки модуля, если у клиента есть донор с боевыми данными.

Тесты и приёмка

  • [ ] Контрактный тест: снипет позиции head/body_end появляется в соответствующей точке рендера страницы с учётом условий показа (страница/город/локаль)
  • [ ] При выключении модуля или head-scripts.kill_switch = true ни один снипет не вставляется, сайт рендерится без 500
  • [ ] Снипет с nonce_required = true получает валидный nonce ядра через security.csp; без корректного nonce в режиме enforce скрипт не исполняется браузером
  • [ ] admin_zone_kill_switch исключает вставку снипетов на /admin/*
  • [ ] Только роль studio может создавать/редактировать/удалять снипеты; admin — только просмотр списка; матрица ролей покрыта тестом
  • [ ] Права head-scripts.view/head-scripts.manage разграничены корректно
  • [ ] Конкурентное редактирование снипета двумя studio-пользователями → 409 по lock_version
  • [ ] Превышение head-scripts.max_snippet_size_kb — человеческая ошибка, не 500 и не молчаливое обрезание
  • [ ] Условие показа по городу работает и при отсутствии cms/multicity (оба режима)
  • [ ] Инвалидация page-cache по тегу head-scripts при CRUD снипета и при изменении настроек группы (enabled, kill_switch, admin_zone_kill_switch)
  • [ ] Нет N+1 при выборке активных снипетов позиции (1 закешированный запрос на позицию)
  • [ ] Legacy-импорт идемпотентен по external_id, --dry-run не пишет в БД
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (admin CRUD снипетов)
  • [ ] Тестовая БД только head-scripts_test; migrate:fresh/refresh/reset/db:wipe запрещены

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