# Админка прелендингов — preland#22 Отдельное PHP-приложение для `admin.zoomstage.online`. Формы редактируют фактические `config.json` в общей таблице; сохранённые настройки можно скопировать в выбранные домены. ZIP indpic проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов. Реквизиты и устаревший `utm.chb` скрыты и защищены от записи. Неизвестные поля показаны только для чтения и сохраняются. Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории, копии и фактический статус — в базе знаний, основной реестр — preland#22. ## Сводка и конструктор ссылки Первый экран — редактор фактических конфигов всех подключений. Основной сайт, варианты, веса и default редактируются в соответствующей колонке. Доля вычисляется автоматически:3:1 →75%/25%; нулевые веса дают0%. Default выбирается radio обычного начертания. Отсутствующая группа добавляется только явно. «Сохранить» и «Отмена» работают отдельно для каждого домена; ошибка и конфликт сохраняют черновик. Перечитывание другой колонки сохраняет DOM, фокус и курсор. После конфликта нужно явно загрузить сохранённое либо отменить черновик. Версии CAS остаются внутренними; даты заметок и версии конфигов в таблице не показываются. Под таблицей — копирование сохранённых host и восьми канонических UTM из одного источника в несколько получателей. Источник исключён из получателей. Черновики источника и выбранных получателей сначала нужно сохранить или отменить. Неизвестные поля, реквизиты, заметки и файлы ZIP не копируются. Отсутствующая группа источника оставляет группу получателя. Источник и каждая цель проверяются по своей версии; сохранение/копирование считаются завершёнными после результата задания и перечитывания. Ошибки отдельных целей видны в колонке и журнале; успешные цели не повторяются. Конструктор работает отдельно: значения или «Рандом» в восьми списках, затем «Сформировать и сохранить». Последний выбор и точный URL сохраняются на сервере отдельно по ID домена, восстанавливаются после перезагрузки. Изменение списка скрывает старую ссылку до явного сохранения. Значащая цифра стоит на пятой позиции метки, `0` означает выбор по весам. Конструктор не меняет config, default, веса или наборы, не создаёт задания; сохранённый выбор доступен и при недоступном хостинге. `GET /api/link-builder?target=ID` читает выбор, `POST /api/link-builder` сохраняет `{target,selection}` с ровно восемью каноническими UTM-ключами. Действуют общий вход и CSRF. Состояние находится в приватном `state/link-builder-ID.json` с режимом0600. При переименовании домена меняется origin ссылки, выбор и сохранённые метки остаются. Неизвестная в текущем runtime/catalog группа сопровождается предупреждением; конструктор не исправляет конфигурацию автоматически. ## Компактная сводка и заметки — preland#23 / #24 С версии preland#25 ячейки содержат inline-редактор, описанный выше. В блоке каждой UTM верхняя строка содержит название один раз и общую заметку в объединённой ячейке доменов. Ниже — пустая первая ячейка и значения доменов. Устаревший `utm.chb` скрыт в сводке и редакторе; остальные неизвестные поля видны. Редактор заметки — растягиваемая по высоте textarea на всю ширину объединённой ячейки доменов; сохранение явное. Черновик сохраняется при обновлении колонок или ошибке запроса. При конфликте сохранения нужно явно загрузить серверный текст; чужой текст молча не заменяется. `GET /api/utm-notes` читает восемь заметок, `POST /api/utm-notes` принимает `{marker,text,version}`. Авторизация/CSRF общие; версия отдельная у каждой заметки, запись защищена CAS. Plain text до10000 Unicode-символов, пустой текст очищает заметку. Файл `state/utm-notes.json` приватный0600, хранится независимо от config.json, подключений, конструктора ссылок и заданий. Заметки не доставляются на хостинги и сохраняются после замены контейнера. ## Состав и доступы `compose.yaml` запускает web и отдельный worker из одного неизменяемого образа. Нужны PHP8.3, ZIP, Imagick с AVIF/WebP/JPEG и phpseclib3 из зафиксированного Composer lock. На Windows ничего устанавливать не требуется: сборка и проверки выполняются Docker. На сервере `/srv/preland-admin`: `compose.yaml`, `.env` с `ADMIN_IMAGE`, закрытый `secret.env`, `state/`, `secrets/` и `deploy/`. В `secret.env` задаются `ADMIN_USERNAME` и `ADMIN_PASSWORD_HASH`; bcrypt-значение заключается в одинарные кавычки, чтобы Compose не интерполировал `$`. Пароль хешируется через `bin/password-hash.php` со stdin. В HTTP Secure-cookie включена по умолчанию; `ADMIN_COOKIE_SECURE=0` разрешена только на изолированном HTTP-стенде. PHP-сессии хранятся в `state/sessions` с режимом0700, файлы имеют режим0600. Пересоздание web-контейнера сохраняет вход; срок бездействия остаётся3600секунд. Хранилище сессий не допускает относительный путь, symlink или публичный каталог. При первом переходе со старого ephemeral backing существующие session-файлы нужно перенести приватно до пересоздания; сам код не мигрирует старый `/tmp`. Необязательная галочка «Запомнить меня на 90 дней» создаёт persistent cookie `preland_admin` с HttpOnly/Secure/SameSite=Strict. Срок фиксируется при входе, чтения его не продлевают. Данные такого входа лежат отдельно в `state/remember-sessions`0700, файлы0600. Без галочки остаются session-only cookie и прежний час бездействия. Анонимные обращения не создают долгоживущих файлов. «Выйти» удаляет серверный сеанс и cookie немедленно; пароль не сохраняется. UID/GID приложения — 997:987. Приложение получает приватный state и ровно два local mount: `/targets/zoompixel.ru`, `/targets/zoomstage.online`. Весь `/srv/preland`, Docker socket, root SSH key и база основного сайта в приложение не передаются. Права публикации на каждом корне проверяются реальным временным файлом/rename при проверке подключения; нельзя включать запись до успешной проверки. REG.RU ограничивает размер SSH exec-команды: длинный inline helper разрывал соединение после успешного входа. Доверенный helper передаётся сжатым zlib; финальная команда ограничена7000байт, большие payload идут в закрытый SFTP-inbox. На удалённом PHP требуется `gzinflate`; отсутствие возвращает понятную ошибку. Содержимое inbox удаляется и при нормальном завершении, и резервно при обрыве. Два REG.RU-хостинга подключаются SFTP/SSH с их обычными ограниченными учётными записями. Нужны абсолютный корень, host key либо SHA256 fingerprint, пароль или приватный ключ, доступный через stdin CLI `bin/target-import.php`. Секрет хранится 0600 в приватном state, по API не возвращается. На удалённом хосте нужен `php` CLI >=8.1 с `fsync`; удалённый Imagick не нужен. Замена реквизитов сбрасывает проверку подключения. ## Работа через формы 1. Изменить основной сайт или UTM в нужной колонке, проверить проценты и default, нажать «Сохранить» под колонкой. Остальные поля сохраняются; ошибка оставляет черновик. 2. Для копирования выбрать источник и получателей под таблицей. Сначала сохранить либо отменить их черновики, затем нажать кнопку с числом получателей. 3. Проверить итог каждой цели в колонках и журнале. При конфликте явно перечитать сохранённые настройки; неизвестный результат требует проверки журнала и фактического состояния. 4. Раздел indpic имеет собственные «Открытый домен» и checkbox целей ZIP/проверки. Выбор копирования настроек на эти цели не влияет. Резерв относится к открытому домену indpic. 5. Загрузить ZIP или проверить SFTP-папку, при необходимости назначить новый default в основной таблице: его номер должен присутствовать в весах, допускается вес0. Номера — 1–9, ZIP содержит папку номера и ровно90 изображений установленных форматов/ размеров; лимит8МиБ на файл,64МиБ распакованного набора. При нулевой сумме весов rnd использует первый ключ weights по порядку config.json; только cons использует default. Имена numeric/legacy UTM адаптируются отдельно для каждой цели, конфликтующие aliases блокируются. Удаление набора из реестра оставляет файлы; используемый номер удалить нельзя. ## Каталог групп и приватная публикация `deploy/export-catalog.php` выполняется внутри существующего API-контейнера минимальным Yii bootstrap, в read-only DB-транзакции. Он экспортирует только номера/названия degr/tp/cons, не выдавая приложению доступ к БД. `deploy/refresh-catalog.sh` запускается host cron раз в минуту от root. Подготовка/проверка/chown происходят в root-only `secrets/`, затем `mv -fT` заменяет фиксированный `state/catalog.json` без следования ссылке назначения. Снимок действует180секунд только для совпадающего основного host. Недоступный/старый каталог даёт явную пометку непроверенного существования групп, а не выдуманный список. Резервы и journal локального транспорта хранятся в приватном state. Для exact bind mount папка подготовки `.preland-admin-staging` создаётся внутри корня с0700; публичный веб-сервер другого UID обязан получать403 и через внешний origin, и через прямой loopback Apache. При наличии nginx-правила deny оно служит дополнительной защитой. Одной надежды на `.htaccess` недостаточно: AllowOverride может быть выключен. Каталог активного `/N/` —0755, файлы0644. SFTP использует приватную sibling-папку вне webroot. Переключение каталогов имеет два rename и durable journal. При аварии upload-child supervisor сразу пытается восстановить полный прежний/новый набор под тем же lock; при полном перезапуске это делает следующая операция транспорта. Неподтверждённый результат остаётся unknown. Это восстановление после прерывания, а не обещание отсутствия краткого окна между двумя rename. При недоступном SSH восстановление нельзя подтвердить. ## Сборка, проверки и выпуск ```sh docker build -t preland-admin: -f admin/Dockerfile admin docker run --rm --entrypoint composer -v "$PWD:/work:ro" -w /work/admin preland-admin: test docker run --rm --entrypoint bash -v "$PWD:/work:ro" preland-admin: -c 'sed "s/\r$//" /work/admin/tests/catalog-publish.sh | bash' ``` Дополнительные Docker проверки: `tests/backend-sftp.Dockerfile` — настоящий SSH/SFTP, `tests/backend-worker.php` — цикл с паузой65секунд, `tests/ui-*.cjs` — реальные browser сценарии. Последние выполняются только на disposable целях; они изменяют и восстанавливают настройки. `NODE_PATH=/e2e/node_modules`, `CHROMIUM_PATH` задаёт браузер имеющегося e2e-образа. Для production подготовить отдельный TLS/nginx по `deploy/nginx.conf`, импортировать проверенные подключения, сохранить резерв состояния, указать точный image в `.env`, затем `docker compose up -d`. Проверить web/worker health, вход, четыре чтения, безопасный реальный цикл сохранения/перечитывания, ZIP и публичные изображения. Не заменять runtime-конфиги Git-снимком. Для отката приложения вернуть прежний `ADMIN_IMAGE`; state и контент сохраняются. Возврат контента выполняется отдельно через резерв и CAS, не через старый Docker-образ. Фактические production-команды, версии, ограничения, свидетельства UI и статус подключения четырёх доменов фиксируются в preland#22 и KB; этот README сам не доказывает завершение выпуска. ## Browser проверки preland#25 `ui-matrix.cjs` выполняет полный цикл на четырёх disposable local-целях a/b/c/d: inline input/default/add/remove/missing, Save/Cancel, параллельные черновики, задержанный GET, настоящий CAS-конфликт и offline/recovery, copy двух получателей, partial failure/source changed, aliases/unknown/requisites, независимые ZIP-цели, ошибочный ZIP, конструктор/заметки и1440/390. Скрипту нужны writable mount fixture в `/fixture` и output в `/output`; он возвращает исходные config-файлы в finally. `ui-regression.cjs` — совместимая точка входа этого же сценария, отдельно повторять её не требуется. `ui-utm.cjs` отдельно проверяет настоящую ZIP-доставку в a+b, variant3 с весом0/default и обратное восстановление через controls; нужен `VALID_ZIP` набора3. `ui-notes.cjs`, `ui-link-builder.cjs`, `ui-verified-refresh.cjs`, `ui-remember.cjs`, `ui-requisites.cjs` и `ui-smoke.cjs` сохраняют отдельные сценарии своих подсистем. Ни один browser write-test не предназначен для production. Отдельный `ui-session-jobs.cjs` удерживает ответ настоящего завершённого Save/Copy через выход и повторный вход: старое продолжение не сбрасывает новый черновик, не подменяет результаты/notice и не снимает busy новой операции. Серверные задания при выходе продолжаются; отменяются только устаревшие UI-продолжения. ## Скрытие, удаление и порядок доменов (preland#26) Значок перечёркнутого глаза справа от домена временно убирает его из таблицы и рабочих списков. Подключение, секрет, проверка соединения, сайт, config.json и наборы сохраняются. В «Скрытых доменах» доступны возврат и удаление подключения. Сервер запрещает удалять видимый домен и домен с незавершённым заданием; сайт и файлы удаление подключения не затрагивает. История сохраняется, результаты скрытых целей не выводятся в рабочих списках. Маркер слева от названия позволяет перетащить колонку мышью; стрелки на маркере позволяют изменить порядок с клавиатуры. Порядок автоматически сохраняется на сервере и применяется к таблице и спискам выбора для всех входов. Скрытый домен сохраняет свою позицию, новый добавляется в конец. Черновики настроек и заметок при перестановке сохраняются; перед скрытием изменённого домена нужно сохранить или отменить его правки. API с обычными авторизацией и CSRF: `POST /api/targets/visibility` принимает `{id,hidden}` (hidden — boolean), `POST /api/targets/order` принимает `{ids}` — ровно все видимые ID в нужном порядке; оба возвращают актуальные `targets`. Неверный/устаревший набор ID отвергается, скрытые позиции не перезаписываются. `GET /api/state` содержит все подключения, включая hidden; интерфейс отдельно фильтрует рабочие списки. Скрытие не отменяет уже запущенное задание. Ответы API401 не отправляют Set-Cookie: задержанный ответ старого запроса не может перезаписать cookie нового успешного входа. Истёкший сеанс отзывается на сервере, а явный выход (200) удаляет cookie. UI также отбрасывает ответы прежнего сеанса. Проверка `ui-target-login-race.cjs` включает следующий авторизованный запрос и сохранение порядка после задержанного401, а не только видимость рабочей области. ## Номера вариантов и чтение с хостинга (#29) В строках UTM редактируются только вес и default; номера отображаются текстом, добавление/удаление вариантов и создание отсутствующей группы из таблицы убраны. Копирование сохранённых групп остаётся доступно. API config возвращает read_at UTC как метаданные успешного Transport-чтения, не записывая его в config.json. Колонка показывает время последнего успешного чтения МСК с секундами, отдельные loading/error/retry состояния и «Записано на хостинг» после подтверждённых Save/Copy. Ошибка сохраняет прошлую дату; ввод во время чтения сохраняет черновик, но обновляет метаданные чтения. Сохранение блокируется до завершения чтения. ## Процентный контракт (#28) В `config.json` веса известных UTM хранятся непосредственно в процентах: числа 0–100 с точностью до двух знаков; сумма группы — 100 либо все значения равны 0. Поле «Вес, %» показывает эти же числа, без пересчёта и перераспределения ввода. Номер варианта и default не меняются при изменении веса. Промежуточная неверная сумма допускается в черновике и блокирует сохранение с понятной причиной. Config/Transport проверяют единый контракт для Save, Copy, Restore и проверки нового подключения. Старые относительные конфиги и резервные копии не публикуются; сами архивы сохраняются. Совместимость со старой единицей весов не поддерживается по прямому решению владельца. Не путать это с неизменными alias/defaulttypes. Публичный выбор варианта остаётся прежним: масштабирование до процентов сохраняет доли; поведение полностью нулевой группы не заменяется новым fallback. `PercentMigration` применяется только явно при миграции: заменяет числовые токены известных weights, сохраняя остальные байты. Доли округляются независимо до сотых; если сумма округлений не равна 100, операция останавливается до записи всех целей. Остаток автоматически не распределяется. Приватный CLI с планом/receipt/CAS делает резервные копии и проверяет точные bytes после каждой записи; в public его нет.