274 lines
30 KiB
Markdown
274 lines
30 KiB
Markdown
# Админка прелендингов — 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 редактируются в соответствующей колонке. Веса хранятся в процентах:
|
||
0..100 с двумя знаками; сумма группы — 100% либо все веса — 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 ссылки, выбор и сохранённые метки остаются.
|
||
Неизвестный в текущем config номер сопровождается предупреждением;
|
||
конструктор не исправляет конфигурацию автоматически.
|
||
|
||
## Компактная сводка и заметки — 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
|
||
блокируются. Удаление набора из реестра оставляет файлы; используемый номер удалить нельзя.
|
||
|
||
## Ручные варианты и независимость — preland#30
|
||
|
||
Во всех восьми UTM ссылка «Добавить вариант» создаёт пустую строку: номер, вес и
|
||
неотмеченный default. До нажатия пустых строк нет. Существующие номера показаны
|
||
текстом; новые вводятся вручную (1–9). После сохранения новый номер тоже становится
|
||
обычным текстом. Нулевой вес сохраняется. Отсутствующую группу можно создать через
|
||
ту же ссылку; для неё default выбирается явно. «Отмена» возвращает последний прочитанный конфиг.
|
||
|
||
Редактор не обращается к каталогу основной админки и не проверяет реализацию номера
|
||
на сайте: можно записать theme=3 или indpic=9 независимо от наличия темы/файлов.
|
||
Соответствие номера содержимому сайта обеспечивает пользователь. Формат номера,
|
||
дубликаты, aliases, проценты и CAS проверяются; конфиг не нормализуется автоматически.
|
||
Сохранение, копирование и восстановление одинаково независимы от каталога/наборов.
|
||
|
||
Новый сет загружается отдельно: выбрать домены, вручную указать номер, выбрать ZIP,
|
||
нажать «Загрузить сет». Для существующего номера нужно подтвердить замену.
|
||
Проверка ZIP и изображений сохраняется, как и проверка файлов, загруженных по SFTP.
|
||
Загрузка не меняет config.json. После неё пользователь сам добавляет вариант indpic,
|
||
заполняет вес/default и сохраняет настройки. Автоматической связи нет.
|
||
|
||
## Приватное состояние и публикация наборов
|
||
|
||
Резервы и 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:<git-sha> -f admin/Dockerfile admin
|
||
docker run --rm --entrypoint composer -v "$PWD:/work:ro" -w /work/admin preland-admin:<git-sha> test
|
||
```
|
||
|
||
Дополнительные 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 его нет.
|
||
|
||
## Независимые UTM-наборы — preland#32
|
||
|
||
Дискета перед глазиком создаёт снимок текущих UTM-полей таблицы, включая ещё не опубликованные правки. Сохранение набора не пишет на хостинг. Обязательно собственное название; дубликат без учёта регистра отклоняется. Приватный utm-presets.json содержит только id/name/utm: ни домен, ни ID подключения, ни происхождение не сохраняются. Host, заметки, реквизиты и файлы сетов не входят в набор.
|
||
|
||
GET/POST/DELETE /api/utm-presets требуют вход; записи требуют CSRF. POST принимает только name и utm (канонические группы, только default/weights). DELETE принимает только id. Копирование POST /api/copy принимает preset и targets с ID/версией каждого получателя; прежний источник-домен больше не поддерживается. Очередь фиксирует собственный UTM-снимок, поэтому удаление набора или любого подключения-источника не влияет на задание. Получателем может быть любой доступный домен. Основной сайт и заметки сохраняются, отсутствующая группа набора не удаляет группу получателя. CAS, резерв и подтверждённое перечитывание сохранены.
|
||
|
||
Проверки: tests/utm-presets.php, обновлённый tests/copy.php и ui-utm-presets.cjs; helper preset-ui-helper.cjs адаптирует прежние copy-регрессии к независимому источнику. UI покрывает черновик без публикации, имена/дубликаты, два получателя, reload, гонку list GET/delete, 401/relogin, отмену удаления и API-удаление, мобильную ширину. Тестовые удаления выполняются только над созданными этим тестом наборами.
|
||
|
||
|
||
api#347: part — девятая UTM, пятый символ1/2, default1. Поддерживается редактором, именованными UTM-наборами, копированием, заметками и конструктором. Preland только передаёт метку, ссылка «Для бизнеса» там не добавляется. Runtime config автоматически не дополняется. Старые сохранённые ссылки получают независимый part1 без изменения остальных query-шумов; recovery использует общий приоритет сохранённого snapshot перед текущим resolved fallback.
|