Author SHA1 Message Date
Panfilov Konstantin b4c4a2ca27 документация: обновить адрес Gitea 2026-11-03 23:55:31 +03:00
Panfilov Konstantin ba33a76a34 feat: неблокирующий сбор User Flow и гостевых ошибок (api#376 api#377)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
2026-10-31 13:29:45 +03:00
Panfilov Konstantin 791761a782 fix: тематическое окно ошибки загрузки (#33)
deploy-preland / deploy zoomstage.online (push) Successful in 1m7s
2026-10-28 09:07:00 +03:00
Panfilov Konstantin 4a6683f335 test: учитывать текущие UTM defaults владельца в stage CI #347
deploy-preland / deploy zoomstage.online (push) Successful in 1m0s
2026-10-28 05:34:19 +03:00
Panfilov Konstantin 2970f990e0 docs: сохранить фактический stage config без изменения runtime #347
deploy-preland / deploy zoomstage.online (push) Failing after 35s
2026-10-28 05:15:31 +03:00
Panfilov Konstantin 875549a5af fix(admin): уточнить ошибку допустимого значения part #347 2026-10-28 05:11:28 +03:00
Panfilov Konstantin 01f83375a7 docs: уточнить допустимые варианты part #347 2026-10-28 05:10:43 +03:00
Panfilov Konstantin 469bec1595 api#347: обновить полный перечень девяти UTM 2026-10-28 05:06:53 +03:00
Panfilov Konstantin f7d1792e23 api#347: сохранить part в именованных наборах и процентных контрактах 2026-10-28 05:06:11 +03:00
Panfilov Konstantin 1993f7b4b1 api#347: проверить старые recovery snapshots с default2 2026-10-28 05:04:08 +03:00
Panfilov Konstantin 0d350ed14b api#347: передавать и настраивать UTM part без ссылки в Preland 2026-10-28 05:04:08 +03:00
Panfilov Konstantin 94619432a3 feat(admin): независимые именованные UTM-наборы 2026-10-26 18:38:11 +03:00
Panfilov Konstantin 941a4f01aa feat(admin): ручные варианты UTM и независимый редактор config (#30) 2026-10-26 05:42:03 +03:00
Panfilov Konstantin 47405b27d8 feat(admin): единые процентные веса без старого формата (#28) 2026-10-26 04:56:32 +03:00
Panfilov Konstantin a22519e754 fix(admin): номера UTM и дата чтения с хостинга (#29) 2026-10-26 04:42:40 +03:00
Panfilov Konstantin 00fbef9ede feat(admin): скрытие доменов и сохраняемый порядок колонок (#26) 2026-10-26 04:11:44 +03:00
Panfilov Konstantin dd6d221dd7 feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s
2026-10-26 03:06:11 +03:00
Panfilov Konstantin 84f70b1c71 fix(admin): align UTM notes and block obsolete chb publication
deploy-preland / deploy zoomstage.online (push) Successful in 54s
2026-10-25 23:16:35 +03:00
Panfilov Konstantin f2097d4800 feat(admin): compact UTM percentages and shared notes
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
2026-10-25 22:59:37 +03:00
Panfilov Konstantin 4fc4fd8162 fix(admin): route multi-megabyte sets to SFTP before shell escaping
deploy-preland / deploy zoomstage.online (push) Successful in 1m3s
2026-10-25 22:14:15 +03:00
Panfilov Konstantin 6ec813bc99 fix(admin): refresh verified connection state without losing drafts
deploy-preland / deploy zoomstage.online (push) Successful in 56s
2026-10-25 19:27:09 +03:00
Panfilov Konstantin 522aaf8554 feat(admin): remember login for 90 days and support REG SSH limits
deploy-preland / deploy zoomstage.online (push) Successful in 57s
2026-10-25 19:17:51 +03:00
Panfilov Konstantin d04b40519e fix(admin): persist private sessions across container replacement
deploy-preland / deploy zoomstage.online (push) Successful in 58s
2026-10-25 18:26:41 +03:00
Panfilov Konstantin 2cfa2e2d2e feat(admin): add runtime overview and independent saved UTM link builder
deploy-preland / deploy zoomstage.online (push) Successful in 57s
2026-10-25 18:17:23 +03:00
Panfilov Konstantin 48c71f3300 fix(admin): show verified catalog groups in UTM forms
deploy-preland / deploy zoomstage.online (push) Successful in 1m3s
2026-10-25 11:53:20 +03:00
Panfilov Konstantin 9515c1c62b feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
2026-10-25 11:33:56 +03:00
Panfilov Konstantin 5b6b2d2053 docs: план выполнения админки прелендингов (#22) 2026-10-25 10:46:16 +03:00
Panfilov Konstantin b3b14ec193 docs: проект единой админки конфигов и indpic (#22) 2026-10-25 10:35:35 +03:00
102 changed files with 4811 additions and 74 deletions

No files matched your search

+7 -10
View File
@@ -20,7 +20,7 @@ jobs:
REMOTE_DIR: domains/zoomstage.online
steps:
- name: Install tools
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli php-imagick
- name: Checkout staging branch
env:
@@ -38,8 +38,8 @@ jobs:
php bin/release-assets.php --check
for test in tests/check-*.php; do php "$test"; done
php bin/release-assets.php --primary
# /N/ по-прежнему принадлежит stage. Любое расхождение требует
# сначала перенести актуальный снимок в Git по задаче.
# preland#22: /N/ — runtime. Проверяем config и декодируем все 90
# обязательных изображений используемых наборов, не сверяем с Git SHA.
php bin/release-assets.php --stage-sets "$PRELAND_URL"
- name: Apply approved domain brand only if needed
@@ -85,9 +85,8 @@ jobs:
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
# Наборы `/N/` на stage — редактируемый контент. Кодовая доставка их
# намеренно не перезаписывает: актуальное содержимое stage фиксируют в Git
# отдельной синхронизацией по задаче.
# Положительный список: tests/bin/планы не публикуются.
# намеренно не перезаписывает. Обновление набора через админку не требует Git.
# Положительный список: admin/tests/bin/планы/секреты не публикуются.
php bin/release-assets.php --files | grep -vE '^(config\.json$|[0-9]+/)' > "$files"
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
@@ -141,10 +140,8 @@ jobs:
echo "$api"
case "$api" in *'zoomzilla.ru\/api\/images\/public\/create'*) ;; *) echo "на странице нет адреса загрузки Zoomzilla"; exit 1 ;; esac
printf '%s' "$page" | grep -q 'claim?key=' || { echo "переход не на claim"; exit 1; }
curl -fsS --max-time 30 "$PRELAND_URL/1/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован первый набор примеров"; exit 1; }
curl -fsS --max-time 30 "$PRELAND_URL/2/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован второй набор примеров"; exit 1; }
page2=$(curl -fsS --max-time 30 "$PRELAND_URL/?indpic=abcd2efghi&nc=$(date +%s)")
printf '%s' "$page2" | grep -q '2/01_mob_1-source.avif' || { echo "indpic=2 не использует второй набор примеров"; exit 1; }
# Список наборов берётся из runtime config, а не фиксируется как /1/ и /2/.
# Полная проверка изображений ниже; одиночный HTTP200 недостаточен.
php bin/release-assets.php --remote "$PRELAND_URL"
php bin/release-brand.php --status "$PRELAND_URL"
+32 -3
View File
@@ -45,8 +45,8 @@
создавать target-specific конфиги или `config.json.example`.
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
Git отдельной синхронизацией по задаче.
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert`
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
@@ -65,7 +65,7 @@
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
- /N/ по-прежнему редактируется на stage и не перезаписывается обычной кодовой доставкой; все150файлов перед выкатом сверяются с Git. Drift блокирует выпуск до импорта актуального набора. При полном переносе репозитория набор уже должен быть правильным.
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
@@ -80,3 +80,32 @@
primary guard теперь сверяет PDF/favicon, а не заставляет возвращать Zoomzilla и реквизиты на преленд. Реквизиты не отображаются и больше автоматически не синхронизируются; хостовый config и его существующие значения не меняются этим поручением. Устаревшие requisites.html и patch-скрипт удалены из поставки. Статический manifest183, отдельный каталог брендов проверяется по собственным SHA. История #11 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
Это решение явно заменяет требование #11 о предварительном импорте изменённых
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
обновление контента админкой не требует commit/push. Исторические записи `/N/`
в `release/assets.json` остаются снимком, не источником истины runtime.
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
затем декодируют все 90 обязательных изображений каждого используемого набора,
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
вложенных каталогов и лишних элементов выполнить на фактическом webroot
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
+32 -3
View File
@@ -45,8 +45,8 @@
создавать target-specific конфиги или `config.json.example`.
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
Git отдельной синхронизацией по задаче.
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
@@ -62,7 +62,7 @@
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
- /N/ по-прежнему редактируется на stage и не перезаписывается обычной кодовой доставкой; все150файлов перед выкатом сверяются с Git. Drift блокирует выпуск до импорта актуального набора. При полном переносе репозитория набор уже должен быть правильным.
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
@@ -77,3 +77,32 @@
primary guard теперь сверяет PDF/favicon, а не заставляет возвращать Zoomzilla и реквизиты на преленд. Реквизиты не отображаются и больше автоматически не синхронизируются; хостовый config и его существующие значения не меняются этим поручением. Устаревшие requisites.html и patch-скрипт удалены из поставки. Статический manifest183, отдельный каталог брендов проверяется по собственным SHA. История #11 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
Это решение явно заменяет требование #11 о предварительном импорте изменённых
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
обновление контента админкой не требует commit/push. Исторические записи `/N/`
в `release/assets.json` остаются снимком, не источником истины runtime.
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
затем декодируют все 90 обязательных изображений каждого используемого набора,
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
вложенных каталогов и лишних элементов выполнить на фактическом webroot
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
+38 -6
View File
@@ -65,12 +65,13 @@ HTTP→HTTPS, проверка двух тем/ширин, примеров, upl
| `tp` | номер настроенной тарифной группы1–9 |
| `cons` | номер группы согласий1–9; существование проверяет API |
| `focus` |1 face,2 center |
| `part` |1 показывать ссылку «Для бизнеса» на сайте,2 скрывать; Preland ссылку не показывает |
`config.json` хранит публичные числовые `weights/default`. Внутри PHP сохраняются прежние semantic enum. Runtime-конфиг мигрируется `bin/ensure-utm-config.php`: только согласованные UTM, с сохранением весов, default и JSON-типов соседних полей. Скрипт идемпотентен; старые focus/cons patch-скрипты больше не вызываются доставкой.
В api#327 публичное `chob` заменено на `banch`; внутренний `bchoice` сохраняется. Мигратор переименовывает текущий host-ключ токенно, сохраняя все остальные байты, включая веса и default. Одновременные `banch/chob/bchoice` блокируют patch без записи. До миграции код читает прежний host-config; сохранённые recovery-записи с `utmDigits.chob` или `redirectUtm` продолжают выбранный вариант и выпускают уже `banch`.
Перед каждым переходом Preland создаёт для всех восьми меток новый шум из10 ASCII букв/цифр, с уже выбранной цифрой в пятой позиции. Retry и recovery меняют только шум; вариант повторно не выбирается. Выбранные цифры сохраняются в upload record. Старые записи IndexedDB адаптируются только на пути recovery, без публичных alias старого URL.
Перед каждым переходом Preland создаёт для всех девяти меток новый шум из10 ASCII букв/цифр, с уже выбранной цифрой в пятой позиции. Retry и recovery меняют только шум; вариант повторно не выбирается. Выбранные цифры сохраняются в upload record. Старые записи IndexedDB адаптируются только на пути recovery, без публичных alias старого URL.
Чужие параметры (`utm_source`, `keyword`, `subid` и любые другие) сохраняют значения после обычного URL-кодирования. Технические `key/id/stage/shown_theme` не маскируются; внешний `key` не может подменить одноразовый ключ загрузки. `shown_theme` берётся из фактически показанной темы непосредственно при переходе.
@@ -144,9 +145,9 @@ N/05_...
Для `zoomstage.online` обычный deploy ветки `staging` намеренно **не загружает**
корневые числовые каталоги `/N/`: это редактируемый контент stage, а не артефакт
кодовой доставки. Workflow продолжает проверять их доступность HTTP-smoke. Чтобы
зафиксировать обновлённый набор, сначала синхронизируйте его со stage в Git по
отдельной задаче; следующая заливка не перетрёт его старой ревизией.
кодовой доставки. По #22 workflow проверяет их полный контракт по фактическому
config через runtime guard. Импорт изменённого набора в Git не требуется;
следующая заливка не перетрёт его старой ревизией.
## Деплой на произвольный домен
@@ -156,7 +157,7 @@ N/05_...
1. **Домен.** Запись A (или CNAME) на хостинг, сертификат (Let's Encrypt или от хостинга); открыть `https://<домен>/`.
2. **Файлы.** Содержимое репозитория — в корень сайта, без `.git` и служебного (`README.md`, `.gitea/`):
```bash
git clone http://git.dira89kv.beget.tech:3000/anton/preland.git && cd preland
git clone https://git.zoomzilla.ru/anton/preland.git && cd preland
# дальше — загрузка на хостинг: rsync/scp по SSH или FTP-клиент
```
3. **Конфиг и реквизиты.** Рабочий `config.json` на хостинге — источник истины. При первичном создании домена его настраивают отдельно; обычный deploy не заменяет его репозиторным снимком и не перезаписывает `requisites.html`. Менять можно только явно согласованные ключи после чтения текущего файла.
@@ -219,7 +220,7 @@ Workflow `.gitea/workflows/deploy.yml` содержит единственную
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
- /N/ по-прежнему редактируется на stage и не перезаписывается обычной кодовой доставкой; все150файлов перед выкатом сверяются с Git. Drift блокирует выпуск до импорта актуального набора. При полном переносе репозитория набор уже должен быть правильным.
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
@@ -238,3 +239,34 @@ primary guard теперь сверяет PDF/favicon, а не заставля
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
Дополнение приёмки #12: tests/e2e/brand-footer.cjs проверяет именно видимый SVG по центру шапки, а не только headerbox. PUBLISHED=1 использует zoomstage.online (или PRELAND_URL); без него — изолированный pre301.localhost QA proxy. Запускать в имеющемся Docker Playwright с /candidate=репозиторий,/qa=артефакты; POST/PUT блокируются. Пропущенное при compact CSS правило Bootstrap navbar>.container восстановлено во всех3CSS: API уже имеет его из полного Bootstrap. Это устраняет переход logo y0→13.5px, который рамочный тест не замечал.
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
Это решение явно заменяет требование #11 о предварительном импорте изменённых
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
обновление контента админкой не требует commit/push. Исторические записи `/N/`
в `release/assets.json` остаются снимком, не источником истины runtime.
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
затем декодируют все 90 обязательных изображений каждого используемого набора,
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
вложенных каталогов и лишних элементов выполнить на фактическом webroot
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
api#347: part — девятая UTM, пятый символ1/2, default1. Preland только передаёт её на сайт и никогда не показывает ссылку «Для бизнеса». Отсутствующая группа/веса сохраняют fallback1; случайный выбор использует существующий порядок и настроенные веса. Runtime config автоматически не дополняется. Старые сохранённые ссылки конструктора получают part1 без изменения шума остальных меток; старые заметки читаются с пустой заметкой part.
+6
View File
@@ -0,0 +1,6 @@
tests
vendor
state
secret.env
.env
*.log
+5
View File
@@ -0,0 +1,5 @@
/vendor/
/state/
/secret.env
/.env
/*.log
+23
View File
@@ -0,0 +1,23 @@
FROM php:8.3-apache-bookworm@sha256:cbd3b3d9011668443d54ab40ec31db56c73d91b4c8198935d639aa80d2d815c1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libmagickwand-dev libzip-dev unzip ca-certificates git \
&& docker-php-ext-install zip \
&& printf '\n' | pecl install imagick-3.8.0 \
&& docker-php-ext-enable imagick \
&& a2enmod rewrite headers \
&& groupmod -g 987 www-data && usermod -u 997 -g 987 www-data \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2@sha256:b6dcd72ca98fa19d0e5b8476de179859704891fad06dd9e0b96833d4c430a5fc /usr/bin/composer /usr/local/bin/composer
RUN docker-php-ext-install pcntl
WORKDIR /app
COPY composer.* ./
RUN COMPOSER_IPRESOLVE=4 composer install --no-dev --no-interaction --prefer-dist --no-progress --optimize-autoloader
COPY . /app
COPY deploy/apache.conf /etc/apache2/sites-available/000-default.conf
COPY deploy/php.ini /usr/local/etc/php/conf.d/preland-admin.ini
RUN mkdir -p /var/lib/preland-admin && chown 997:987 /var/lib/preland-admin
ENV ADMIN_STATE_DIR=/var/lib/preland-admin
EXPOSE 80
CMD ["apache2-foreground"]
+273
View File
@@ -0,0 +1,273 @@
# Админка прелендингов — 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, для part только 1 или 2). После сохранения новый номер тоже становится
обычным текстом. Нулевой вес сохраняется. Отсутствующую группу можно создать через
ту же ссылку; для неё 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.
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
// Пароль читается только из stdin; не передавать его через аргументы командной строки.
$password=rtrim(stream_get_contents(STDIN),"\r\n");if(strlen($password)<12){fwrite(STDERR,"Пароль должен содержать не менее 12 символов.\n");exit(1);}echo password_hash($password,PASSWORD_DEFAULT)."\n";
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
// Начальная настройка из закрытого stdin. Секреты никогда не выводятся.
try{$input=json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);$store=new PrelandAdmin\Store();foreach($input as $item){$secret=$item['secret']??null;unset($item['secret']);$target=$store->saveTarget($item,$secret);$actual=$store->target($target['id']);(new PrelandAdmin\Transport($store,$actual))->test();$store->verifyTarget($target['id'],$actual);}echo "Подключения импортированы и проверены.\n";}catch(Throwable){fwrite(STDERR,"Импорт не выполнен. Проверьте приватные параметры и соединения.\n");exit(1);}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
$jobIndex=array_search('--job',$argv,true);
// Supervisor не создаёт Imagick: его policy time не должна накапливаться между заданиями.
if($jobIndex===false)define('PRELAND_ADMIN_SUPERVISOR',true);
require dirname(__DIR__).'/bootstrap.php';
$store=new PrelandAdmin\Store();$jobs=new PrelandAdmin\Jobs($store);
if($jobIndex!==false){$id=$argv[$jobIndex+1]??'';if(!preg_match('/^[a-f0-9]{32}$/D',$id))exit(2);$jobs->run($id);exit(0);}
$once=in_array('--once',$argv,true);
do {
try {
$id=$jobs->pending();
if($id!==null){
$process=proc_open([PHP_BINARY,__FILE__,'--job',$id],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);
if(!is_resource($process))throw new RuntimeException('Не удалось запустить исполнитель.');fclose($pipes[0]);
$deadline=microtime(true)+max(30,min(900,(int)(getenv('ADMIN_JOB_TIMEOUT')?:300)));$exit=null;
do{$state=proc_get_status($process);if(!$state['running']){$exit=$state['exitcode'];break;}if(microtime(true)>$deadline){proc_terminate($process);usleep(250000);$state=proc_get_status($process);if($state['running'])proc_terminate($process,9);$exit=124;break;}usleep(100000);}while(true);
$closed=proc_close($process);if(($exit??$closed)!==0)$jobs->interrupted($id);
}
}catch(Throwable){fwrite(STDERR,"Не удалось обработать очередь. Повтор через 2 секунды.\n");}
if(!$once)sleep(2);
}while(!$once);
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
if (is_file(__DIR__ . '/vendor/autoload.php')) require __DIR__ . '/vendor/autoload.php';
spl_autoload_register(static function (string $class): void {
$prefix = 'PrelandAdmin\\';
if (str_starts_with($class, $prefix)) {
$path = __DIR__ . '/src/' . substr($class, strlen($prefix)) . '.php';
if (is_file($path)) require $path;
}
});
umask(0077);
if (!defined('PRELAND_ADMIN_SUPERVISOR') && class_exists(Imagick::class)) {
Imagick::setResourceLimit(Imagick::RESOURCETYPE_MEMORY, 134217728);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_MAP, 268435456);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_DISK, 536870912);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_TIME, 60);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_THREAD, 2);
}
+36
View File
@@ -0,0 +1,36 @@
services:
web:
image: ${ADMIN_IMAGE:?Укажите неизменяемую версию образа}
restart: unless-stopped
env_file: [./secret.env]
environment:
ADMIN_STATE_DIR: /var/lib/preland-admin
ADMIN_LOCAL_ROOTS: /targets/zoompixel.ru,/targets/zoomstage.online
volumes:
- ./state:/var/lib/preland-admin
- /srv/preland/domains/zoompixel.ru:/targets/zoompixel.ru
- /srv/preland/domains/zoomstage.online:/targets/zoomstage.online
ports: ["127.0.0.1:18084:80"]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
cap_add: [CHOWN, SETGID, SETUID, NET_BIND_SERVICE]
healthcheck:
test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"]
interval: 30s
timeout: 5s
retries: 3
worker:
image: ${ADMIN_IMAGE:?Укажите неизменяемую версию образа}
restart: unless-stopped
user: "997:987"
command: ["php", "/app/bin/worker.php"]
env_file: [./secret.env]
environment:
ADMIN_STATE_DIR: /var/lib/preland-admin
ADMIN_LOCAL_ROOTS: /targets/zoompixel.ru,/targets/zoomstage.online
volumes:
- ./state:/var/lib/preland-admin
- /srv/preland/domains/zoompixel.ru:/targets/zoompixel.ru
- /srv/preland/domains/zoomstage.online:/targets/zoomstage.online
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
+8
View File
@@ -0,0 +1,8 @@
{
"name": "zoomzilla/preland-admin",
"description": "Защищённое управление runtime прелендингов",
"license": "proprietary",
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"},
"config": {"sort-packages": true},
"scripts": {"test": ["php tests/utm-presets.php", "php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php", "php tests/part.php"], "test:worker": "php tests/backend-worker.php"}
}
+254
View File
@@ -0,0 +1,254 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "571e6007d448594fcee9719e8947dd65",
"packages": [
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.57",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-08-26T12:13:21+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.3",
"ext-json": "*",
"ext-zip": "*",
"ext-imagick": "*",
"ext-zlib": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+18
View File
@@ -0,0 +1,18 @@
<VirtualHost *:80>
ServerName admin.zoomstage.online
DocumentRoot /app/public
<Directory /app/public>
AllowOverride None
Options -Indexes
Require all granted
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</Directory>
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "same-origin"
Header always set X-Frame-Options "DENY"
ErrorLog /proc/self/fd/2
# Не логировать параметры запросов, тела форм и секреты.
CustomLog /proc/self/fd/1 "%h %t %m %U %>s %b"
</VirtualHost>
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
listen [::]:80;
server_name admin.zoomstage.online;
location ^~ /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name admin.zoomstage.online;
ssl_certificate /etc/letsencrypt/live/admin.zoomstage.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.zoomstage.online/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 130m;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:18084;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
}
}
+10
View File
@@ -0,0 +1,10 @@
expose_php=Off
display_errors=Off
log_errors=On
upload_max_filesize=128M
post_max_size=130M
max_execution_time=60
memory_limit=512M
session.cookie_httponly=1
session.cookie_samesite=Strict
session.use_strict_mode=1
+22
View File
@@ -0,0 +1,22 @@
/* Local Bootstrap 3 compatible surface; indpic table/controls ported from
* api/modules/cpanel/views/settings/index.php at 591a67cc8302 (preland#22). */
*{box-sizing:border-box}body{margin:0;color:#333;background:#f7f7f7;font:14px/1.42857143 "Helvetica Neue",Helvetica,Arial,sans-serif}.container{width:min(1170px,100%);padding:0 15px;margin:0 auto}.navbar{background:#222;color:#ddd;min-height:52px}.nav-inner{display:flex;gap:24px;align-items:center;min-height:52px}.nav-inner strong{font-size:18px}.nav-inner button{margin-left:auto}h1{font-size:30px;font-weight:500;margin:24px 0 20px}h2{font-size:24px;font-weight:500;margin:0 0 18px}h3{font-size:18px;font-weight:500}p{margin:0 0 12px}.panel{padding:20px;background:white;border:1px solid #ddd;border-radius:4px;margin:20px 0}.login-panel{max-width:420px;margin:60px auto}.login-panel label{display:block;margin:15px 0}.form-control{display:block;width:100%;min-height:34px;padding:6px 12px;font:inherit;line-height:1.42857143;color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px #0001}input[type=checkbox]{margin:0 5px 0 0;accent-color:#337ab7}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid #337ab7;outline-offset:2px}button,input,select{font:inherit}.btn{display:inline-block;padding:6px 12px;border:1px solid transparent;border-radius:4px;font-weight:400;text-align:center;cursor:pointer;background:white;color:#333}.btn-default{border-color:#ccc}.btn-default:hover{background:#e6e6e6}.btn-primary{background:#337ab7;border-color:#2e6da4;color:white}.btn-danger{background:#d9534f;color:white}.btn-xs{padding:1px 5px;font-size:12px}.btn:disabled{opacity:.5;cursor:not-allowed}.toolbar,.target-options,.utm-inline-options,.utm-inline-option{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.toolbar{margin:12px 0}.grow{flex:1;min-width:200px}.target-options label{padding:8px 10px;border:1px solid #ddd;border-radius:4px}.text-muted{color:#707070}.alert{border:1px solid #ddd;border-radius:4px;padding:15px;margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.alert-danger{background:#f2dede;color:#a94442;border-color:#ebccd1}.alert-success{background:#dff0d8;color:#3c763d}.alert-warning{background:#fcf8e3;color:#8a6d3b}.alert-info{background:#d9edf7;color:#31708f}.tabs{display:flex;flex-wrap:wrap;gap:24px;border-bottom:1px solid #ddd;padding-bottom:12px}a,.utm-default-action,.utm-group-save{color:#337ab7}.utm-default-action,.utm-group-save{border:0;background:none;cursor:pointer;padding:0;text-decoration:underline}.utm-default-value{font-weight:bold;color:#3c763d}.table-responsive{overflow-x:auto;max-width:100%}.table{width:100%;border-collapse:collapse;margin-bottom:12px}.table th{text-align:left;background:#f5f5f5}.table td,.table th{padding:8px;vertical-align:middle;border:1px solid #ddd}.table-hover tbody tr:hover{background:#f5f5f5}.indpic-sets-table{table-layout:fixed;min-width:900px}.indpic-sets-table th:first-child{width:170px}.indpic-default-column{width:105px}.indpic-check-column{width:110px}.indpic-actions-column{width:100px}.indpic-file-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.indpic-file-control input{max-width:230px;min-width:0}.indpic-add-form{display:flex;align-items:end;flex-wrap:wrap;gap:10px;margin-top:14px}.indpic-number-input{width:96px}.indpic-add-form .checkbox{margin:0 4px 7px}.indpic-add-form input[name=name]{width:170px}.indpic-add-form input[type=file]{max-width:300px}.utm-group-number{font-size:18px;font-weight:600}.utm-group-name{margin:6px 0}.utm-inline-options{gap:24px;margin-bottom:12px}.utm-inline-option{gap:6px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.field{margin:12px 0}.field-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.field-head label{font-weight:600}.nested{border-left:3px solid #eee;padding-left:15px;min-width:0}.field-block{margin:18px 0;padding-top:12px;border-top:1px solid #eee}.weight-table{max-width:560px}.weight-table input,.weight-table select{width:100%;min-width:75px}.mixed{font-size:12px;color:#8a6d3b;margin-left:8px}.job{margin:16px 0;border-top:1px solid #ddd;padding-top:12px}.job .table{min-width:480px}.status-success{color:#3c763d}.status-error,.status-conflict{color:#a94442}.status-unknown{color:#8a6d3b}.connection{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.connection span{flex:1;min-width:160px}.break{overflow-wrap:anywhere}dialog{border:1px solid #999;border-radius:6px;box-shadow:0 5px 15px #0008;padding:0;width:960px;max-width:calc(100% - 40px)}dialog::backdrop{background:#0007}.modal-header{position:relative;text-align:center;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header h2{font-size:18px;margin:0 34px}.close{position:absolute;right:15px;top:10px;background:none;border:0;font-size:25px;color:#777;cursor:pointer}.modal-body{padding:15px;max-height:calc(100vh - 180px);overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}[hidden]{display:none!important}@media(max-wLine truncated
#notice{position:sticky;top:8px;z-index:100;box-shadow:0 2px 6px #0002}
/* Overview and link builder retain the existing CPanel / Bootstrap surface. */
.overview-wrapper{margin-top:15px}.overview-table{min-width:760px;table-layout:fixed}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){min-width:190px}.overview-table th,.overview-table td{vertical-align:top;overflow-wrap:anywhere}.overview-table th:first-child,.overview-table td:first-child{position:sticky;left:0;background:#f5f5f5;z-index:1}.overview-table caption{text-align:left;padding:8px 0}.overview-domain{font-size:15px;margin-bottom:6px}.overview-status{font-size:12px;font-weight:400;margin:8px 0}.overview-weights{list-style:none;padding:0;margin:7px 0 0}.overview-weights li{margin:3px 0}.overview-warning{color:#8a6d3b;margin-top:8px;font-size:12px}.overview-error{color:#a94442}.overview-table .toolbar{gap:6px;margin-bottom:0}.builder-domain{display:block;max-width:450px;margin:15px 0}.builder-field{display:block}.builder-field select{margin-top:5px}#builder-fields{margin:15px 0}#builder-result{margin-top:18px}#builder-url{font-family:monospace}#builder-state{margin-top:10px}#builder-warnings .alert{padding:10px}#builder-save{margin-top:5px}@media(max-width:600px){.overview-table{min-width:720px}.overview-table th:first-child{width:90px}.overview-table .btn{padding:4px 8px}.builder-domain{max-width:100%}}
.overview-plain{white-space:pre-wrap}
.overview-default{font-weight:400}.overview-table tr[data-utm-note] th{font-size:16px}.overview-table tr[data-overview-utm] th{background:#fafafa}
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
/* #25: one editable matrix and independent saved-settings copy. */
.container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 76px 1fr;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 76px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-total,.matrix-validation{font-size:12px;font-weight:400;margin:7px 0 0}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}}
#notice{position:static;box-shadow:none}
.overview-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.hidden-link{border:0;background:none;color:#52677c;text-decoration:underline;cursor:pointer;padding:6px 0}.domain-heading{display:flex;align-items:center;gap:7px}.domain-heading .overview-domain{margin:0;min-width:0;overflow-wrap:anywhere}.domain-grip,.domain-hide{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:0;background:transparent;color:#89949c;padding:3px;width:24px;height:24px;border-radius:4px}.domain-grip{cursor:grab;font-size:22px;touch-action:none}.domain-hide{cursor:pointer}.domain-grip:hover,.domain-hide:hover{background:#edf1f4;color:#40566b}.domain-hide:focus-visible,.domain-grip:focus-visible{outline:2px solid #337ab7}.domain-grip:disabled,.domain-hide:disabled{opacity:.4;cursor:wait}.hidden-domain-row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid #e2e7ec;flex-wrap:wrap}.domain-dragging{user-select:none;cursor:grabbing}.domain-drag-preview{position:fixed;z-index:1001;pointer-events:none;background:#fff;border:1px solid #9bb7cf;border-radius:6px;padding:12px;box-shadow:0 8px 24px #23394d30;opacity:.92}.domain-drag-preview>p,.domain-drag-preview>button{display:none}.domain-insertion{position:fixed;z-index:1000;pointer-events:none;width:3px;background:#337ab7}.hidden-domain-row .grow{min-width:180px}
.matrix-read-at{font-size:11px;font-weight:400;margin:4px 0 8px;line-height:1.4}.matrix-variant-number{font-size:13px;padding:3px 5px}
.matrix-add-variant{display:inline-flex;align-items:center;border:0;background:transparent;color:#337ab7;padding:8px 0 0;font-size:12px;cursor:pointer}.matrix-add-variant:hover{color:#23527c;text-decoration:underline}.matrix-add-variant:focus-visible{outline:2px solid #337ab7;outline-offset:3px}.matrix-add-variant:disabled{opacity:.5;cursor:default}.matrix-new-number{width:44px}.indpic-add-form{align-items:end}
.domain-preset{display:inline-flex;align-items:center;justify-content:center;padding:3px;border:0;background:transparent;color:#8092a2;border-radius:3px;cursor:pointer;flex-shrink:0}.domain-preset:hover{color:#337ab7;background:#e9f1f7}.domain-preset:disabled{opacity:.5;cursor:default}.preset-select-row{display:flex;gap:8px;align-items:stretch;margin-top:8px}.preset-select-row select{flex:1;min-width:0}.preset-icon{display:inline-flex;align-items:center;justify-content:center;padding:7px 9px;flex-shrink:0}.preset-icon:hover{color:#a94442}.preset-list-tools{margin:8px 0}.preset-dialog{width:460px}.preset-dialog .modal-body{white-space:normal}.preset-dialog label{display:block;font-weight:600;margin-bottom:8px}.preset-help{font-size:12px;margin:12px 0 0}.preset-dialog #preset-error:not(:empty){margin:12px 0 0}.preset-dialog .modal-footer{display:flex;justify-content:flex-end;gap:8px}
+368
View File
@@ -0,0 +1,368 @@
/* CPanel indpic UI adapted from api 591a67cc8302, settings/index.php and
* _utm-default.php. PublicUtmCodec labels retained; no runtime HTML injection. */
'use strict';
(() => {
const $ = id => document.getElementById(id);
const state = {sessionGeneration:0,csrf:'',allTargets:[],targets:[],targetsEpoch:0,targetsBusy:false,source:'',reads:new Map(),jobs:[],loading:0};
const markers=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
const enums={theme:{'1':'dark','2':'light'},reg:{'1':'before','2':'after'},banch:{'1':'b','2':'a'},focus:{'1':'face','2':'center'}};
const descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'},part:{'1':'показывать ссылку «Для бизнеса» на сайте','2':'скрывать ссылку «Для бизнеса» на сайте'}};
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
// Read-only runtime snapshots and independent constructor drafts never enter state.reads/fields.
const overview={entries:new Map(),pending:new Map(),serial:0};
const builder={target:'',entries:new Map(),serial:0};
function groupFor(config,marker){
const keys=[marker,...(marker==='reg'?['registration']:marker==='banch'?['chob','bchoice']:[])].filter(key=>Object.hasOwn(config?.utm||{},key));
return {key:keys[0],value:keys.length?config.utm[keys[0]]:null,conflict:keys.length>1?keys:[]};
}
function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)&&(marker!=='part'||['1','2'].includes(digit))?digit:null;}
const utmNotes={entries:new Map(),loading:false,error:'',serial:0};
function noteEntry(marker){
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=Math.max(1,state.targets.length);return entry;}
const entry={marker,epoch:0,note:null,draft:'',baseVersion:'',editing:false,saving:false,loading:false,error:'',conflict:false};
const makeButton=(text,action)=>el('button',{type:'button',class:'btn btn-default btn-xs',text,onclick:()=>run(action)});
entry.row=el('tr',{'data-utm-note':marker});entry.cell=el('td',{class:'utm-note-cell'});entry.row.append(el('th',{scope:'row',text:marker}),entry.cell);
entry.text=el('div',{class:'utm-note-text'});
entry.edit=makeButton('Добавить заметку',()=>{if(!entry.note)return;entry.epoch++;entry.draft=entry.note.text;entry.baseVersion=entry.note.version;entry.editing=true;entry.error='';entry.conflict=false;renderNote(entry);entry.textarea.focus();});
entry.edit.setAttribute('aria-label','Добавить заметку '+marker);
entry.readView=el('div',{class:'utm-note-read'},entry.text,entry.edit);
entry.textarea=el('textarea',{class:'form-control utm-note-textarea',rows:3,'aria-label':'Заметка '+marker});
entry.textarea.addEventListener('input',()=>{entry.draft=entry.textarea.value;if(!entry.conflict)entry.error='';renderNote(entry);});
entry.save=makeButton('Сохранить',()=>saveNote(marker));entry.save.setAttribute('aria-label','Сохранить заметку '+marker);
entry.cancel=makeButton('Отмена',()=>{const wasConflict=entry.conflict;entry.epoch++;entry.editing=false;entry.draft=entry.note?.text||'';entry.error='';entry.conflict=false;renderNote(entry);if(wasConflict)run(loadNotes);});entry.cancel.setAttribute('aria-label','Отменить заметку '+marker);
entry.reload=makeButton('Загрузить сохранённую заметку',()=>loadCurrentNote(marker));entry.reload.setAttribute('aria-label','Загрузить сохранённую заметку '+marker);
entry.editView=el('div',{class:'utm-note-edit'},entry.textarea,el('div',{class:'toolbar'},entry.save,entry.cancel,entry.reload));
entry.retry=makeButton('Повторить загрузку заметок',loadNotes);entry.retry.setAttribute('aria-label','Повторить загрузку заметок '+marker);
entry.status=el('p',{class:'utm-note-status text-muted',role:'status','aria-live':'polite'});
entry.cell.append(entry.readView,entry.editView,entry.status,entry.retry);utmNotes.entries.set(marker,entry);renderNote(entry);return entry;
}
function renderNote(entry){
entry.cell.colSpan=Math.max(1,state.targets.length);entry.text.textContent=entry.note?.text||'';entry.text.hidden=!entry.note?.text;
entry.readView.hidden=entry.editing;entry.editView.hidden=!entry.editing;
entry.edit.textContent=entry.note?.text?'Редактировать':'Добавить заметку';entry.edit.setAttribute('aria-label',(entry.note?.text?'Редактировать':'Добавить')+' заметку '+entry.marker);entry.edit.disabled=!entry.note||entry.loading;
if(entry.textarea.value!==entry.draft)entry.textarea.value=entry.draft;
entry.textarea.disabled=entry.saving||entry.loading;entry.save.disabled=entry.saving||entry.loading||entry.conflict||!entry.baseVersion;entry.cancel.disabled=entry.saving||entry.loading;
entry.reload.hidden=!entry.conflict;entry.reload.disabled=entry.saving||entry.loading;
entry.retry.hidden=!utmNotes.error;entry.retry.disabled=utmNotes.loading;
const error=entry.error||utmNotes.error;entry.status.className='utm-note-status '+(error?'overview-error':'text-muted');
entry.status.textContent=entry.saving?'Сохранение заметки…':entry.loading?'Загрузка сохранённой заметки…':error?error:utmNotes.loading?'Загрузка заметок…':entry.note?'': 'Заметка ещё не загружена.';
}
function validNote(note){return note&&typeof note.text==='string'&&typeof note.version==='string'&&(note.saved_at===null||typeof note.saved_at==='string');}
async function loadNotes(){
const request=++utmNotes.serial,epochs=new Map(markers.map(marker=>[marker,noteEntry(marker).epoch]));utmNotes.loading=true;utmNotes.error='';for(const entry of utmNotes.entries.values())renderNote(entry);
try{const result=await api('utm-notes');if(request!==utmNotes.serial||!state.csrf)return;for(const marker of markers){const entry=noteEntry(marker),note=result.notes?.[marker];if(entry.epoch!==epochs.get(marker)||entry.saving||entry.loading)continue;if(!validNote(note)){entry.error='Сервер не вернул корректную заметку '+marker+'.';continue;}if(!entry.editing){entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.error='';}else if(entry.baseVersion!==note.version){entry.error='Заметка уже изменена. Загрузите сохранённую заметку явно; черновик сохранён.';entry.conflict=true;}}}
catch(error){if(request===utmNotes.serial&&state.csrf)utmNotes.error='Ошибка загрузки заметок: '+error.message;}
finally{if(request===utmNotes.serial){utmNotes.loading=false;for(const entry of utmNotes.entries.values())renderNote(entry);}}
}
async function loadCurrentNote(marker){
const entry=noteEntry(marker);entry.epoch++;entry.loading=true;renderNote(entry);
try{const result=await api('utm-notes');if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;const note=result.notes?.[marker];if(!validNote(note))throw new Error('Сервер не вернул корректную сохранённую заметку.');entry.epoch++;entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.conflict=false;entry.error='';}
catch(error){entry.error='Не удалось загрузить сохранённую заметку: '+error.message;}
finally{entry.loading=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
}
async function saveNote(marker){
const entry=noteEntry(marker);if(!entry.editing||entry.saving||entry.loading||entry.conflict||!entry.baseVersion)return;entry.epoch++;entry.saving=true;entry.error='';renderNote(entry);
try{const result=await api('utm-notes',{marker,text:entry.draft,version:entry.baseVersion});if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;if(!validNote(result.note))throw new Error('Сервер не подтвердил сохранённую заметку.');entry.epoch++;entry.note=result.note;entry.draft=result.note.text;entry.baseVersion=result.note.version;entry.editing=false;entry.conflict=false;}
catch(error){entry.error=error.message;entry.conflict=error.status===409;}
finally{entry.saving=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
}
function overviewExtra(config){
const values=new Map(),secretKeys=new Set(['secret','password','private_key','privatekey','credential','credentials','api_key','apikey','access_token','accesstoken','refresh_token','refreshtoken','authorization']);
const visit=(path,value)=>{const key=path.join('.');if(path.some(part=>secretKeys.has(String(part).toLowerCase().replace(/-/g,'_')))){values.set(key,{label:key,text:'Скрытое поле'});return;}
if(value!==null&&typeof value==='object'){const entries=Object.entries(value);if(!entries.length){values.set(key,{label:key,text:Array.isArray(value)?'Пустой массив':'Пустой объект'});return;}for(const [child,next]of entries)visit([...path,child],next);return;}
const text=value===null?'null':typeof value==='boolean'?(value?'Да':'Нет')+' (boolean)':typeof value==='number'?String(value)+' (число)':String(value);
values.set(key,{label:key==='host'?'Основной сайт':key,text});
};
for(const [key,value]of Object.entries(config||{}))if(!['utm','requisites'].includes(key))visit([key],value);
const utm=config?.utm;
if(utm&&typeof utm==='object'&&!Array.isArray(utm)){for(const [key,value]of Object.entries(utm)){if(key==='chb')continue;
const marker=key==='registration'?'reg':['chob','bchoice'].includes(key)?'banch':key;
if(!markers.includes(marker))visit(['utm',key],value);
else if(groupFor(config,marker).conflict.length)visit(['utm',key],value);
else if(value&&typeof value==='object'&&!Array.isArray(value))for(const [extra,next]of Object.entries(value))if(!['default','weights'].includes(extra))visit(['utm',key,extra],next);
}}else if(utm!==undefined)visit(['utm'],utm);
return values;
}
const matrix={entries:new Map(),structure:'',copySource:'',copyTargets:new Set(),copyBusy:false,copyEpoch:0,copyError:'',copyResults:[],operationTargets:new Set()};
function matrixEntry(id){if(!matrix.entries.has(id))matrix.entries.set(id,{id,epoch:0,data:null,dirty:false,saving:false,error:'',conflict:false,cells:new Map(),groups:new Map()});return matrix.entries.get(id);}
function matrixDirty(){return [...matrix.entries.values()].some(e=>e.dirty||e.saving)||[...utmNotes.entries.values()].some(e=>e.editing||e.saving);}
function matrixChanged(entry){entry.epoch++;entry.dirty=true;entry.error='';entry.validation='';try{matrixChanges(entry);}catch(error){entry.validation=error.message;}updateMatrixStatus(entry);renderCopy();}
function updateMatrixStatus(entry){
if(!entry.statusNode)return;const reading=entry.reading;entry.statusNode.textContent=entry.saving?'Сохранение…':reading?'Чтение с хостинга…':entry.error||entry.validation|| (entry.dirty?'Есть изменения':entry.written?'Записано на хостинг':entry.data?'Прочитано с хостинга':'Чтение с хостинга…');if(entry.readAtNode)entry.readAtNode.textContent=entry.readAt?'Последнее чтение: '+new Intl.DateTimeFormat('ru-RU',{timeZone:'Europe/Moscow',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(new Date(entry.readAt))+' МСК':'Последнее чтение: ещё не выполнено';entry.statusNode.className='matrix-status '+(entry.error||entry.validation?'overview-error':entry.dirty?'matrix-dirty':'text-muted');
entry.save.disabled=reading||!entry.dirty||!!entry.validation||entry.saving||entry.conflict||!entry.data||!state.targets.find(t=>t.id===entry.id)?.enabled||!state.targets.find(t=>t.id===entry.id)?.verified;entry.cancel.disabled=entry.saving||!entry.dirty;entry.reload.disabled=entry.saving||reading;entry.reload.textContent=entry.conflict?'Загрузить сохранённое':'Обновить';
if(entry.footerError){entry.footerError.textContent=entry.error||entry.validation||'';entry.footerError.hidden=!entry.footerError.textContent;}for(const cell of entry.cells.values())for(const input of cell.querySelectorAll('input,select,button'))input.disabled=entry.saving||input.dataset.exhausted==='true';
}
function matrixGroup(entry,marker,cell){
cell.replaceChildren();const group=groupFor(entry.data.config,marker),raw=group.value;
if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return;}
if(group.key&&(!raw||typeof raw!=='object'||Array.isArray(raw)||!raw.weights||typeof raw.weights!=='object'||Array.isArray(raw.weights)||!digitFor(marker,raw.default)||Object.entries(raw.weights).some(([key,w])=>!digitFor(marker,key)||typeof w!=='number'||!Number.isFinite(w)||w<0)||new Set(Object.keys(raw.weights).map(k=>digitFor(marker,k))).size!==Object.keys(raw.weights).length)){cell.append(el('p',{class:'overview-error',text:'Некорректная группа. Редактирование недоступно.'}));return;}
const draft={key:group.key,original:structuredClone(raw||{weights:{}}),rows:[],defaultRow:null,added:false};entry.groups.set(marker,draft);renderGroup(draft);if(!group.key)return;for(const [key,w]of Object.entries(raw.weights))addVariant(draft,key,w);const defaultDigit=digitFor(marker,raw.default);if(!draft.rows.some(row=>digitFor(marker,row.variant.value)===defaultDigit))addVariant(draft,String(raw.default),0);draft.defaultRow=draft.rows.find(row=>digitFor(marker,row.variant.value)===defaultDigit);draft.defaultRow.radio.checked=true;percentages(draft);
function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});draft.total=el('p',{class:'matrix-total text-muted','aria-live':'polite'});draft.validation=el('p',{class:'matrix-validation overview-error','aria-live':'polite'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес, %'}),el('span',{text:'Default'})),draft.list,el('button',{type:'button',class:'matrix-add-variant',text:'+ Добавить вариант','aria-label':'Добавить вариант '+marker+' '+entry.id,title:'Добавить вариант',onclick:()=>{addVariant(draft,'','',true);draft.added=!group.key;draft.touched=true;matrixChanged(entry);draft.rows.at(-1).variant.focus();}}),draft.total,draft.validation);}
function addVariant(draft,key,weight,isNew=false){const wrapper=el('div',{class:'matrix-variant'}),variant=isNew?el('input',{type:'text',inputMode:'numeric',maxLength:1,class:'form-control matrix-new-number',value:'','aria-label':'Новый вариант '+marker+' '+entry.id}):{value:key};const input=numberInput(weight,0);input.max='100';input.step='0.01';input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,radio,isNew};draft.rows.push(row);wrapper.append(isNew?variant:el('span',{class:'matrix-variant-number',text:digitFor(marker,key)}),input,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})));draft.list.append(wrapper);if(isNew){const label=()=>{input.setAttribute('aria-label',marker+' вес '+(variant.value||'новый')+' '+entry.id);radio.setAttribute('aria-label',marker+' default '+(variant.value||'новый')+' '+entry.id);};label();variant.addEventListener('input',()=>{label();draft.touched=true;matrixChanged(entry);});}input.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);}
}
function percentError(group){let sum=0;for(const row of group.rows){const text=row.weight.value,w=Number(text);if(text===''||!Number.isFinite(w)||w<0||w>100)return group.marker+': вес должен быть числом от 0 до 100%.';if(w!==Number(w.toFixed(2)))return group.marker+': в весе допустимо не более двух знаков после запятой.';sum+=Math.round(w*100);}return sum!==0&&sum!==10000?group.marker+': сумма весов должна быть 100% или все веса должны быть 0%.':'';}
function percentages(group){const values=group.rows.map(row=>row.weight.value===''?NaN:Number(row.weight.value)),sum=values.reduce((total,w)=>total+w,0);group.total.textContent='Сумма: '+(Number.isFinite(sum)?String(Math.round(sum*1000000)/1000000)+'%':'—');group.validation.textContent=percentError(group);group.validation.hidden=!group.validation.textContent;}
function matrixValidation(entry){for(const marker of markers){const group=groupFor(entry.data?.config,marker);if(group.key&&!entry.groups.has(marker))return marker+': некорректная группа. Обновите конфигурацию.';const draft=entry.groups.get(marker);if(draft){if(draft.rows.some(row=>!digitFor(marker,row.variant.value)))return marker+(marker==='part'?': укажите вариант 1 или 2.':': укажите номер варианта от 1 до 9.');if(new Set(draft.rows.map(row=>digitFor(marker,row.variant.value))).size!==draft.rows.length)return marker+': варианты должны быть уникальны.';const error=percentError(draft);if(error)return error;if(draft.touched&&!draft.defaultRow)return marker+': выберите default.';}}return '';}
function matrixChanges(entry){const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!row.isNew&&!Object.hasOwn(group.original.weights,key)&&w===0)continue;if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?(enums[marker][digit]||digit):digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;}
function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}entry.validation=matrixValidation(entry);updateMatrixStatus(entry);}
function renderOverview(){
const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature&&matrix.structure.split('|').slice().sort().join('|')===signature.split('|').slice().sort().join('|')&&table.querySelector('thead tr')){const ids=state.targets.map(t=>t.id);for(const row of table.querySelectorAll('tr')){if(row.hasAttribute('data-utm-note'))continue;const children=[...row.children];if(children.length===ids.length+1)for(const id of ids){const oldIndex=matrix.structure.split('|').indexOf(id);row.append(children[oldIndex+1]);}}matrix.structure=signature;if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}}if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.readAtNode=el('p',{class:'matrix-read-at text-muted'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},domainHeading(target),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.readAtNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);}
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true'}));for(const target of state.targets){const cell=matrixEntry(target.id).cells.get(marker)||el('td',{'data-matrix-target':target.id,'data-marker':marker});matrixEntry(target.id).cells.set(marker,cell);row.append(cell);}body.append(noteEntry(marker).row,row);}
const footer=el('tr',{class:'matrix-footer'},el('th',{scope:'row',text:'Сохранение'}));for(const target of state.targets){const entry=matrixEntry(target.id);entry.save=button('Сохранить',()=>saveMatrix(target.id),'primary');entry.save.setAttribute('aria-label','Сохранить настройки '+target.id);entry.cancel=button('Отмена',()=>{if(entry.conflict)return refreshOverviewTarget(target.id,true);entry.epoch++;entry.dirty=false;entry.error='';entry.validation='';populateMatrix(entry);renderCopy();});entry.cancel.setAttribute('aria-label','Отменить настройки '+target.id);entry.footerError=el('p',{class:'overview-error matrix-footer-error',role:'alert','aria-live':'polite',hidden:true});footer.append(el('td',{},el('div',{class:'toolbar'},entry.save,entry.cancel),entry.footerError));if(entry.data&&!entry.host)populateMatrix(entry);else updateMatrixStatus(entry);}body.append(footer);if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}
}
for(const target of state.targets){const entry=matrixEntry(target.id),snapshot=overview.entries.get(target.id);const title=entry.statusNode?.parentElement;if(title){title.querySelector('.overview-domain').textContent=target.domain;title.querySelector('.matrix-environment').textContent=target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено');}if(snapshot?.data&&entry.data!==snapshot.data&&!entry.dirty&&!entry.saving){entry.data=snapshot.data;populateMatrix(entry);}if(snapshot?.status==='error'&&!entry.error){entry.error='Ошибка чтения: '+snapshot.error;}updateMatrixStatus(entry);}renderTargetControls();renderExtras();renderCopy();if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
}
function renderExtras(){const body=$('overview-table').querySelector('tbody'),values=new Map(),keys=new Map();for(const t of state.targets){const v=overviewExtra(matrixEntry(t.id).data?.config);v.delete('host');values.set(t.id,v);for(const [key,item]of v)keys.set(key,item.label);}body.querySelectorAll('[data-matrix-extra]').forEach(n=>n.remove());for(const [key,label]of keys){const row=el('tr',{'data-matrix-extra':key},el('th',{text:label}));for(const t of state.targets)row.append(el('td',{class:'overview-plain',text:values.get(t.id).get(key)?.text||'Поле отсутствует'}));body.append(row);}}
function refreshOverviewTarget(id,force=false){if(!state.targets.some(t=>t.id===id))return Promise.resolve();const entry=matrixEntry(id);if(entry.reading)return overview.pending.get(id)||Promise.resolve();const epoch=++entry.epoch,request=++overview.serial,previous=overview.entries.get(id);entry.reading=true;overview.entries.set(id,{...previous,status:'loading',request});renderOverview();const pending=(async()=>{try{const data=await api('config?target='+encodeURIComponent(id));if(matrix.entries.get(id)!==entry||!state.csrf)return;entry.readAt=data.read_at||entry.readAt;entry.written=false;entry.error='';overview.entries.set(id,{status:'success',request,data});if(entry.epoch!==epoch)return;if(entry.dirty&&!force){if(entry.data?.version!==data.version){entry.conflict=true;entry.error='Настройки изменены на сервере. Черновик сохранён. Загрузите сохранённое или отмените изменения.';}}else{entry.data=data;entry.dirty=false;entry.conflict=false;entry.error='';populateMatrix(entry);}}catch(error){if(matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});entry.error=error.message;}finally{if(matrix.entries.get(id)===entry){entry.reading=false;overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;}
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;run(loadNotes);try{await Promise.allSettled(state.targets.map(t=>refreshOverviewTarget(t.id)));}finally{control.disabled=false;}}
async function waitMatrixJob(job,isCurrent){
let current=typeof job==='object'?job:null,id=typeof job==='string'?job:job.id;
while(!current||current.status!=='completed'){
await new Promise(resolve=>setTimeout(resolve,600));if(!isCurrent())return null;
current=(await api('jobs?id='+encodeURIComponent(id))).job;if(!isCurrent())return null;
}
if(!isCurrent())return null;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(current);renderJobs();return current;
}
async function saveMatrix(id){
const entry=matrixEntry(id),generation=state.sessionGeneration,isCurrent=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.entries.get(id)===entry;
if(entry.reading||entry.saving||entry.conflict||!entry.dirty)return;
try{
const changes=matrixChanges(entry);if(!changes.length){entry.dirty=false;updateMatrixStatus(entry);renderCopy();return;}
entry.epoch++;entry.saving=true;entry.error='';updateMatrixStatus(entry);renderCopy();
const result=await api('apply',{targets:[{id,version:entry.data.version}],changes});if(!isCurrent())return;
const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;
const outcome=job.results?.find(r=>(typeof r.target==='object'?r.target.id:r.target)===id);
if(outcome?.status!=='success'){entry.conflict=outcome?.status==='conflict';throw Error(outcome?.message||'Сохранение не подтверждено. Проверьте журнал.');}
entry.saving=false;if(!isCurrent())return;await refreshOverviewTarget(id,true);if(!isCurrent())return;if(overview.entries.get(id)?.status==='success'){entry.written=true;updateMatrixStatus(entry);}
if(state.source===id){await fetchRead(id);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
}catch(error){if(isCurrent()){entry.error=error.message;entry.conflict=entry.conflict||error.status===409;}}
finally{if(isCurrent()){entry.saving=false;updateMatrixStatus(entry);renderCopy();}}
}
const presets={items:[],loaded:false,loading:false,error:'',serial:0,busy:false,snapshot:null};
async function loadPresets(){
const serial=++presets.serial,generation=state.sessionGeneration;presets.loading=true;presets.error='';renderCopy();
try{const result=await api('utm-presets');if(serial!==presets.serial||generation!==state.sessionGeneration||!state.csrf)return;presets.items=result.presets;presets.loaded=true;if(!presets.items.some(p=>p.id===matrix.copySource))matrix.copySource='';}
catch(error){if(serial===presets.serial&&generation===state.sessionGeneration)presets.error=error.message;}
finally{if(serial===presets.serial&&generation===state.sessionGeneration){presets.loading=false;renderCopy();}}
}
function currentUtm(entry){
if(!entry.data||entry.reading||entry.saving)throw Error('Дождитесь чтения настроек и завершения сохранения.');
const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);
const utm={};
for(const marker of markers){const group=entry.groups.get(marker),raw=groupFor(entry.data.config,marker);if(!raw.key&&!group?.added)continue;
if(!group)throw Error(marker+': некорректная группа.');
if(!group.touched&&!group.added){utm[marker]={default:structuredClone(raw.value.default),weights:structuredClone(raw.value.weights)};continue;}
if(!group.rows.length||!group.defaultRow)throw Error(marker+': заполните варианты и выберите default.');
const weights={};for(const row of group.rows){if(!row.isNew&&!Object.hasOwn(group.original.weights,row.variant.value)&&Number(row.weight.value)===0)continue;weights[row.variant.value]=Number(row.weight.value);}
utm[marker]={default:group.defaultRow.variant.value,weights};
}
if(!Object.keys(utm).length)throw Error('Нет UTM для сохранения.');return utm;
}
function openPreset(id){
try{presets.snapshot=currentUtm(matrixEntry(id));$('preset-name').value='';$('preset-name').disabled=false;$('preset-save').disabled=false;$('preset-cancel').disabled=false;$('preset-error').textContent='';$('preset-dialog').showModal();$('preset-name').focus();}
catch(error){notice(error.message);}
}
async function savePreset(event){
event.preventDefault();if(presets.busy||!presets.snapshot)return;const generation=state.sessionGeneration;
presets.busy=true;$('preset-save').disabled=true;$('preset-cancel').disabled=true;$('preset-name').disabled=true;$('preset-error').textContent='';
try{const result=await api('utm-presets',{name:$('preset-name').value,utm:presets.snapshot});if(generation!==state.sessionGeneration||!state.csrf)return;
presets.serial++;presets.loading=false;presets.loaded=true;presets.error='';presets.items=presets.items.filter(p=>p.id!==result.preset.id);presets.items.push({id:result.preset.id,name:result.preset.name});matrix.copySource=result.preset.id;
$('preset-dialog').close();presets.snapshot=null;notice('UTM-набор «'+result.preset.name+'» сохранён в админке. Настройки сайтов не изменены.','success');renderCopy();
}catch(error){if(generation===state.sessionGeneration)$('preset-error').textContent=error.message;}
finally{if(generation===state.sessionGeneration){presets.busy=false;$('preset-save').disabled=false;$('preset-cancel').disabled=false;$('preset-name').disabled=false;}}
}
async function deletePreset(){
const selected=presets.items.find(p=>p.id===matrix.copySource);if(!selected||presets.busy||presets.loading||matrix.copyBusy)return;
if(!confirm('Удалить набор «'+selected.name+'» из админки? Настройки сайтов и заметки не изменятся.'))return;
const generation=state.sessionGeneration;presets.busy=true;renderCopy();
try{await api('utm-presets',{id:selected.id},'DELETE');if(generation!==state.sessionGeneration||!state.csrf)return;presets.serial++;presets.items=presets.items.filter(p=>p.id!==selected.id);if(matrix.copySource===selected.id)matrix.copySource='';presets.error='';notice('Набор удалён.','success');}
catch(error){if(generation===state.sessionGeneration)presets.error=error.message;}
finally{if(generation===state.sessionGeneration){presets.busy=false;renderCopy();}}
}
function renderCopy(){
$('copy-all').disabled=matrix.copyBusy;$('copy-none').disabled=matrix.copyBusy;
const resultBox=$('copy-results');resultBox.replaceChildren();if(matrix.copyError)resultBox.append(el('p',{class:'overview-error',text:matrix.copyError}));for(const result of orderedResults(matrix.copyResults)){const id=typeof result.target==='object'?result.target.id:result.target;resultBox.append(el('p',{class:'status-'+result.status,text:targetName(id)+' — '+(statuses[result.status]||result.status)+(result.message?' · '+result.message:'')}));}
const source=$('copy-source');source.replaceChildren(el('option',{value:'',text:'Выберите сохранённый набор'}),...presets.items.map(p=>el('option',{value:p.id,text:p.name})));source.value=matrix.copySource;source.disabled=matrix.copyBusy||presets.busy||presets.loading;
$('preset-delete').disabled=!presets.items.some(p=>p.id===matrix.copySource)||matrix.copyBusy||presets.busy||presets.loading;
$('preset-reload').disabled=presets.loading||presets.busy||matrix.copyBusy;
$('preset-list-status').textContent=presets.loading?'Загрузка наборов…':presets.error?'Не удалось обновить список: '+presets.error:presets.loaded&&!presets.items.length?'Пока нет сохранённых наборов. Нажмите дискету рядом с доменом.':'';
const box=$('copy-targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',checked:matrix.copyTargets.has(target.id),disabled:matrix.copyBusy||!target.enabled||!target.verified,'aria-label':'Копировать в '+target.domain});input.addEventListener('change',()=>{if(input.checked)matrix.copyTargets.add(target.id);else matrix.copyTargets.delete(target.id);renderCopy();});box.append(el('label',{},input,target.domain));}
const targets=[...matrix.copyTargets].map(id=>matrixEntry(id));let reason=matrix.copyBusy?'Копирование…':presets.loading?'Дождитесь загрузки наборов.':presets.error?'Обновите список сохранённых наборов.':!presets.items.some(p=>p.id===matrix.copySource)?'Выберите сохранённый набор.':!targets.length?'Выберите домены получателей.':targets.some(e=>!state.targets.find(t=>t.id===e.id)?.enabled||!state.targets.find(t=>t.id===e.id)?.verified)?'Получатель отключён или соединение не проверено.':targets.some(e=>e.data&&matrixValidation(e))?'Получатель: '+matrixValidation(targets.find(e=>e.data&&matrixValidation(e))):targets.some(e=>e.dirty||e.saving)?'Сначала сохраните или отмените изменения получателей.':targets.some(e=>!e.data||e.conflict||overview.entries.get(e.id)?.status!=='success')?'Обновите настройки получателей.':'';
$('copy-reason').textContent=reason;$('copy-submit').disabled=!!reason||presets.busy;$('copy-submit').textContent='Копировать в '+targets.length+' '+(targets.length%10===1&&targets.length%100!==11?'домен':targets.length%10>=2&&targets.length%10<=4&&(targets.length%100<12||targets.length%100>14)?'домена':'доменов');
}
async function copySettings(){
if($('copy-submit').disabled)return;
const preset=matrix.copySource,ids=[...matrix.copyTargets],generation=state.sessionGeneration,operation=++matrix.copyEpoch;
const entries=new Map(ids.map(id=>[id,matrix.entries.get(id)]));
const sameOperation=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.copyEpoch===operation;
const isCurrent=()=>sameOperation()&&[...entries].every(([id,entry])=>matrix.entries.get(id)===entry);
matrix.copyError='';matrix.copyResults=[];matrix.copyBusy=true;for(const entry of entries.values()){entry.epoch++;entry.saving=true;updateMatrixStatus(entry);}renderCopy();
try{
const result=await api('copy',{preset,targets:ids.map(id=>({id,version:entries.get(id).data.version}))});if(!isCurrent())return;
const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;matrix.copyResults=job.results||[];
for(const outcome of job.results||[]){if(!isCurrent())return;const id=typeof outcome.target==='object'?outcome.target.id:outcome.target,entry=entries.get(id);if(!entry)continue;
if(outcome.status==='success'){entry.saving=false;await refreshOverviewTarget(id);if(!isCurrent())return;if(overview.entries.get(id)?.status==='success')entry.written=true;}
else{entry.error=outcome.message||'Не удалось скопировать настройки.';entry.conflict=outcome.status==='conflict';updateMatrixStatus(entry);}}
if(!isCurrent())return;notice('Копирование завершено. Результат каждого домена показан в журнале.','info');
if(state.source&&ids.includes(state.source)){await fetchRead(state.source);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
}catch(error){if(isCurrent()){matrix.copyError=error.message;notice(error.message);if(error.status===404)run(loadPresets);}}
finally{if(sameOperation()){matrix.copyBusy=false;for(const [id,entry]of entries)if(matrix.entries.get(id)===entry){entry.saving=false;updateMatrixStatus(entry);}renderCopy();}}
}
function syncOverview(refresh=true){
const ids=new Set(state.targets.map(target=>target.id));for(const id of matrix.entries.keys())if(!ids.has(id)){matrix.entries.get(id).epoch++;matrix.entries.delete(id);matrix.copyTargets.delete(id);matrix.operationTargets.delete(id);}for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
if(builder.target&&!ids.has(builder.target))builder.target='';
const select=$('builder-target');select.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));select.value=builder.target;renderOverview();if(builder.target&&builder.entries.get(builder.target)?.domain!==state.targets.find(t=>t.id===builder.target)?.domain)run(()=>openBuilder(builder.target,true));else renderBuilder();if(refresh)run(refreshOverview);
}
function selectionsEqual(a,b){return !!a&&!!b&&markers.every(marker=>String(a[marker])===String(b[marker]));}
function constructorValid(selection){return markers.every(marker=>selection[marker]==='rnd'||!!digitFor(marker,selection[marker])&&String(digitFor(marker,selection[marker]))===selection[marker]);}
function builderWarnings(entry){
const result=[],snapshot=overview.entries.get(builder.target),config=snapshot?.data?.config;
if(!config){result.push('Текущий runtime-конфиг не прочитан; фактическое применение выбранных значений не проверено.');return result;}
if(snapshot.status!=='success')result.push('Проверка выбора использует прежний снимок runtime-конфига; обновите сводку.');
for(const marker of markers){const choice=entry.draft[marker],group=groupFor(config,marker);if(group.conflict.length){result.push(marker+': конфликт ключей '+group.conflict.join(', ')+'. Публичный resolver может выбрать другое значение.');continue;}
if(choice!=='rnd'&&!digitFor(marker,choice)){result.push(marker+': выберите допустимое значение. Сохранённый выбор не заменён автоматически.');continue;}
const weights=group.value?.weights||{},variants=Object.keys(weights).map(v=>digitFor(marker,v));
if(['indpic','degr','tp'].includes(marker)&&choice!=='rnd'&&!variants.includes(choice))result.push(marker+'='+choice+': варианта нет в текущих весах; при открытии ссылки resolver использует default (или штатный fallback). Конфигурация не меняется.');
if(!group.key)result.push(marker+': группа отсутствует в runtime; при открытии применяется штатный fallback.');
}
return result;
}
function renderBuilder(){
const entry=builder.entries.get(builder.target);$('builder-fields').replaceChildren();$('builder-result').hidden=true;$('builder-warnings').replaceChildren();$('builder-save').disabled=true;
if(!builder.target){$('builder-state').textContent='Выберите домен.';return;}
if(!entry?.ready){$('builder-state').textContent=entry?.error?'Ошибка чтения выбора: '+entry.error:'Чтение последнего выбора…';if(entry?.error)$('builder-fields').append(button('Повторить чтение выбора',()=>openBuilder(builder.target,true)));return;}
for(const marker of markers){const values=[...(marker==='part'?['1','2']:Array.from({length:9},(_,i)=>String(i+1))),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)})));
if(entry.draft[marker]&&!values.includes(entry.draft[marker]))select.append(el('option',{value:entry.draft[marker],text:'Недопустимый сохранённый выбор: '+entry.draft[marker]}));select.value=entry.draft[marker]||'';select.disabled=entry.saving;
select.addEventListener('change',()=>{entry.draft[marker]=select.value;entry.error='';updateBuilderState();});$('builder-fields').append(labelControl(marker,select));
}
updateBuilderState();
}
function updateBuilderState(){
const entry=builder.entries.get(builder.target);if(!entry?.ready)return;const dirty=!selectionsEqual(entry.draft,entry.saved),valid=constructorValid(entry.draft);
$('builder-state').textContent=entry.saving?'Формирование и сохранение…':entry.error?'Ошибка: '+entry.error:dirty?'Есть несохранённый выбор. Нажмите «Сформировать и сохранить».':'Показан последний сохранённый выбор этого домена.';
$('builder-save').disabled=!!entry.saving||!valid;for(const select of $('builder-fields').querySelectorAll('select'))select.disabled=!!entry.saving;
$('builder-warnings').replaceChildren(...builderWarnings(entry).map(text=>el('p',{class:'alert alert-warning',text})));
const show=!!entry.url&&!dirty;let safeUrl=null;if(show){try{const parsed=new URL(entry.url),target=state.targets.find(t=>t.id===builder.target);if(parsed.protocol==='https:'&&parsed.host===target?.domain)safeUrl=parsed.href;}catch(_){}}
$('builder-result').hidden=!safeUrl;if(safeUrl){$('builder-url').value=safeUrl;$('builder-open').href=safeUrl;$('builder-saved-at').textContent=entry.savedAt?'Выбор сохранён '+formatDate(entry.savedAt):'';}else if(show)$('builder-state').textContent='Сервер вернул недопустимую ссылку. Сформируйте её повторно.';
}
async function openBuilder(id,force=false){
builder.target=id;$('builder-target').value=id;if(!id){renderBuilder();return;}const domain=state.targets.find(t=>t.id===id)?.domain;if(builder.entries.get(id)?.ready&&builder.entries.get(id).domain===domain&&!force){renderBuilder();return;}
const request=++builder.serial;builder.entries.set(id,{ready:false,request,domain});renderBuilder();
try{const stored=await api('link-builder?target='+encodeURIComponent(id));if(!stored.selection&&!overview.entries.get(id)?.data)await(overview.pending.get(id)||refreshOverviewTarget(id));if(builder.entries.get(id)?.request!==request||!state.csrf)return;
const saved=stored.selection?{...stored.selection}:null;const draft=saved?{...saved}:{};if(!saved){const config=overview.entries.get(id)?.data?.config;for(const marker of markers){const group=groupFor(config,marker);draft[marker]=group.conflict.length?'':digitFor(marker,group.value?.default)||'';}}
builder.entries.set(id,{ready:true,request,domain,draft,saved,url:stored.url,savedAt:stored.saved_at,error:''});
}catch(error){if(builder.entries.get(id)?.request===request&&state.csrf)builder.entries.set(id,{ready:false,request,domain,error:error.message});}finally{if(builder.target===id)renderBuilder();}
}
async function saveBuilder(){
const id=builder.target,entry=builder.entries.get(id);if(!entry?.ready||!constructorValid(entry.draft)||entry.saving)return;const request=entry.request;entry.saving=true;entry.error='';updateBuilderState();
try{const result=await api('link-builder',{target:id,selection:{...entry.draft}});if(builder.entries.get(id)?.request!==request||!state.csrf)return;entry.saved={...result.selection};entry.draft={...result.selection};entry.url=result.url;entry.savedAt=result.saved_at;}catch(error){entry.error=error.message;}finally{entry.saving=false;if(builder.target===id)updateBuilderState();}
}
$('overview-refresh').addEventListener('click',()=>run(refreshOverview));
$('builder-target').addEventListener('change',event=>run(()=>openBuilder(event.target.value)));
$('builder-save').addEventListener('click',()=>run(saveBuilder));
$('builder-copy').addEventListener('click',()=>run(async()=>{const input=$('builder-url');if($('builder-result').hidden)return;try{if(!navigator.clipboard)throw new Error();await navigator.clipboard.writeText(input.value);notice('Ссылка скопирована.','success');}catch(_){input.focus();input.select();notice('Ссылка выделена. Скопируйте её через меню браузера или Ctrl+C.','info');}}));
const statuses={queued:'Ожидание',running:'Выполняется',success:'Сохранено и проверено',error:'Ошибка',conflict:'Конфликт версии — перечитайте домен',unknown:'Неизвестный результат — проверьте состояние'};
function el(tag,attrs={},...children){const n=document.createElement(tag);for(const [k,v] of Object.entries(attrs)){if(k==='class')n.className=v;else if(k==='text')n.textContent=v;else if(k.startsWith('on'))n.addEventListener(k.slice(2),v);else if(k in n && k!=='list')n[k]=v;else n.setAttribute(k,v);}for(const c of children.flat())if(c!==undefined&&c!==null)n.append(c instanceof Node?c:document.createTextNode(String(c)));return n;}
const button=(label,action,kind='default')=>{const control=el('button',{type:'button',class:'btn btn-'+kind,text:label});control.addEventListener('click',()=>run(async()=>{control.disabled=true;try{await action();}finally{control.disabled=false;for(const entry of matrix.entries.values())updateMatrixStatus(entry);renderCopy();}}));return control;};
function notice(message,type='danger'){const n=$('notice');n.className='alert alert-'+type;n.textContent=message;n.hidden=!message;}
async function api(path,data,method='POST'){
const generation=state.sessionGeneration;const options={method:data===undefined?'GET':method,credentials:'same-origin',headers:{'Accept':'application/json'}};
if(data!==undefined){options.headers['X-CSRF-Token']=state.csrf;if(data instanceof FormData)options.body=data;else{options.headers['Content-Type']='application/json';options.body=JSON.stringify(data);}}
let response;try{response=await fetch('/api/'+path,options);}catch(_){throw new Error('Связь с админкой прервана. Откройте журнал: операция могла сохраниться на сервере.');}
let body;try{body=await response.json();}catch(_){throw new Error('Сервер вернул непонятный ответ. Проверьте журнал перед повтором.');}
if(generation!==state.sessionGeneration){const error=new Error('Ответ относится к завершённому сеансу.');error.staleSession=true;throw error;}if(!response.ok||!body.ok){if(response.status===401&&generation===state.sessionGeneration)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
}
async function run(action){try{await action();}catch(error){if(!error.staleSession)notice(error.message);}}
function showLogin(){state.sessionGeneration++;presets.serial++;presets.items=[];presets.loaded=false;presets.loading=false;presets.busy=false;presets.snapshot=null;presets.error='';$('preset-dialog').close();matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.targetsEpoch++;state.targetsBusy=false;state.targets=[];state.allTargets=[];state.source='';state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
function selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
function targetName(id){const t=state.targets.find(t=>t.id===id);return t? t.domain+' ('+t.environment+')':id;}
function equivalent(a,b){return JSON.stringify(a)===JSON.stringify(b);}
function readCurrent(){return state.reads.get(state.source);}
function numberInput(value,min=undefined,max=undefined){const n=el('input',{class:'form-control',type:'number',value:String(value),step:'any'});if(min!==undefined)n.min=min;if(max!==undefined)n.max=max;return n;}
function labelControl(text,input){const label=el('label',{text});label.append(input);return label;}
function optionLabel(marker,value){const digit=Object.entries(enums[marker]||{}).find(([,v])=>v===value)?.[0]||value;return digit+(descriptions[marker]?.[digit]?' — '+descriptions[marker][digit]:'');}
async function fetchRead(id){const generation=state.sessionGeneration;const data=await api('config?target='+encodeURIComponent(id));if(generation===state.sessionGeneration&&state.targets.some(t=>t.id===id))state.reads.set(id,data);return data;}
async function openTarget(id){if(id&&!state.targets.some(t=>t.id===id))return;if(id!==state.source)matrix.operationTargets=new Set(id?[id]:[]);state.source=id;$('sets-body').replaceChildren();$('backups').replaceChildren();renderTargetOptions();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение конфигурации для наборов и резерва…';try{await fetchRead(id);await Promise.all([loadSets(),loadBackups()]);if(state.source===id)$('read-state').textContent='Домен прочитан.';}finally{state.loading--;}} function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:matrix.operationTargets.has(target.id)&&target.enabled,disabled:!target.enabled});const status=el('span',{class:'text-muted'});const label=el('label',{},input,targetName(target.id),status);box.append(label);input.addEventListener('change',()=>run(async()=>{if(input.checked)matrix.operationTargets.add(target.id);else matrix.operationTargets.delete(target.id);if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}}));}}
async function loadState(){if(state.targetsBusy)return;const epoch=state.targetsEpoch,generation=state.sessionGeneration;const data=await api('state');if(generation!==state.sessionGeneration||epoch!==state.targetsEpoch||state.targetsBusy)return;state.csrf=data.csrf;$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;acceptTargets(data.targets||[]);run(loadPresets);await loadJobs();}
function orderedResults(results){return (results||[]).filter(visibleResult).slice().sort((a,b)=>{const id=r=>typeof r.target==='object'?r.target.id:r.target;return state.targets.findIndex(t=>t.id===id(a))-state.targets.findIndex(t=>t.id===id(b));});}
function visibleResult(result){const id=typeof result.target==='object'?result.target.id:result.target;return state.targets.some(t=>t.id===id);}
function acceptTargets(targets,refresh=true){
state.allTargets=targets;state.targets=targets.filter(t=>!t.hidden);const ids=new Set(state.targets.map(t=>t.id));
for(const id of state.reads.keys())if(!ids.has(id))state.reads.delete(id);
if(state.source&&!ids.has(state.source)){state.source='';$('sets-body').replaceChildren();$('backups').replaceChildren();$('read-state').textContent='Выберите домен.';if($('indpic-result-modal').open)$('indpic-result-modal').close();}
const form=$('connection-form');if(form.elements.id.readOnly&&!ids.has(form.elements.id.value)){$('connection-new').click();}
const source=$('source-target');source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));source.value=state.source;
renderTargetOptions();renderConnections();syncOverview(refresh);renderJobs();renderHidden();
}
async function mutateTargets(path,data,method='POST'){
if(state.targetsBusy)return;const generation=state.sessionGeneration;state.targetsBusy=true;state.targetsEpoch++;renderTargetControls();
try{const result=await api(path,data,method);if(generation!==state.sessionGeneration||!state.csrf)return;if(result.targets)acceptTargets(result.targets,path!=='targets/order');else{const snapshot=await api('state');if(generation===state.sessionGeneration&&state.csrf)acceptTargets(snapshot.targets||[]);}if(generation===state.sessionGeneration&&state.csrf){const message=path==='targets/order'?'Порядок доменов сохранён.':path==='targets/visibility'?(data.hidden?'Домен скрыт.':'Домен возвращён.'):method==='DELETE'?'Подключение удалено.':'';if(message)notice(message,'success');}}
finally{if(generation===state.sessionGeneration){state.targetsBusy=false;renderTargetControls();}}
}
function renderTargetControls(){document.querySelectorAll('.domain-hide,.domain-grip,#hidden-domains button').forEach(n=>n.disabled=state.targetsBusy);$('connection-form').querySelector('button[type=submit],button:not([type])')?.toggleAttribute('disabled',state.targetsBusy);}
function hideTarget(id){const entry=matrix.entries.get(id);if(entry?.dirty||entry?.saving||matrix.copyBusy||state.loading){notice('Сначала сохраните или отмените изменения домена и дождитесь завершения текущей операции.');return;}return mutateTargets('targets/visibility',{id,hidden:true});}
function renderHidden(){const hidden=state.allTargets.filter(t=>t.hidden);$('hidden-toggle').textContent='Скрытые домены ('+hidden.length+')';const box=$('hidden-list');box.replaceChildren();if(!hidden.length)box.append(el('p',{class:'text-muted',text:'Скрытых доменов пока нет.'}));for(const target of hidden)box.append(el('div',{class:'hidden-domain-row','data-hidden-target':target.id},el('span',{class:'grow break'},el('strong',{text:target.domain}),el('span',{class:'text-muted',text:' · '+target.environment})),Object.assign(button('Вернуть',()=>mutateTargets('targets/visibility',{id:target.id,hidden:false})),{ariaLabel:'Вернуть домен '+target.domain}),Object.assign(button('Удалить',()=>{if(confirm('Удалить подключение '+target.domain+' из админки? Сайт, файлы, DNS и регистрация домена останутся без изменений.'))return mutateTargets('targets',{id:target.id},'DELETE');},'danger'),{ariaLabel:'Удалить домен '+target.domain})));renderTargetControls();}
function reorderTarget(id,before){if(state.targetsBusy)return;const ids=state.targets.map(t=>t.id),next=ids.filter(v=>v!==id),index=before?next.indexOf(before):next.length;next.splice(index<0?next.length:index,0,id);if(next.join('|')===ids.join('|'))return;return mutateTargets('targets/order',{ids:next});}
function domainHeading(target){
const grip=el('button',{type:'button',class:'domain-grip',text:'⠿',title:'Перетащить домен; стрелки влево/вправо меняют порядок','aria-label':'Переместить домен '+target.domain});
grip.addEventListener('keydown',event=>{if(!['ArrowLeft','ArrowRight'].includes(event.key))return;event.preventDefault();const ids=state.targets.map(t=>t.id),i=ids.indexOf(target.id),next=i+(event.key==='ArrowRight'?1:-1);if(next<0||next>=ids.length)return;run(()=>reorderTarget(target.id,event.key==='ArrowRight'?ids[next+1]:ids[next]));});
grip.addEventListener('pointerdown',event=>startDomainDrag(event,target.id));
const hide=el('button',{type:'button',class:'domain-hide',title:'Скрыть домен','aria-label':'Скрыть домен '+target.domain,onclick:()=>run(()=>hideTarget(target.id))});
hide.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="m3 3 18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.5 5.3A11 11 0 0 1 12 5c6 0 10 7 10 7a17 17 0 0 1-3.4 4.1M6.2 6.2C3.6 8 2 12 2 12s4 7 10 7a11 11 0 0 0 5.1-1.4"/></svg>';
const save=el('button',{type:'button',class:'domain-preset',title:'Сохранить UTM-настройки','aria-label':'Сохранить UTM-набор '+target.domain,onclick:()=>openPreset(target.id)});
save.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2Zm2 0v6h10V3M7 21v-8h10v8"/></svg>';
return el('div',{class:'domain-heading'},grip,el('span',{class:'overview-domain',text:target.domain}),save,hide);
}
function startDomainDrag(event,id){
if(event.button!==0||state.targetsBusy)return;event.preventDefault();const grip=event.currentTarget,wrapper=$('overview-table').closest('.overview-wrapper'),header=grip.closest('th'),preview=header.cloneNode(true),marker=el('div',{class:'domain-insertion'});preview.className='domain-drag-preview';preview.style.width=header.offsetWidth+'px';document.body.append(preview,marker);let before=id,x=event.clientX,y=event.clientY,frame;
const move=()=>{preview.style.left=x+12+'px';preview.style.top=y+12+'px';const rect=wrapper.getBoundingClientRect();if(x<rect.left+45)wrapper.scrollLeft-=15;else if(x>rect.right-45)wrapper.scrollLeft+=15;const heads=[...$('overview-table').querySelectorAll('[data-overview-target]')].filter(n=>n.dataset.overviewTarget!==id);const next=heads.find(n=>x<n.getBoundingClientRect().left+n.offsetWidth/2);before=next?.dataset.overviewTarget||null;const edge=next?.getBoundingClientRect().left||heads.at(-1)?.getBoundingClientRect().right||rect.left;marker.style.cssText='left:'+Math.max(rect.left,Math.min(rect.right,edge))+'px;top:'+rect.top+'px;height:'+Math.min(rect.height,innerHeight-rect.top)+'px';frame=requestAnimationFrame(move);};
const pointerMove=e=>{x=e.clientX;y=e.clientY;};const finish=e=>{cancelAnimationFrame(frame);document.removeEventListener('pointermove',pointerMove);document.removeEventListener('pointerup',finish);document.removeEventListener('pointercancel',cancel);preview.remove();marker.remove();document.body.classList.remove('domain-dragging');if(e.type==='pointerup')run(()=>reorderTarget(id,before));};const cancel=e=>finish(e);document.body.classList.add('domain-dragging');document.addEventListener('pointermove',pointerMove);document.addEventListener('pointerup',finish);document.addEventListener('pointercancel',cancel);move();
}
function safeTargets(){const ids=selected();if(!ids.length)throw new Error('Выберите хотя бы один домен.');if(state.loading||ids.some(id=>!state.reads.has(id)))throw new Error('Сначала перечитайте выбранные домены и дождитесь загрузки.');return ids;}
async function applyChanges(changes,ids=safeTargets()){
if(!changes.length)throw new Error('Выберите поля для применения.');
const targets=ids.map(id=>{const read=state.reads.get(id);if(!read)throw new Error('Сначала прочитайте конфигурацию '+targetName(id));return {id,version:read.version};});
try{const result=await api('apply',{targets,changes});notice('Задание принято. Проверяйте результат каждой цели в журнале.','info');await showJob(result.job);}finally{}
}
async function loadSets(){if(!state.source)return;const targetId=state.source;const data=await api('sets?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;const current=readCurrent();if(current)current.sets=data.sets;const body=$('sets-body');body.replaceChildren();for(const set of (data.sets||[]).filter(s=>s.exists||s.registered||s.in_use)){const tr=el('tr',{class:'indpic-set-row'});const name=el('input',{class:'form-control utm-group-name',value:set.name||'',maxLength:255,'aria-label':'Название Set '+set.number});const namecell=el('td',{},el('span',{class:'utm-group-number',text:String(set.number)}),name,el('button',{type:'button',class:'utm-group-save',text:set.registered?'Сохранить':'Проверить и добавить',onclick:()=>run(async()=>{if(set.registered){await api('sets/name',{target:state.source,number:set.number,name:name.value});notice('Название сохранено.','success');}else{const result=await api('sets/register',{targets:[state.source],number:set.number,name:name.value});await showJob(result.job,true);}})}));const def=el('td');if(set.default)def.append(el('span',{class:'utm-default-value',text:'default'}));else def.append(el('button',{type:'button',class:'utm-default-action',text:'назначить',onclick:()=>run(()=>applyChanges([{path:['utm','indpic','default'],value:String(set.number)}],[state.source]))}));
const file=el('input',{type:'file',accept:'.zip,application/zip','aria-label':'ZIP Set '+set.number});const zip=el('td',{},el('div',{class:'indpic-file-control'},file,button('Загрузить ZIP',()=>uploadSet(file.files[0],set.number,name.value,set.in_use))));
const actions=el('td');if(!set.default&&!set.in_use&&set.registered)actions.append(button('Удалить',async()=>{if(confirm('Удалить Set '+set.number+' из реестра? Файлы на хосте останутся.')){await api('sets/delete',{target:state.source,number:set.number});await loadSets();}},'danger'));else actions.append(el('span',{class:'text-muted',text:set.in_use?'Используется':'Не в реестре'}));
tr.append(namecell,def,el('td',{},button('Проверить',async()=>{const r=await api('sets/check',{targets:safeTargets(),number:set.number});await showJob(r.job,true);})),zip,actions);body.append(tr);
}if(!(data.sets||[]).some(s=>s.exists||s.registered||s.in_use))body.append(el('tr',{},el('td',{colSpan:5,text:'Наборы ещё не зарегистрированы.'})));
}
async function uploadSet(file,number,name,inUse=false){if(!file)throw new Error('Выберите ZIP-файл.');const ids=safeTargets();const existing=ids.filter(id=>(state.reads.get(id)?.sets||[]).some(s=>String(s.number)===String(number)&&(s.exists||s.in_use)));if((inUse||existing.length)&&!confirm('Заменить существующий набор '+number+' на выбранных доменах? Он может использоваться как default или с ненулевым весом. Default и веса не изменятся.'))return;const form=new FormData();form.set('file',file);form.set('number',number);form.set('name',name);form.set('targets',JSON.stringify(safeTargets()));const r=await api('sets/upload',form);await showJob(r.job,true);}
function modal(title,text){$('result-title').textContent=title;$('result-body').textContent=text;if(!$('indpic-result-modal').open)$('indpic-result-modal').showModal();}
function renderJobs(){const box=$('jobs');box.replaceChildren();const visibleJobs=state.jobs.filter(job=>orderedResults(job.results).length);if(!visibleJobs.length)box.append(el('p',{class:'text-muted',text:'Заданий для видимых доменов пока нет.'}));for(const job of visibleJobs){const block=el('div',{class:'job'},el('strong',{class:'break',text:'Задание '+job.id+' · '+(job.status==='completed'?'Завершено':statuses[job.status]||job.status)}));if(job.created_at)block.append(el('p',{class:'text-muted',text:formatDate(job.created_at)}));if(job.changes)block.append(el('p',{class:'text-muted break',text:'Поля: '+job.changes.map(c=>Array.isArray(c.path)?c.path.join('.'):String(c.path)).join(', ')}));const table=el('table',{class:'table table-bordered'},el('thead',{},el('tr',{},el('th',{text:'Домен'}),el('th',{text:'Результат'}),el('th',{text:'Повтор'}))));const body=el('tbody');const retry=[];for(const result of orderedResults(job.results)){const id=typeof result.target==='object'?result.target.id:result.target;const tr=el('tr');const outcome=el('td',{class:'status-'+result.status},el('strong',{text:statuses[result.status]||result.status}),el('div',{class:'break',text:[result.message,...(result.warnings||[])].filter(Boolean).join(' · ')}));const action=el('td');if(['error','conflict','unknown'].includes(result.status)){const check=el('input',{type:'checkbox',value:id,'aria-label':'Повтор '+targetName(id),checked:false});retry.push(check);action.append(check);}tr.append(el('td',{text:targetName(id)}),outcome,action);body.append(tr);}table.append(body);block.append(el('div',{class:'table-responsive'},table));if(retry.length)block.append(button('Повторить выбранные неуспешные цели',async()=>{const ids=retry.filter(c=>c.checked).map(c=>c.value);if(!ids.length)throw new Error('Отметьте неуспешные цели для повтора.');if((job.results||[]).some(r=>ids.includes(r.target)&&r.status==='conflict')){throw new Error('Конфликт нельзя повторить со старой версией. Перечитайте домен, проверьте значения и примените поля заново.');}const r=await api('jobs/retry',{id:job.id,targets:ids});await showJob(r.job);}));box.append(block);}}
async function loadJobs(){const r=await api('jobs');state.jobs=(r.jobs||[]).slice().sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||'')));renderJobs();for(const job of state.jobs)if(job.status!=='completed'&&!pollTimers.has(job.id))pollTimers.set(job.id,setTimeout(()=>run(()=>pollJob(job.id,false)),1400));}
const pollTimers=new Map();
async function showJob(job,dialog=false){const id=typeof job==='string'?job:job.id;if(typeof job==='object'){state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();}if(dialog)modal('Проверка / публикация indpic','Задание '+id+' принято. Ожидание проверки…');await pollJob(id,dialog);}
async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',orderedResults(job.results).map(v=>targetName(typeof v.target==='object'?v.target.id:v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore','copy'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success'&&visibleResult(result)){state.reads.delete(result.target);run(()=>refreshOverviewTarget(result.target));}if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}}}
async function loadBackups(){const box=$('backups');box.replaceChildren();if(!state.source)return;const targetId=state.source;const r=await api('backups?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;for(const backup of r.backups||[]){box.append(el('div',{class:'toolbar'},el('span',{class:'break',text:(backup.created_at?formatDate(backup.created_at):'')+' · '+backup.id}),button('Восстановить',async()=>{if(!confirm('Восстановить резерв '+backup.id+' на '+targetName(state.source)+'?'))return;const read=readCurrent();if(!read)throw new Error('Перед восстановлением перечитайте текущий домен.');const result=await api('restore',{target:state.source,id:backup.id,version:read.version});await showJob(result.job);})));}if(!(r.backups||[]).length)box.append(el('p',{class:'text-muted',text:'Резервных копий пока нет.'}));}
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});if(state.targets.includes(t)){t.verified=true;renderConnections();renderOverview();}modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');})));}}
$('hidden-toggle').addEventListener('click',()=>{$('hidden-domains').hidden=false;$('hidden-domains').scrollIntoView({block:'start'});});$('hidden-close').addEventListener('click',()=>{$('hidden-domains').hidden=true;$('overview').scrollIntoView({block:'start'});});
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;state.sessionGeneration++;state.targetsEpoch++;try{const data=await api('login',{username:form.username.value,password:form.password.value,remember:form.remember.checked});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
$('logout').addEventListener('click',()=>run(async()=>{if(matrixDirty()&&!confirm('Есть несохранённые изменения настроек или заметок. Выйти?'))return;await api('logout',{});for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();showLogin();notice('Сеанс завершён.','info');}));
$('source-target').addEventListener('change',event=>run(()=>openTarget(event.target.value)));
$('reload').addEventListener('click',()=>run(()=>openTarget(state.source)));
$('copy-source').addEventListener('change',e=>{matrix.copySource=e.target.value;matrix.copyError='';matrix.copyResults=[];renderCopy();});
$('copy-all').addEventListener('click',()=>{matrix.copyTargets=new Set(state.targets.filter(t=>t.enabled&&t.verified).map(t=>t.id));renderCopy();});
$('copy-none').addEventListener('click',()=>{matrix.copyTargets.clear();renderCopy();});
$('preset-form').addEventListener('submit',event=>run(()=>savePreset(event)));
$('preset-cancel').addEventListener('click',()=>{$('preset-dialog').close();presets.snapshot=null;});
$('preset-dialog').addEventListener('cancel',event=>{if(presets.busy)event.preventDefault();else presets.snapshot=null;});
$('preset-delete').addEventListener('click',()=>run(deletePreset));
$('preset-reload').addEventListener('click',()=>run(loadPresets));
$('copy-submit').addEventListener('click',()=>run(copySettings)); $('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить сет';});
$('set-add').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const number=Number(form.elements.number.value);if(form.elements.sftp.checked){const r=await api('sets/register',{targets:safeTargets(),number,name:form.elements.name.value});await showJob(r.job,true);}else await uploadSet(form.elements.file.files[0],number,form.elements.name.value,false);});});
$('connection-new').addEventListener('click',()=>{$('connection-form').reset();$('connection-form').elements.id.readOnly=false;$('connection-title').textContent='Добавить подключение';});
$('connection-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const target={};for(const key of ['id','name','domain','environment','type','host','user','root','host_key'])target[key]=form.elements[key].value;target.port=Number(form.elements.port.value);target.enabled=form.elements.enabled.checked;const data={target};if(form.elements.secret.value)data.secret=form.elements.secret.value;await mutateTargets('targets',data);form.elements.secret.value='';notice('Подключение сохранено. Проверьте соединение перед записью.','success');});});
$('refresh-history').addEventListener('click',()=>run(async()=>{await Promise.all([loadJobs(),loadBackups()]);notice('Журнал обновлён.','info');}));
for(const id of ['result-close','result-done'])$(id).addEventListener('click',()=>$('indpic-result-modal').close());
window.addEventListener('beforeunload',event=>{if(matrixDirty()){event.preventDefault();event.returnValue='';}});
run(async()=>{try{await loadState();}catch(error){if(error.status===401)showLogin();else throw error;}});
})();
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,LinkBuilder,Notes,Copy,UtmPresets};
header('X-Content-Type-Options: nosniff');header('X-Frame-Options: DENY');header('Referrer-Policy: same-origin');header('Cache-Control: no-store');
$path=parse_url($_SERVER['REQUEST_URI']??'/',PHP_URL_PATH);
if(!str_starts_with((string)$path,'/api/')) {require dirname(__DIR__).'/views/dashboard.php';exit;}
header('Content-Type: application/json; charset=utf-8');
function reply(array $payload,int $status=200): never {http_response_code($status);echo json_encode(['ok'=>true]+$payload,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);exit;}
try {
Auth::session();$store=new Store();$auth=new Auth($store);$jobs=new Jobs($store);$sets=new Sets($store);$method=$_SERVER['REQUEST_METHOD']??'GET';$data=[];
if(!in_array($method,['GET','POST','DELETE','PUT'],true))throw new Problem('Метод не поддерживается.',405);
if($method!=='GET'){
$origin=$_SERVER['HTTP_ORIGIN']??'';
if($origin!=='' && strcasecmp((string)parse_url($origin,PHP_URL_HOST),(string)preg_replace('/:\d+$/','',$_SERVER['HTTP_HOST']??''))!==0)throw new Problem('Недопустимый источник запроса.',403);
if(!str_starts_with($_SERVER['CONTENT_TYPE']??'','multipart/form-data')){
if((int)($_SERVER['CONTENT_LENGTH']??0)>2097152)throw new Problem('Запрос слишком большой.',413);
$raw=file_get_contents('php://input',false,null,0,2097153);if(strlen($raw)>2097152)throw new Problem('Запрос слишком большой.',413);
if($raw!==''){$object=json_decode($raw,false,32,JSON_THROW_ON_ERROR);if(!$object instanceof stdClass)throw new Problem('Ожидался объект запроса.');$data=(array)$object;}
}else $data=$_POST;
}
if($path==='/api/login'&&$method==='POST'){
if(array_key_exists('remember',$data)&&!is_bool($data['remember']))throw new Problem('Поле «Запомнить меня» должно быть логическим значением.');
reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown',$data['remember']??false));
}
$user=$auth->requireUser();
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
// Авторизация, idle timestamp и CSRF уже проверены; SSH/файловые операции
// не должны удерживать PHP session lock и блокировать соседние запросы.
session_write_close();
// Удаление и постановка заданий используют одну блокировку: транспорт нельзя
// удалить между проверкой существования подключения и записью задания.
$enqueue=static function(callable $create,array $selections)use($store){return $store->locked('targets',function()use($create,$selections,$store){foreach($selections as $selection){$id=is_array($selection)?($selection['id']??null):$selection;if(!is_string($id))throw new Problem('Некорректная цель задания.');$store->target($id);}return $create();});};
if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
if($path==='/api/utm-notes'){
$notes=new Notes($store);
if($method==='GET'){
if($_GET)throw new Problem('Заметки UTM общие для всех доменов и не принимают параметры.');
reply(['notes'=>$notes->all()]);
}
if($method==='POST')reply(['note'=>$notes->save($data)]);
throw new Problem('Метод заметок UTM не поддерживается.',405);
}
if($path==='/api/link-builder'){
$builder=new LinkBuilder($store);
if($method==='GET'){
if(array_diff(array_keys($_GET),['target'])||!is_string($_GET['target']??null))throw new Problem('Укажите ID подключения конструктора ссылки.');
reply($builder->get($_GET['target']));
}
if($method==='POST'){
if(count($data)!==2||array_diff(array_keys($data),['target','selection'])||!is_string($data['target']??null)||!(($data['selection']??null) instanceof stdClass))throw new Problem('Передайте ID подключения и объект выбора конструктора ссылки.');
reply($builder->save($data['target'],get_object_vars($data['selection'])));
}
throw new Problem('Метод конструктора ссылки не поддерживается.',405);
}
if($path==='/api/targets'&&$method==='POST')reply(['target'=>$store->saveTarget((array)($data['target']??[]),isset($data['secret'])?(string)$data['secret']:null)]);
if($path==='/api/targets/visibility'&&$method==='POST'){
if(count($data)!==2||array_diff(array_keys($data),['id','hidden'])||!is_string($data['id']??null)||!is_bool($data['hidden']??null))throw new Problem('Передайте строковый ID и логическое значение hidden.');
reply(['targets'=>$store->setTargetVisibility($data['id'],$data['hidden'])]);
}
if($path==='/api/targets/order'&&$method==='POST'){
if(count($data)!==1||!is_array($data['ids']??null)||!array_is_list($data['ids']))throw new Problem('Передайте список строковых ID в поле ids.');
reply(['targets'=>$store->orderTargets($data['ids'])]);
}
if($path==='/api/targets'&&$method==='DELETE'){$store->deleteTarget((string)($data['id']??''));reply([]);}
if($path==='/api/targets/test'&&$method==='POST'){$id=(string)($data['id']??'');$t=$store->target($id);(new Transport($store,$t))->test();$store->verifyTarget($id,$t);reply(['message'=>'Пути, блокировка, чтение и права записи проверены.']);}
if($path==='/api/config'&&$method==='GET'){
$target=$store->target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();$readAt=gmdate(DATE_ATOM);
reply(['config'=>$read['config'],'version'=>$read['version'],'read_at'=>$readAt,'warnings'=>[],'sets'=>$sets->list($target,$read['config'])]);
}
if($path==='/api/utm-presets'){
$presets=new UtmPresets($store);
if($method==='GET'){if($_GET)throw new Problem('Список наборов не привязан к доменам.');reply(['presets'=>$presets->all()]);}
if($method==='POST')reply(['preset'=>$presets->create($data)],201);
if($method==='DELETE'){if(count($data)!==1||!is_string($data['id']??null))throw new Problem('Передайте только ID набора.');$presets->delete($data['id']);reply([]);}
throw new Problem('Метод наборов не поддерживается.',405);
}
if($path==='/api/copy'){
if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405);
reply(['job'=>$enqueue(fn()=>(new Copy($store))->create($data),[])],202);
}
if($path==='/api/apply'&&$method==='POST'){$data['targets']=array_map(static fn($t)=>(array)$t,(array)($data['targets']??[]));$data['changes']=array_map(static fn($c)=>(array)$c,(array)($data['changes']??[]));reply(['job'=>$enqueue(fn()=>$jobs->create('apply',$data),$data['targets'])],202);}
if($path==='/api/jobs'&&$method==='GET')reply(isset($_GET['id'])?['job'=>$jobs->get((string)$_GET['id'])]:['jobs'=>$jobs->all()]);
if($path==='/api/jobs/retry'&&$method==='POST')reply(['job'=>$enqueue(fn()=>$jobs->retry((string)($data['id']??''),(array)($data['targets']??[])),(array)($data['targets']??[]))],202);
if($path==='/api/backups'&&$method==='GET'){$id=(string)($_GET['target']??'');$store->target($id);reply(['backups'=>$store->backups($id)]);}
if($path==='/api/restore'&&$method==='POST')reply(['job'=>$enqueue(fn()=>$jobs->create('restore',$data),[$data['target']??null])],202);
if($path==='/api/sets'&&$method==='GET'){$target=$store->target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();reply(['sets'=>$sets->list($target,$read['config']),'version'=>$read['version']]);}
if($path==='/api/sets/upload'&&$method==='POST'){
if(($_FILES['file']['error']??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK||!is_uploaded_file($_FILES['file']['tmp_name']))throw new Problem('ZIP не получен либо превышает лимит 64 МиБ.');
$number=Sets::number($data['number']??0);$prepared=$sets->prepareZip($_FILES['file']['tmp_name'],$number);
$targets=json_decode((string)($data['targets']??'[]'),true,16,JSON_THROW_ON_ERROR);
reply(['job'=>$enqueue(fn()=>$jobs->create('upload',['targets'=>$targets,'number'=>$number,'name'=>(string)($data['name']??''),'prepared'=>$prepared]),$targets)],202);
}
if(in_array($path,['/api/sets/check','/api/sets/register'],true)&&$method==='POST'){$data['number']=Sets::number($data['number']??0);reply(['job'=>$enqueue(fn()=>$jobs->create($path==='/api/sets/check'?'check':'register',$data),(array)($data['targets']??(isset($data['target'])?[$data['target']]:[])))],202);}
if(in_array($path,['/api/sets/name','/api/sets/delete'],true)&&$method==='POST'){$target=$store->target((string)($data['target']??''));$number=Sets::number($data['number']??0);if($path==='/api/sets/name')$sets->name($target,$number,(string)($data['name']??''));else $sets->delete($target,$number);reply([]);}
throw new Problem('Маршрут не найден.',404);
}catch(Problem $e){
// Запоздалый 401 (анонимный запрос, истечение сессии, ошибка входа) не должен
// подменять или удалять cookie более нового успешного входа. Истёкший backing
// Auth уже отзывает на сервере; явный logout возвращает 200 и очищает cookie.
if($e->status===401)header_remove('Set-Cookie');
http_response_code($e->status);echo json_encode(['ok'=>false,'error'=>$e->getMessage()],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
}
catch(JsonException){http_response_code(400);echo json_encode(['ok'=>false,'error'=>'Некорректный JSON запроса.'],JSON_UNESCAPED_UNICODE);}
catch(Throwable){http_response_code(500);echo json_encode(['ok'=>false,'error'=>'Операция не выполнена. Проверьте доступность и повторите.'],JSON_UNESCAPED_UNICODE);}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Auth
{
private const REMEMBER_SECONDS=90*86400;
private static bool $rememberSession=false;
public function __construct(private Store $store) {}
public static function session(): void {
if(session_status()===PHP_SESSION_ACTIVE)return;
$directory=self::sessionDirectory();
$rememberDirectory=self::sessionDirectory('remember-sessions',false);
$incoming=$_COOKIE['preland_admin']??null;
$validId=is_string($incoming)&&preg_match('/^[a-zA-Z0-9,-]{22,256}$/D',$incoming)===1;
if(!$validId)unset($_COOKIE['preland_admin']);
$remember=false;
if($validId&&$rememberDirectory!==null){
$candidate=$rememberDirectory.'/sess_'.$incoming;clearstatcache(true,$candidate);
if(is_link($candidate))throw new Problem('Символические ссылки файлов сессий запрещены.',503);
$stat=@lstat($candidate);$remember=$stat!==false&&($stat['mode']&0170000)===0100000;
}
self::configure($remember?$rememberDirectory:$directory,$remember);
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
// Между проверкой SID и session_start файл мог быть отозван logout другого запроса.
// Новый анонимный SID не должен оставаться в долгоживущем каталоге.
if($remember&&(session_id()!==$incoming||empty($_SESSION['user'])||!is_int($_SESSION['remember_until']??null))){
self::destroy(false);self::configure($directory,false);
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
}
}
private static function configure(string $directory,bool $remember): void {
if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503);
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
ini_set('session.gc_maxlifetime',(string)($remember?self::REMEMBER_SECONDS:3600));ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100');
session_name('preland_admin');
session_set_cookie_params(['lifetime'=>$remember?self::REMEMBER_SECONDS:0]+self::cookieOptions());
self::$rememberSession=$remember;
}
private static function cookieOptions(): array {return ['path'=>'/','domain'=>(string)ini_get('session.cookie_domain'),'secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict'];}
private static function destroy(bool $clearCookie): void {
$_SESSION=[];
if(session_status()===PHP_SESSION_ACTIVE&&!@session_destroy())throw new Problem('Не удалось отозвать сессию.',503);
unset($_COOKIE['preland_admin']);session_id('');
if($clearCookie)setcookie('preland_admin','',['expires'=>time()-3600]+self::cookieOptions());
self::$rememberSession=false;
}
/** Готовит приватный backing до Store: не создаёт данные в небезопасном state. */
private static function sessionDirectory(string $bucket='sessions',bool $create=true): ?string {
$state=rtrim(getenv('ADMIN_STATE_DIR')?:'/var/lib/preland-admin','/');
if($state===''||!str_starts_with($state,'/')||str_contains($state,'\\')||preg_match('/[\x00-\x1f]/',$state))throw new Problem('Для сессий требуется абсолютный приватный путь состояния.',503);
$parts=explode('/',substr($state,1));
foreach($parts as $part)if($part===''||$part==='.'||$part==='..')throw new Problem('Некорректный путь приватного состояния сессий.',503);
$publicRoots=[dirname(__DIR__).'/public',$_SERVER['DOCUMENT_ROOT']??''];
foreach(explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'') as $root)$publicRoots[]=trim($root);
foreach($publicRoots as $root){$root=rtrim($root,'/');if($root!==''&&($state===$root||str_starts_with($state,$root.'/')))throw new Problem('Сессии нельзя хранить в публичном каталоге.',503);}
$current='';
foreach([...$parts,$bucket] as $part){
$current.='/'.$part;clearstatcache(true,$current);
if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503);
if(!is_dir($current)){
if(file_exists($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
if(!$create)return null;
if(!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
}
clearstatcache(true,$current);
if(is_link($current)||realpath($current)!==$current)throw new Problem('Небезопасный путь хранилища сессий.',503);
}
if(!@chmod($current,0700)||!is_readable($current)||!is_writable($current))throw new Problem('Приватное хранилище сессий недоступно.',503);
return $current;
}
public function login(string $username,string $password,string $ip,bool $remember=false): array {
$key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){
$attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now];
if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429);
$expected=getenv('ADMIN_USERNAME')?:'';$hash=getenv('ADMIN_PASSWORD_HASH')?:'';
if($expected===''||$hash==='')throw new Problem('Администратор ещё не настроен.',503);
$valid=password_verify($password,$hash)&&hash_equals($expected,$username);
if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);}
unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts);
});
// Каталог проверяется после пароля, но до уничтожения действующего входа.
$directory=self::sessionDirectory($remember?'remember-sessions':'sessions');
self::destroy(false);self::configure($directory,$remember);
if(!@session_start())throw new Problem('Не удалось создать новую сессию.',503);
$now=time();$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>$now];
if($remember)$_SESSION['remember_until']=$now+self::REMEMBER_SECONDS;
return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
}
public function requireUser(): string {
$now=time();$expired=self::$rememberSession?(!is_int($_SESSION['remember_until']??null)||$_SESSION['remember_until']<=$now):(($_SESSION['last']??0)<$now-3600);
if(empty($_SESSION['user'])||$expired){if(self::$rememberSession||!empty($_SESSION['user']))self::destroy(true);else $_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=$now;return $_SESSION['user'];
}
public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);}
public function logout(): void {self::destroy(true);}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Config
{
public static function decode(string $json): \stdClass {
try { $value = json_decode($json, false, 64, JSON_THROW_ON_ERROR); } catch (\Throwable) { throw new Problem('Настройки содержат некорректный JSON.'); }
if (!$value instanceof \stdClass) throw new Problem('Настройки должны быть объектом.');
return $value;
}
public static function encode(\stdClass $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR) . "\n"; }
/** Старый raw читается без миграции, но повторная публикация chb запрещена. */
public static function assertWritable(\stdClass $config): void {
if(($config->utm??null) instanceof \stdClass&&property_exists($config->utm,'chb'))throw new Problem('Устаревший ключ utm.chb нельзя записывать. Явно удалите его перед сохранением; восстановление такого архива запрещено.');
}
public static function patch(\stdClass $config, array $changes): \stdClass {
if (!$changes || count($changes)>500) throw new Problem('Выберите изменённые поля (не более 500).');
$copy = self::decode(self::encode($config));
foreach ($changes as $change) {
$change=self::adaptChange($copy,$change);
$path = $change['path'] ?? [];
if (!is_array($path) || !$path || count($path)>16) throw new Problem('Некорректный путь поля.');
$ref = &$copy;
foreach ($path as $i=>$part) {
if (!is_string($part) || $part==='' || strlen($part)>100 || in_array($part,['__proto__','constructor','prototype'],true) || preg_match('/[\x00-\x1f]/',$part)) throw new Problem('Недопустимый ключ поля.');
$last = $i===count($path)-1;
if ($ref instanceof \stdClass) {
if ($last) { if (!empty($change['delete'])) unset($ref->$part); else $ref->$part=$change['value'] ?? null; }
else { if (!property_exists($ref,$part)) $ref->$part=new \stdClass(); $ref=&$ref->$part; }
} elseif (is_array($ref) && ctype_digit($part)) {
$index=(int)$part;
if ($last) { if (!empty($change['delete'])) { array_splice($ref,$index,1); } else $ref[$index]=$change['value'] ?? null; }
elseif (array_key_exists($index,$ref)) $ref=&$ref[$index]; else throw new Problem('Элемент массива не найден.');
} else throw new Problem('Путь проходит через скалярное поле.');
}
unset($ref);
}
self::assertWritable($copy);return $copy;
}
private static function adaptChange(\stdClass $config,array $change): array {
$path=$change['path']??[];if(($path[0]??null)!=='utm'||!isset($path[1]))return $change;
$aliases=['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
$key=$path[1];$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key]??$key;$settings=$config->utm??new \stdClass();$existing=[];
foreach($aliases[$canonical]??[$canonical] as $alias)if(isset($settings->$alias))$existing[]=$alias;
if(count($existing)>1)throw new Problem("Конфликт UTM-ключей {$canonical}. Перечитайте и явно устраните неоднозначность.");
$actual=$existing[0]??$canonical;$path[1]=$actual;$group=$settings->$actual??null;$map=$maps[$canonical]??[];$semantic=false;
if($group instanceof \stdClass){$semantic=in_array($group->default??null,array_values($map),true);foreach(get_object_vars($group->weights??new \stdClass()) as $variant=>$unused)if(in_array((string)$variant,array_values($map),true))$semantic=true;}
$convert=static function($value)use($map,$semantic){$original=$value;$digit=array_search((string)$value,$map,true);$digit=$digit===false?(string)$value:(string)$digit;return $semantic?($map[$digit]??$original):(is_int($original)?$original:$digit);};
if(($path[2]??null)==='weights'&&isset($path[3]))$path[3]=(string)$convert($path[3]);
if(array_key_exists('value',$change)){
if(($path[2]??null)==='default')$change['value']=$convert($change['value']);
if(count($path)===3&&$path[2]==='weights'&&$change['value'] instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($change['value']) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$change['value']=$weights;}
if(count($path)===2&&$change['value'] instanceof \stdClass){$value=self::decode(self::encode($change['value']));if(isset($value->default))$value->default=$convert($value->default);if(($value->weights??null)instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($value->weights) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$value->weights=$weights;}$change['value']=$value;}
}
$change['path']=$path;return $change;
}
public static function validate(\stdClass $config): array {
self::assertWritable($config);
PercentWeights::validate($config);
if (isset($config->host)) {
$host = $config->host;
if (!is_string($host) || !preg_match('~^(?:https://)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$~iD',$host) || str_contains($host,'..')) throw new Problem('Адрес сайта должен быть HTTPS-хостом без пути.');
}
if (isset($config->requisites) && (!is_array($config->requisites) || array_filter($config->requisites,static fn($line)=>!is_string($line)))) throw new Problem('Реквизиты должны содержать строки.');
$utm=$config->utm ?? new \stdClass();
if (!$utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
foreach (['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'] as $old=>$new) if (isset($utm->$old,$utm->$new)) throw new Problem("Конфликт старого {$old} и нового {$new} UTM-ключа.");
$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
$warnings=[];
foreach (get_object_vars($utm) as $key=>$group) {
$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key] ?? $key;
if (!in_array($canonical,['indpic','theme','degr','reg','banch','tp','cons','focus','part'],true)) continue;
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights ?? null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$key}.");
$number=static function($value) use($maps,$canonical): string {
if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.');
$value=(string)$value;
if ($canonical==='part' && !in_array($value,['1','2'],true)) throw new Problem('Вариант part должен быть 1 или 2.');
if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; }
if (!preg_match('/^[1-9]$/D',$value)) throw new Problem('Вариант UTM должен быть 1–9.');
return $value;
};
$default=$number($group->default);$seen=[];
foreach (get_object_vars($group->weights) as $variant=>$weight) {
$digit=$number($variant);
if (isset($seen[$digit])) throw new Problem('Конфликт semantic и numeric вариантов UTM.'); $seen[$digit]=true;
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float)$weight) || $weight<0) throw new Problem('Вес должен быть конечным неотрицательным числом.');
}
if (in_array($canonical,['indpic','degr','tp'],true)&&!isset($seen[$default])) throw new Problem("Добавьте вариант {$default} в таблицу весов {$key} (допустим вес 0).");
}
return $warnings;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace PrelandAdmin;
final class Conflict extends Problem { public function __construct() { parent::__construct('Файл изменился. Перечитайте настройки и повторите изменение.',409); } }
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Применяет независимый именованный UTM-снимок, сохраняя остальные поля получателя. */
final class Copy
{
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
private const ALIASES = ['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];
private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
public function __construct(private Store $store) {}
public function create(array $data): array
{
if (count($data)!==2 || array_diff(array_keys($data),['preset','targets']) || !is_string($data['preset']??null)) throw new Problem('Передайте только ID набора и получателей копирования.');
$targets=$data['targets']??null;
if (!is_array($targets) || !array_is_list($targets) || !$targets || count($targets)>100) throw new Problem('Выберите от 1 до 100 получателей.');
$selected=[];$seen=[];
foreach ($targets as $entry) {
$target=$this->selection($entry);
if (isset($seen[$target['id']])) throw new Problem('Получатели не должны повторяться.');
$seen[$target['id']]=true;$this->available($target['id']);$selected[]=$target;
}
return $this->store->locked('utm-presets',function()use($data,$selected){
$preset=(new UtmPresets($this->store))->get($data['preset']);
$changes=self::project((object)['utm'=>$preset['utm']]);
if (!$changes) throw new Problem('В наборе нет UTM для копирования.');
// Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы.
return (new Jobs($this->store))->create('copy',['preset'=>['id'=>$preset['id'],'name'=>$preset['name']],'targets'=>$selected,'changes'=>$changes]);
});
}
private function selection(mixed $value): array
{
if (!$value instanceof \stdClass) throw new Problem('Получатель должен быть объектом с ID и версией.');
$value=get_object_vars($value);
if (count($value)!==2 || array_diff(array_keys($value),['id','version']) || !is_string($value['id']??null) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$value['id']) || !is_string($value['version']??null) || !preg_match('/^[a-f0-9]{64}$/D',$value['version'])) throw new Problem('Укажите корректные ID и версию сохранённых настроек.');
return $value;
}
private function available(string $id): array
{
$target=$this->store->target($id);
if (empty($target['enabled'])) throw new Problem('Подключение отключено.');
if (empty($target['verified'])) throw new Problem('Сначала проверьте подключение.');
return $target;
}
/** Проверяет неоднозначность всех alias, включая пару chob/bchoice и значения null. */
public static function assertAliases(\stdClass $config): void
{
if (!property_exists($config,'utm')) return;
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
foreach (self::ALIASES as $canonical=>$aliases) {
$found=0;foreach ($aliases as $alias) if (property_exists($config->utm,$alias)) $found++;
if ($found>1) throw new Problem("Конфликт UTM-ключей {$canonical}. Явно устраните неоднозначность.");
}
}
public static function project(\stdClass $config): array
{
self::assertAliases($config);
$projection=new \stdClass();$changes=[];
$projection->utm=new \stdClass();
foreach (self::GROUPS as $canonical) {
$group=null;$present=false;
foreach (self::ALIASES[$canonical]??[$canonical] as $alias) {
if (isset($config->utm) && property_exists($config->utm,$alias)) {$group=$config->utm->$alias;$present=true;break;}
}
// Отсутствующая группа источника никогда не удаляет группу получателя.
if (!$present) continue;
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights??null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$canonical} источника.");
$number=static function(mixed $value) use($canonical): mixed {
if (!is_string($value) && !is_int($value)) throw new Problem('Вариант UTM источника должен быть номером.');
$digit=array_search((string)$value,self::MAPS[$canonical]??[],true);
return $digit===false?$value:(string)$digit;
};
$weights=new \stdClass();
foreach (get_object_vars($group->weights) as $variant=>$weight) {
$variant=(string)$number($variant);
if (property_exists($weights,$variant)) throw new Problem('Конфликт semantic и numeric вариантов UTM источника.');
$weights->$variant=$weight;
}
$default=$number($group->default);
$projection->utm->$canonical=(object)['default'=>$default,'weights'=>$weights];
// Меняем листья, чтобы сохранить неизвестные свойства группы получателя.
$changes[]=['path'=>['utm',$canonical,'default'],'value'=>$default];
$changes[]=['path'=>['utm',$canonical,'weights'],'value'=>$weights];
}
// Редактор проверяет формат конфига, а не реализацию вариантов на сайтах.
Config::validate($projection);
return $changes;
}
}
+384
View File
@@ -0,0 +1,384 @@
<?php
namespace PrelandAdmin;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use ZipArchive;
/**
* Файловый контракт Set демонстрационных изображений Index.
* Перенесено из API 591a67cc8302 components/indpic/IndpicSetStorage.php (preland#22).
*
* Компонент не знает о БД: он только проверяет и атомарно заменяет содержимое
* каталогов `images/indpic/<номер>`. Это позволяет одинаково применять его к
* Set, добавленному через SFTP или ZIP CPanel.
*/
final class IndpicSetStorage
{
/** @var array<string, array{width: int, height: int}> */
private const DIMENSIONS = [
'01_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'01_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'01_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'01_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'01_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'01_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'01_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'01_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'01_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'01_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'01_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'01_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'01_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'01_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'01_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'02_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'02_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'02_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'02_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'02_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'02_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'02_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'02_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'02_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'02_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'02_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'02_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'02_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'02_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'02_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'03_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'03_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'03_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'03_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'03_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'03_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'03_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'03_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'03_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'03_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'03_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'03_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'03_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'03_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'03_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'04_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'04_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'04_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'04_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'04_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'04_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'04_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'04_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'04_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'04_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'04_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'04_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'04_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'04_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'04_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'05_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'05_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'05_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'05_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'05_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'05_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'05_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'05_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'05_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'05_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'05_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'05_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'05_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'05_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'05_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
];
private readonly ?\Closure $imageInspector;
public function __construct(private readonly string $root, ?callable $imageInspector = null)
{
$this->imageInspector = $imageInspector === null ? null : \Closure::fromCallable($imageInspector);
}
/** @return array<string, array{width: int, height: int}> */
public static function expectedDimensions(): array
{
return self::DIMENSIONS;
}
public function isComplete(int $set): bool
{
try {
$this->validateDirectory($set);
return true;
} catch (RuntimeException) {
return false;
}
}
/** @throws RuntimeException если каталог не соответствует контракту Set. */
public function validateDirectory(int $set): void
{
$directory = $this->directory($set);
if (!is_dir($directory)) {
throw new RuntimeException("Папка Set {$set} не найдена.");
}
$actual = [];
$errors = [];
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $item) {
if ($item->isLink() || !$item->isFile()) {
$errors[] = "Лишний элемент в Set {$set}: {$item->getFilename()}.";
continue;
}
$actual[$item->getFilename()] = $item->getPathname();
}
$expected = self::DIMENSIONS;
foreach (array_keys($expected) as $filename) {
if (!isset($actual[$filename])) {
$errors[] = "Отсутствует обязательный файл {$filename}.";
}
}
foreach (array_keys($actual) as $filename) {
if (!isset($expected[$filename])) {
$errors[] = "Лишний файл {$filename}.";
}
}
foreach ($expected as $filename => $dimensions) {
if (!isset($actual[$filename])) {
continue;
}
try {
$actualDimensions = $this->dimensions($actual[$filename]);
if ($actualDimensions !== $dimensions) {
$errors[] = sprintf(
'Неверный размер %s: %d×%d, ожидалось %d×%d.',
$filename,
$actualDimensions['width'],
$actualDimensions['height'],
$dimensions['width'],
$dimensions['height']
);
}
} catch (RuntimeException $exception) {
$errors[] = $exception->getMessage();
}
}
if ($errors !== []) {
throw new RuntimeException(implode("\n", $errors));
}
}
/**
* Полностью и атомарно заменяет Set распакованным ZIP после проверки всех файлов.
*
* @throws RuntimeException
*/
public function replaceFromZip(int $set, string $zipPath): void
{
$this->assertSetNumber($set);
if (!is_file($zipPath)) {
throw new RuntimeException('ZIP-файл не получен.');
}
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException('На сервере не доступна поддержка ZIP.');
}
$stagingParent = $this->root . DIRECTORY_SEPARATOR . '.staging';
$staging = $stagingParent . DIRECTORY_SEPARATOR . $set . '-' . bin2hex(random_bytes(8));
$stagedSet = $staging . DIRECTORY_SEPARATOR . $set;
if (!is_dir($stagingParent) && !mkdir($stagingParent, 0775, true) && !is_dir($stagingParent)) {
throw new RuntimeException('Не удалось создать staging-каталог Set.');
}
if (!mkdir($stagedSet, 0775, true)) {
throw new RuntimeException('Не удалось подготовить staging-каталог Set.');
}
try {
$this->extractZip($zipPath, $set, $stagedSet);
$this->validateDirectoryPath($stagedSet, $set);
$this->activateStagedSet($set, $stagedSet);
} catch (\Throwable $exception) {
if (is_dir($staging)) {
$this->removeDirectory($staging);
}
throw $exception instanceof RuntimeException
? $exception
: new RuntimeException('Не удалось заменить Set ZIP-архивом.', 0, $exception);
}
if (is_dir($staging)) {
$this->removeDirectory($staging);
}
}
private function extractZip(string $zipPath, int $set, string $target): void
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new RuntimeException('Не удалось открыть ZIP-архив.');
}
try {
$prefix = $set . '/';
$files = [];
$directories = [];
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if ($name === '' || str_contains($name, '\\') || str_starts_with($name, '/') || str_contains($name, '../')) {
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
}
if ($name === $prefix) {
$directories[] = $name;
continue;
}
if (!str_starts_with($name, $prefix) || str_contains(substr($name, strlen($prefix)), '/')) {
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
}
$filename = substr($name, strlen($prefix));
if (!isset(self::DIMENSIONS[$filename]) || isset($files[$filename])) {
throw new RuntimeException('ZIP содержит лишний или повторяющийся файл.');
}
$files[$filename] = $index;
}
// Некоторые ZIP-клиенты (в частности проводник Windows) не добавляют
// отдельную запись корневого каталога: достаточно 90 файлов с единым
// префиксом "<номер Set>/". Вложенные каталоги всё равно отклоняются выше.
if (count($directories) > 1 || count($files) !== count(self::DIMENSIONS)) {
throw new RuntimeException("ZIP должен содержать папку {$set} и ровно 90 файлов в ней.");
}
foreach (array_keys(self::DIMENSIONS) as $filename) {
if (!isset($files[$filename])) {
throw new RuntimeException("В ZIP отсутствует обязательный файл {$filename}.");
}
$stream = $zip->getStream($prefix . $filename);
if ($stream === false) {
throw new RuntimeException("Не удалось прочитать файл {$filename} из ZIP.");
}
$destination = $target . DIRECTORY_SEPARATOR . $filename;
$output = fopen($destination, 'wb');
if ($output === false) {
fclose($stream);
throw new RuntimeException("Не удалось подготовить файл {$filename}.");
}
$copied = stream_copy_to_stream($stream, $output, 8388609);
fclose($output);
fclose($stream);
if ($copied === false || $copied > 8388608) {
throw new RuntimeException("Не удалось распаковать файл {$filename}.");
}
chmod($destination, 0644);
}
} finally {
$zip->close();
}
}
private function activateStagedSet(int $set, string $stagedSet): void
{
$destination = $this->directory($set);
$backup = $this->root . DIRECTORY_SEPARATOR . '.backup-' . $set . '-' . bin2hex(random_bytes(8));
$hadDestination = is_dir($destination);
if ($hadDestination && !rename($destination, $backup)) {
throw new RuntimeException('Не удалось подготовить замену текущего Set.');
}
if (!rename($stagedSet, $destination)) {
if ($hadDestination) {
rename($backup, $destination);
}
throw new RuntimeException('Не удалось активировать новый Set.');
}
if ($hadDestination) {
$this->removeDirectory($backup);
}
}
/** @return array{width: int, height: int} */
private function dimensions(string $path): array
{
if ($this->imageInspector !== null) {
$dimensions = ($this->imageInspector)($path);
if (!is_array($dimensions) || !isset($dimensions['width'], $dimensions['height'])) {
throw new RuntimeException('Проверка изображения вернула некорректный размер.');
}
return ['width' => (int) $dimensions['width'], 'height' => (int) $dimensions['height']];
}
if (!class_exists(\Imagick::class)) {
throw new RuntimeException('На сервере не доступна проверка изображений Imagick.');
}
try {
$image = new \Imagick();
$image->readImage($path);
$format = strtoupper($image->getImageFormat());
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$allowed = ['jpg'=>['JPEG','JPG'], 'webp'=>['WEBP'], 'avif'=>['AVIF','HEIC']];
if (!in_array($format, $allowed[$extension] ?? [], true) || $image->getNumberImages() !== 1) {
throw new RuntimeException('Формат изображения не совпадает с расширением либо содержит несколько кадров.');
}
$dimensions = ['width' => $image->getImageWidth(), 'height' => $image->getImageHeight()];
$image->clear();
$image->destroy();
return $dimensions;
} catch (\Throwable $exception) {
throw new RuntimeException('Файл ' . basename($path) . ' не является корректным изображением.', 0, $exception);
}
}
private function validateDirectoryPath(string $directory, int $set): void
{
$storage = new self(dirname($directory), $this->imageInspector);
$storage->validateDirectory($set);
}
private function directory(int $set): string
{
$this->assertSetNumber($set);
return $this->root . DIRECTORY_SEPARATOR . $set;
}
private function assertSetNumber(int $set): void
{
if ($set < 1 || $set > 9) {
throw new RuntimeException('Номер Set должен быть от 1 до 9.');
}
}
private function removeDirectory(string $directory): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($directory);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Jobs
{
public function __construct(private Store $store) {}
public function create(string $type,array $payload): array {
if(!in_array($type,['apply','copy','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.');
$targets=$payload['targets']??(isset($payload['target'])?[$payload['target']]:[]);if(!$targets||count($targets)>100)throw new Problem('Выберите от 1 до 100 подключений.');
$results=[];$seen=[];foreach($targets as $t){$id=is_array($t)?($t['id']??''):$t;if(!is_string($id)||!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id))throw new Problem('Некорректная цель задания.');if(isset($seen[$id]))continue;$seen[$id]=true;$results[]=['target'=>$id,'status'=>'queued','message'=>'Ожидает выполнения.'];}
if(isset($payload['changes']))foreach($payload['changes'] as &$change){if(array_key_exists('value',$change)){$change['value_json']=json_encode($change['value'],JSON_THROW_ON_ERROR);unset($change['value']);}}unset($change);
$id=bin2hex(random_bytes(16));$job=['id'=>$id,'type'=>$type,'status'=>'queued','created_at'=>gmdate(DATE_ATOM),'results'=>$results,'payload'=>$payload];$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
}
private function public(array $job): array {$job['changes']=array_map(static fn($c)=>['path'=>$c['path']],$job['payload']['changes']??[]);unset($job['payload']);foreach($job['results'] as &$result)unset($result['desired_hash'],$result['source_manifest'],$result['desired_manifest']);return $job;}
public function get(string $id): array {$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);return $this->public($job);}
public function all(): array {$jobs=[];foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path)$jobs[]=$this->public($this->store->read('jobs/'.basename($path)));usort($jobs,static fn($a,$b)=>strcmp($b['created_at'],$a['created_at']));return array_slice($jobs,0,100);}
public function retry(string $id,array $targets): array {
return $this->store->locked('job-'.$id,function() use($id,$targets){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);if($job['status']!=='completed')throw new Problem('Задание ещё выполняется.',409);
$count=0;foreach($job['results'] as &$result)if(in_array($result['target'],$targets,true)&&in_array($result['status'],['error','unknown'],true)){$result['status']='queued';$result['message']='Повтор ожидает выполнения.';$count++;}unset($result);
if(!$count)throw new Problem('Выберите цели с ошибкой либо неизвестным результатом. Конфликт требует перечитать конфиг.');$job['status']='queued';$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
});
}
public function run(string $id): void {
$this->store->locked('job-'.$id,function() use($id){
$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;
$save=function() use(&$job,$id){$this->store->write('jobs/'.$id.'.json',$job);};$job['status']='running';$save();
foreach($job['results'] as $index=>$result){
if(!in_array($result['status'],['queued','running'],true))continue;
$recover=$result['status']==='running';$job['results'][$index]['status']='running';$save();
try{
$target=$this->store->target($result['target']);if(empty($target['enabled']))throw new Problem('Подключение отключено.');
if(empty($target['verified']))throw new Problem('Сначала проверьте подключение.');
$transport=new Transport($this->store,$target);$p=$job['payload'];$type=$job['type'];
if(in_array($type,['apply','copy','restore'],true)){
$current=$transport->readConfig();$expected=$p['version']??'';
if(in_array($type,['apply','copy'],true))foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
// После обрыва/рестарта сперва сверяем ожидаемый хеш уже подготовленного контента.
$desired=$job['results'][$index]['desired_hash']??null;
if($desired && hash_equals($desired,$current['version'])) {$job['results'][$index]=['target'=>$target['id'],'status'=>'success','message'=>'Сохранённое состояние подтверждено после повторного чтения.','version'=>$current['version']];$save();continue;}
if(!is_string($expected)||!hash_equals($current['version'],$expected))throw new Conflict();
if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change);
if($type==='copy')Copy::assertAliases($current['config']);
$next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]);
$warnings=Config::validate($next);
$content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save();
$written=$transport->writeConfig($expected,$content);$out=['message'=>'Настройки сохранены и проверены.','version'=>$written['version'],'warnings'=>$warnings];
}else{
$sets=new Sets($this->store);$number=Sets::number($p['number']??0);
if($type==='upload') {
$actual=$sets->manifest($target,$number);$saved=$job['results'][$index];
if(array_key_exists('desired_manifest',$saved)){
if($actual===$saved['desired_manifest']){
$out=$sets->check($target,$number,true,$p['name']??'');
$out['message']='Опубликованный набор подтверждён после повторного чтения.';
}elseif($actual!==($saved['source_manifest']??null))throw new Conflict();
else $out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$saved['source_manifest']??null,true);
}else{
$job['results'][$index]['source_manifest']=$actual;$job['results'][$index]['desired_manifest']=Sets::preparedManifest($p['prepared'],$number);$save();
$out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$actual,true);
}
}
else $out=$sets->check($target,$number,$type==='register',$p['name']??'');
}
$job['results'][$index]=['target'=>$target['id'],'status'=>'success']+$out;
}catch(Conflict $e){$job['results'][$index]=['target'=>$result['target'],'status'=>'conflict','message'=>$e->getMessage()];}
catch(\Throwable $e){$old=$job['results'][$index];$status=$e instanceof Problem&&$e->status===504?'unknown':'error';$job['results'][$index]=['target'=>$result['target'],'status'=>$status,'message'=>$e instanceof Problem?$e->getMessage():'Операция не выполнена. Проверьте доступность и повторите.'];foreach(['desired_hash','desired_manifest','source_manifest'] as $field)if(array_key_exists($field,$old))$job['results'][$index][$field]=$old[$field];}
$this->store->audit(['job'=>$id,'target'=>$result['target'],'type'=>$job['type'],'paths'=>array_column($job['payload']['changes']??[],'path'),'status'=>$job['results'][$index]['status']]);$save();
}
$job['status']='completed';$job['finished_at']=gmdate(DATE_ATOM);$save();
});
}
public function tick(): void {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))$this->run($job['id']);}}
public function pending(): ?string {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))return $job['id'];}return null;}
public function interrupted(string $id): void {
$this->store->locked('job-'.$id,function()use($id){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;$queued=false;
foreach($job['results'] as &$result){
if($result['status']==='running'){
$message='Исполнитель прервался. Повтор сначала перечитает фактическое состояние цели.';
if($job['type']==='upload'){
// Read выполняет journal recovery под транспортной блокировкой без Imagick.
// Supervisor обязан попытаться вернуть публичный путь до ожидания retry пользователя.
try{(new Transport($this->store,$this->store->target($result['target'])))->readConfig();$result['recovery_checked']=true;}
catch(\Throwable){$result['recovery_checked']=false;$message='Исполнитель прервался. Автоматическое восстановление не подтверждено: цель недоступна. Повтор сначала перечитает фактическое состояние.';}
}
$result['status']='unknown';$result['message']=$message;
}
if($result['status']==='queued')$queued=true;
}unset($result);
$job['status']=$queued?'queued':'completed';if(!$queued)$job['finished_at']=gmdate(DATE_ATOM);$this->store->write('jobs/'.$id.'.json',$job);
$this->store->audit(['job'=>$id,'status'=>'interrupted']);
});
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Независимый выбор меток: читает реестр целей, пишет только приватное состояние ссылки. */
final class LinkBuilder
{
private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus', 'part'];
public function __construct(private readonly Store $store) {}
public function get(string $targetId): array
{
$this->assertId($targetId);
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId): array {
$origin = $this->origin($this->store->target($targetId));
$name = $this->filename($targetId);
$saved = $this->store->read($name, null);
if ($saved === null) return ['selection'=>null, 'url'=>null, 'saved_at'=>null];
if (!is_array($saved) || !is_array($saved['selection'] ?? null)
|| !is_string($saved['url'] ?? null) || !is_string($saved['saved_at'] ?? null)) {
throw new Problem('Повреждено сохранённое состояние конструктора ссылки.', 503);
}
// Старые восемь полей дополняем part=1, сохраняя исходный шум остальных меток.
$legacy = count($saved['selection']) === 8 && !array_key_exists('part', $saved['selection']);
$selection = $this->selection($legacy ? $saved['selection'] + ['part'=>'1'] : $saved['selection']);
$query = parse_url($saved['url'], PHP_URL_QUERY);
if (!is_string($query)) throw new Problem('Повреждена сохранённая ссылка.', 503);
parse_str($query, $parameters);
if ($legacy && array_keys($parameters) === array_slice(self::KEYS, 0, 8)) {
$parameters['part'] = bin2hex(random_bytes(2)) . '1' . bin2hex(random_bytes(6));
}
if (array_keys($parameters) !== self::KEYS) throw new Problem('Повреждена сохранённая ссылка.', 503);
foreach ($selection as $key=>$value) {
$digit = $value === 'rnd' ? '0' : $value;
if (!is_string($parameters[$key]) || !preg_match('/^[a-f0-9]{4}' . $digit . '[a-f0-9]{12}$/D', $parameters[$key])) {
throw new Problem('Повреждена сохранённая ссылка.', 503);
}
}
// Переименование цели меняет только origin, сохранённый шум не генерируется заново.
$url = $origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
if ($saved['url'] !== $url || $legacy) {
$saved['selection'] = $selection;
$saved['url'] = $url;
$this->store->write($name, $saved);
}
return ['selection'=>$selection, 'url'=>$url, 'saved_at'=>$saved['saved_at']];
});
}
public function save(string $targetId, array $selection): array
{
$this->assertId($targetId);
$selection = $this->selection($selection);
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId, $selection): array {
$origin = $this->origin($this->store->target($targetId));
$parameters = [];
foreach ($selection as $key=>$value) {
$parameters[$key] = bin2hex(random_bytes(2)) . ($value === 'rnd' ? '0' : $value) . bin2hex(random_bytes(6));
}
$saved = [
'selection'=>$selection,
'url'=>$origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986),
'saved_at'=>gmdate(DATE_ATOM),
];
$this->store->write($this->filename($targetId), $saved);
return $saved;
});
}
private function selection(array $input): array
{
if (count($input) !== count(self::KEYS) || array_diff(array_keys($input), self::KEYS)) {
throw new Problem('Передайте ровно девять параметров конструктора ссылки.');
}
$selection = [];
foreach (self::KEYS as $key) {
$value = $input[$key] ?? null;
if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))
|| ($key === 'part' && !in_array($value, ['1', '2', 'rnd'], true))) {
throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки.");
}
$selection[$key] = $value;
}
return $selection;
}
private function origin(array $target): string
{
$domain = $target['domain'] ?? null;
if (!is_string($domain) || strlen($domain)>253 || !preg_match('/^[a-z0-9.-]+$/iD', $domain)
|| str_starts_with($domain, '.') || str_ends_with($domain, '.')
|| filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
throw new Problem('В подключении указан небезопасный домен для HTTPS-ссылки.');
}
return 'https://' . strtolower($domain);
}
private function assertId(string $targetId): void
{
if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $targetId)) throw new Problem('Некорректный ID подключения.');
}
private function filename(string $targetId): string
{
return 'link-builder-' . $targetId . '.json';
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Notes
{
private const MARKERS=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
public function __construct(private readonly Store $store) {}
public function all(): array {
return $this->store->locked('utm-notes',fn()=>$this->load());
}
public function save(array $input): array {
if(count($input)!==3||array_diff(array_keys($input),['marker','text','version'])||!is_string($input['marker']??null)||!in_array($input['marker'],self::MARKERS,true)||!is_string($input['text']??null)||!is_string($input['version']??null))throw new Problem('Передайте маркер UTM, текст и версию заметки.');
if(!self::validText($input['text']))throw new Problem('Заметка должна содержать корректный UTF-8, не более 10000 символов, без управляющих символов кроме перевода строки и табуляции.');
return $this->store->locked('utm-notes',function() use($input){
$notes=$this->load();$marker=$input['marker'];
if(!hash_equals($notes[$marker]['version'],$input['version']))throw new Problem('Заметка уже изменена. Загрузите актуальную версию перед сохранением.',409);
$note=['text'=>$input['text'],'version'=>bin2hex(random_bytes(32)),'saved_at'=>gmdate(DATE_ATOM)];
$notes[$marker]=$note;$this->store->write('utm-notes.json',$notes);return $note;
});
}
private function load(): array {
$path=$this->store->path('utm-notes.json');clearstatcache(true,$path);
if(is_link($path)||file_exists($path)&&(!is_file($path)||filesize($path)>1048576))throw new Problem('Повреждено приватное состояние заметок.',503);
if(!file_exists($path)){
$notes=[];foreach(self::MARKERS as $marker)$notes[$marker]=['text'=>'','version'=>hash('sha256','utm-notes:'.$marker.':empty'),'saved_at'=>null];return $notes;
}
$notes=$this->store->read('utm-notes.json',null);
// Старое хранилище без part читаем с пустой заметкой; прежние версии сохраняем.
if(is_array($notes)&&count($notes)===8&&!array_key_exists('part',$notes))$notes['part']=['text'=>'','version'=>hash('sha256','utm-notes:part:empty'),'saved_at'=>null];
if(!is_array($notes)||count($notes)!==count(self::MARKERS)||array_diff(self::MARKERS,array_keys($notes)))throw new Problem('Повреждено приватное состояние заметок.',503);
$ordered=[];
foreach(self::MARKERS as $marker){
$note=$notes[$marker];
if(!is_array($note)||count($note)!==3||array_diff(array_keys($note),['text','version','saved_at'])||!is_string($note['text']??null)||!self::validText($note['text'])||!is_string($note['version']??null)||!preg_match('/^[a-f0-9]{64}$/D',$note['version'])||!array_key_exists('saved_at',$note)||($note['saved_at']!==null&&(!is_string($note['saved_at'])||!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$note['saved_at']))))throw new Problem('Повреждено приватное состояние заметок.',503);
if($note['saved_at']===null&&($note['text']!==''||$note['version']!==hash('sha256','utm-notes:'.$marker.':empty')))throw new Problem('Повреждено приватное состояние заметок.',503);
$ordered[$marker]=$note;
}
return $ordered;
}
private static function validText(string $text): bool {
return strlen($text)<=40000&&preg_match('//u',$text)===1&&preg_match('/[\x{0000}-\x{0008}\x{000B}\x{000C}\x{000E}-\x{001F}\x{007F}-\x{009F}]/u',$text)===0&&preg_match_all('/./us',$text)<=10000;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Явная миграция #28: заменяет исключительно числовые токены известных весов. */
final class PercentMigration
{
private const KEYS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'];
public static function patch(string $raw): string
{
$config=Config::decode($raw);
$offset=0;
$tree=self::node($raw,$offset);
$utm=self::property($tree,'utm');
if($utm===null||$utm['type']!=='object')throw new Problem('Миграция: отсутствует объект utm.');
foreach([['reg','registration'],['banch','chob','bchoice']] as $aliases){
$present=array_filter($aliases,static fn($key)=>self::property($utm,$key)!==null);
if(count($present)>1)throw new Problem('Миграция: конфликт UTM-псевдонимов.');
}
// Запрещаем потерю положительных значений при json_decode (underflow).
foreach($utm['properties'] as $key=>$setting){
if(!in_array((string)$key,self::KEYS,true))continue;
$weights=self::property($setting,'weights');
if($weights===null||$weights['type']!=='object')throw new Problem('Миграция: отсутствует объект весов '.$key.'.');
foreach($weights['properties'] as $variant=>$node){
$token=substr($raw,$node['start'],$node['end']-$node['start']);
if($node['type']!=='number')throw new Problem('Миграция: вес должен быть числом.');
$number=json_decode($token,false,64,JSON_THROW_ON_ERROR);
if(!is_finite((float)$number)||((float)$number===0.0&&preg_match('/[1-9]/',explode('e',strtolower($token))[0])))throw new Problem('Миграция: вес вне безопасного числового диапазона.');
}
}
$normalized=PercentWeights::normalizeRelative($config);
PercentWeights::validate($normalized);
$replacements=[];
foreach($utm['properties'] as $key=>$setting){
if(!in_array((string)$key,self::KEYS,true))continue;
$weights=self::property($setting,'weights');
foreach($weights['properties'] as $variant=>$node){
$value=$normalized->utm->$key->weights->$variant;
$old=$config->utm->$key->weights->$variant;
if((float)$old===(float)$value)continue;
$text=json_encode($value,JSON_THROW_ON_ERROR);
$replacements[]=[$node['start'],$node['end'],$text];
}
}
usort($replacements,static fn($a,$b)=>$b[0]<=>$a[0]);
foreach($replacements as [$start,$end,$text])$raw=substr_replace($raw,$text,$start,$end-$start);
PercentWeights::validate(Config::decode($raw));
return $raw;
}
/** Грамматика предварительно проверена Config::decode; диапазоны аналогичны ensure-utm-config.php. */
private static function node(string $raw,int &$offset): array
{
$length=strlen($raw);
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
$start=$offset;$char=$raw[$offset]??'';
if($char==='"'){
$offset++;
while($offset<$length){if($raw[$offset]==='\\'){$offset+=2;continue;}if($raw[$offset++]==='"')break;}
return ['start'=>$start,'end'=>$offset,'type'=>'string','value'=>json_decode(substr($raw,$start,$offset-$start),true,64,JSON_THROW_ON_ERROR)];
}
if($char==='{'||$char==='['){
$object=$char==='{';$close=$object?'}':']';$offset++;$properties=[];
while(true){
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
if(($raw[$offset]??'')===$close){$offset++;break;}
if($object){
$key=self::node($raw,$offset)['value'];
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
$offset++;
if(array_key_exists($key,$properties))throw new Problem('Миграция: повторный JSON-ключ; запись запрещена.');
$properties[$key]=self::node($raw,$offset);
}else{self::node($raw,$offset);}
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
if(($raw[$offset]??'')===','){$offset++;continue;}
$offset++;break;
}
return ['start'=>$start,'end'=>$offset,'type'=>$object?'object':'array','properties'=>$properties];
}
while($offset<$length&&!str_contains(",]} \t\r\n",$raw[$offset]))$offset++;
return ['start'=>$start,'end'=>$offset,'type'=>preg_match('/^-?[0-9]/',substr($raw,$start,$offset-$start))?'number':'scalar'];
}
private static function property(array $node,string $key): ?array {return $node['properties'][$key]??null;}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Контракт хранимых процентов. Миграция вызывается только явно, отдельно от чтения и записи. */
final class PercentWeights
{
public const GROUPS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'];
private static function groups(\stdClass $config): array
{
if (!property_exists($config,'utm')) return [];
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
$groups=[];
foreach (get_object_vars($config->utm) as $key=>$group) {
if (!in_array($key,self::GROUPS,true)) continue;
if (!$group instanceof \stdClass || !property_exists($group,'weights') || !$group->weights instanceof \stdClass) throw new Problem("Веса UTM {$key} должны быть объектом процентов.");
$groups[$key]=$group;
}
return $groups;
}
private static function number(mixed $value,string $key): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value<0) throw new Problem("Вес UTM {$key} должен быть конечным неотрицательным числом процентов.");
return (float)$value;
}
/** Допуск покрывает только погрешность двоичного float при переводе в сотые процента. */
private static function basisPoints(float $value,string $key): int
{
if ($value!==round($value,2)) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой.");
$scaled=$value*100;$rounded=round($scaled);
if (($value>0 && $rounded===0.0) || abs($scaled-$rounded)>PHP_FLOAT_EPSILON*max(1.0,abs($scaled))*8) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой; точные доли должны представляться с шагом 0,01%.");
return (int)$rounded;
}
public static function validate(\stdClass $config): void
{
foreach (self::groups($config) as $key=>$group) {
$sum=0;
foreach (get_object_vars($group->weights) as $weight) {
$value=self::number($weight,$key);
if ($value>100) throw new Problem("Вес UTM {$key} должен быть от 0 до 100%.");
$sum+=self::basisPoints($value,$key);
if ($sum>10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%.");
}
if ($sum!==0 && $sum!==10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%. Старые относительные веса требуют явной миграции.");
}
}
/** Только для явной миграции: округляем каждую долю, остаток не перераспределяем. */
public static function normalizeRelative(\stdClass $config): \stdClass
{
// Клонирование не через JSON: сохраняем типы чисел и проверяем INF/NAN сами.
$clone=static function(mixed $value) use (&$clone): mixed {
if ($value instanceof \stdClass) {$copy=new \stdClass();foreach (get_object_vars($value) as $key=>$item) $copy->$key=$clone($item);return $copy;}
if (is_array($value)) {foreach ($value as $key=>$item) $value[$key]=$clone($item);}
return $value;
};
$copy=$clone($config);
foreach (self::groups($copy) as $key=>$group) {
$values=[];$max=0.0;
foreach (get_object_vars($group->weights) as $variant=>$weight) {$values[$variant]=self::number($weight,$key);$max=max($max,$values[$variant]);}
if ($max===0.0) continue;
$sum=0.0;foreach ($values as $weight) $sum+=$weight/$max;
foreach ($values as $variant=>$weight) {
$percent=($weight/$max)/$sum*100;
if ($weight>0 && $percent===0.0) throw new Problem("Доля UTM {$key} слишком мала для точного представления с шагом 0,01%.");
$points=(int)round($percent*100);
$group->weights->{(string)$variant}=$points%100===0?intdiv($points,100):$points/100;
}
}
self::validate($copy);
return $copy;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace PrelandAdmin;
class Problem extends \RuntimeException { public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); } }
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Самодостаточный обработчик: один и тот же CAS выполняется локально и под SSH. */
final class Remote
{
private static function cleanStage(string $directory): void {if(!is_dir($directory)||is_link($directory))return;foreach(new \FilesystemIterator($directory,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isFile()||$item->isLink())unlink($item->getPathname());else throw new \RuntimeException('Небезопасный элемент staging.');}rmdir($directory);}
private static function journal(string $path,array $data): void {
$temp=$path.'.tmp-'.bin2hex(random_bytes(8));$fp=fopen($temp,'xb');if(!$fp)throw new \RuntimeException('Не удалось записать журнал переключения.');
try{$json=json_encode($data,JSON_THROW_ON_ERROR);if(fwrite($fp,$json)!==strlen($json)||!fflush($fp)||!fsync($fp))throw new \RuntimeException('Не удалось зафиксировать журнал переключения.');}finally{fclose($fp);}
if(!rename($temp,$path))throw new \RuntimeException('Не удалось опубликовать журнал переключения.');
}
private static function recover(string $root,string $private,bool $local): void {
foreach(glob($private.'/swap-[1-9].json')?:[] as $path){$record=json_decode(file_get_contents($path),true);$number=(int)($record['number']??0);if($number<1||$number>9)throw new \RuntimeException('Повреждён журнал переключения набора.');
$parent=$local?$root.'/.preland-admin-staging':$private;$stage=$record['stage']??'';$backup=$record['backup']??'';
if(dirname($stage)!==$parent||dirname($backup)!==$parent||!preg_match('/^set-[a-f0-9]{16}$/D',basename($stage))||!preg_match('/^backup-'.$number.'-[a-f0-9]{16}$/D',basename($backup)))throw new \RuntimeException('Небезопасные пути журнала переключения.');
$active=$root.'/'.$number;
if(!file_exists($active)&&!is_link($active)){
if(is_dir($backup)&&!is_link($backup)){if(!rename($backup,$active))throw new \RuntimeException('Не удалось восстановить предыдущий набор.');}
elseif(is_dir($stage)&&self::manifest($stage)===($record['desired_manifest']??null)){if(!chmod($stage,0755)||!rename($stage,$active))throw new \RuntimeException('Не удалось завершить переключение набора.');}
else throw new \RuntimeException('Незавершённый набор требует восстановления из резерва.');
}
// При наличии активного каталога третью версию не перезаписываем.
if(is_link($active)||!is_dir($active))throw new \RuntimeException('Небезопасный активный набор при восстановлении.');
self::cleanStage($stage);if($local)self::cleanStage($backup);unlink($path);
}
}
public static function manifest(string $dir): ?string {
if(!is_dir($dir)){if(file_exists($dir)||is_link($dir))throw new \RuntimeException('Путь набора занят небезопасным элементом.');return null;}
if(is_link($dir))throw new \RuntimeException('Символические ссылки набора запрещены.');$hashes=[];$total=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||$item->getSize()>8388608||count($hashes)>=1000)throw new \RuntimeException('Небезопасный элемент набора.');$total+=$item->getSize();if($total>67108864)throw new \RuntimeException('Превышен лимит набора.');$hashes[$item->getFilename()]=hash_file('sha256',$item->getPathname());}
ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));
}
public static function execute(array $p): array {
// Этот обработчик исполняется отдельно по SSH, без автозагрузки Config.
// Отказ до любых приватных каталогов, recovery, резервов и temp-файлов.
if(($p['op']??null)==='write'&&is_string($p['content']??null)){
$candidate=json_decode($p['content']);
if($candidate instanceof \stdClass&&($candidate->utm??null) instanceof \stdClass&&property_exists($candidate->utm,'chb'))return ['ok'=>false,'error'=>'Устаревший ключ utm.chb нельзя записывать. Явно удалите его перед сохранением; восстановление такого архива запрещено.'];
}
$root=$p['root'];
if (!is_string($root)||!str_starts_with($root,'/')||str_contains($root,'..')||str_contains($root,'\\')||realpath($root)!==$root||is_link($root)) return ['ok'=>false,'error'=>'Небезопасный корневой путь.'];
$private=$p['local_private']??dirname($root).'/.preland-admin-'.substr(hash('sha256',$root),0,16);
if (is_link($private)) return ['ok'=>false,'error'=>'Небезопасный приватный каталог.'];
if (!is_dir($private) && !mkdir($private,0700)) return ['ok'=>false,'error'=>'Не удалось подготовить приватный каталог вне webroot.'];
chmod($private,0700);
$lock=fopen($private.'/publish.lock','c');if(!$lock) return ['ok'=>false,'error'=>'Не удалось получить удалённую блокировку.'];
$deadline=microtime(true)+20;while(!flock($lock,LOCK_EX|LOCK_NB)){if(microtime(true)>$deadline){fclose($lock);return ['ok'=>false,'error'=>'Истекло время ожидания блокировки.'];}usleep(50000);}
try {
self::recover($root,$private,isset($p['local_private']));
$config=$root.'/config.json';
if (is_link($config)||!is_file($config)||filesize($config)>2097152) throw new \RuntimeException('Настройки отсутствуют либо путь небезопасен.');
$content=file_get_contents($config);$version=hash('sha256',$content);
$op=$p['op'];
if ($op==='read') return ['ok'=>true,'content'=>$content,'version'=>$version];
if ($op==='test') {
if(!function_exists('fsync'))throw new \RuntimeException('Для безопасной публикации требуется PHP CLI не ниже 8.1 с fsync.');
if(!is_writable($root)||!is_writable($config)) throw new \RuntimeException('Нет прав записи.');
if(!json_decode($content) instanceof \stdClass)throw new \RuntimeException('config.json не является корректным объектом.');
$probe=$root.'/.config-probe-'.bin2hex(random_bytes(8));$renamed=$probe.'-renamed';
try{if(file_put_contents($probe,'preland-admin-probe')===false||!rename($probe,$renamed)||file_get_contents($renamed)!=='preland-admin-probe')throw new \RuntimeException('Атомарная запись недоступна.');}finally{if(is_file($probe))unlink($probe);if(is_file($renamed))unlink($renamed);}
return ['ok'=>true,'version'=>$version];
}
if ($op==='set-list') {$numbers=[];foreach(range(1,9) as $n)if(is_dir($root.'/'.$n)&&!is_link($root.'/'.$n))$numbers[]=$n;return ['ok'=>true,'numbers'=>$numbers];}
if ($op==='write') {
if (!hash_equals($version,(string)$p['version'])) return ['ok'=>false,'conflict'=>true,'error'=>'Версия изменена.'];
$next=$p['content'];$decoded=json_decode($next);if(!$decoded instanceof \stdClass || strlen($next)>2097152) throw new \RuntimeException('Некорректные настройки.');
$temp=$root.'/.config-'.bin2hex(random_bytes(8));$backup=$private.'/config-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(6)).'.json';
if(file_put_contents($backup,$content)===false) throw new \RuntimeException('Не удалось сохранить резерв.');chmod($backup,0600);
if(file_put_contents($temp,$next)===false) throw new \RuntimeException('Не удалось записать временный файл.');chmod($temp,0644);
if(hash_file('sha256',$temp)!==hash('sha256',$next)||!rename($temp,$config)){@unlink($temp);throw new \RuntimeException('Не удалось атомарно заменить настройки.');}
$actual=hash_file('sha256',$config);if($actual!==hash('sha256',$next)) throw new \RuntimeException('Повторная проверка настроек не совпала.');
return ['ok'=>true,'version'=>$actual,'previous'=>$content];
}
$number=(int)($p['number']??0);if($number<1||$number>9) throw new \RuntimeException('Номер набора должен быть 1–9.');
$dir=$root.'/'.$number;if(is_link($dir)) throw new \RuntimeException('Символические ссылки набора запрещены.');
if($op==='set-manifest')return ['ok'=>true,'manifest'=>self::manifest($dir)];
if($op==='set-read') {
if(!is_dir($dir)) throw new \RuntimeException('Папка набора отсутствует.');$files=[];$bytes=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if(!$item->isFile()||$item->isLink()||$item->getSize()>8388608) throw new \RuntimeException('Недопустимый файл набора (лимит 8 МиБ).');$bytes+=$item->getSize();if($bytes>67108864||count($files)>=90) throw new \RuntimeException('Превышен лимит набора (64 МиБ, 90 файлов).');$files[$item->getFilename()]=base64_encode(file_get_contents($item->getPathname()));}
return ['ok'=>true,'files'=>$files];
}
if($op==='set-write') {
if(!array_key_exists('expected_manifest',$p))throw new \RuntimeException('Отсутствует исходная версия набора.');
if(self::manifest($dir)!==$p['expected_manifest'])return ['ok'=>false,'conflict'=>true,'error'=>'Набор изменился. Перечитайте его состояние перед повторной публикацией.'];
$stageParent=isset($p['local_private'])?$root.'/.preland-admin-staging':$private;
if(is_link($stageParent))throw new \RuntimeException('Небезопасный staging-каталог.');
if(!is_dir($stageParent)&&!mkdir($stageParent,0700))throw new \RuntimeException('Не удалось подготовить staging.');
if(isset($p['local_private']))file_put_contents($stageParent.'/.htaccess',"Require all denied\n");
$stage=$stageParent.'/set-'.bin2hex(random_bytes(8));if(!mkdir($stage,0755))throw new \RuntimeException('Не удалось подготовить набор.');$backup=$stageParent.'/backup-'.$number.'-'.bin2hex(random_bytes(8));$moved=false;
try {
if(count($p['files'])!==90) throw new \RuntimeException('Требуется ровно 90 файлов.');$total=0;
foreach($p['files'] as $name=>$file){if(!preg_match('/^[0-9]{2}_(desk|mob)_[123]-(source|result|thumb)\.(jpg|webp|avif)$/D',$name))throw new \RuntimeException('Недопустимое имя файла.');$data=base64_decode($file['data'],true);$total+=strlen($data===false?'':$data);if($data===false||strlen($data)>8388608||$total>67108864||hash('sha256',$data)!==$file['sha256'])throw new \RuntimeException('Не совпала проверка файла либо превышен лимит.');$path=$stage.'/'.$name;if(file_put_contents($path,$data)===false||hash_file('sha256',$path)!==$file['sha256'])throw new \RuntimeException('Ошибка доставки файла.');chmod($path,0644);}
if(!chmod($stage,0755))throw new \RuntimeException('Не удалось открыть чтение подготовленного набора.');
if(is_dir($dir)){
// Резерв локального bind mount копируется в state до смены активного каталога.
if(isset($p['local_private'])){
$saved=$private.'/backup-'.$number.'-'.bin2hex(random_bytes(8));if(!mkdir($saved,0700))throw new \RuntimeException('Не удалось подготовить резерв.');
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||!copy($item->getPathname(),$saved.'/'.$item->getFilename()))throw new \RuntimeException('Не удалось сохранить резерв набора.');}
}
self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($dir,$backup))throw new \RuntimeException('Не удалось сохранить старый набор.');$moved=true;
}
else self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($stage,$dir)) {if($moved)rename($backup,$dir);throw new \RuntimeException('Не удалось активировать набор.');}
foreach($p['files'] as $name=>$file)if(hash_file('sha256',$dir.'/'.$name)!==$file['sha256']) {rename($dir,$stage);if($moved)rename($backup,$dir);throw new \RuntimeException('Ошибка проверки после публикации; выполнен откат.');}
if($moved && isset($p['local_private'])) {
foreach(new \FilesystemIterator($backup,\FilesystemIterator::SKIP_DOTS) as $item)unlink($item->getPathname());rmdir($backup);
}
unlink($private.'/swap-'.$number.'.json');
return ['ok'=>true,'hash'=>hash('sha256',json_encode(array_map(static fn($f)=>$f['sha256'],$p['files'])))];
} finally {if(is_dir($stage)){foreach(new \FilesystemIterator($stage,\FilesystemIterator::SKIP_DOTS) as $item)if($item->isFile())unlink($item->getPathname());rmdir($stage);}}
}
throw new \RuntimeException('Неизвестная операция.');
} catch (\Throwable $e) {return ['ok'=>false,'error'=>$e->getMessage()];} finally {flock($lock,LOCK_UN);fclose($lock);}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Sets
{
public function __construct(private Store $store) {}
public static function number(mixed $number): int {if(!preg_match('/^[1-9]$/D',(string)$number)) throw new Problem('Номер набора должен быть 1–9.');return (int)$number;}
public function list(array $target, ?\stdClass $config=null): array {
$names=$this->store->read('sets.json');$checks=$this->store->read('checks.json');$result=[];$available=(new Transport($this->store,$target))->call(['op'=>'set-list'])['numbers'];
$config ??= (new Transport($this->store,$target))->readConfig()['config'];$group=$config->utm->indpic ?? new \stdClass();
foreach(range(1,9) as $number){$key=$target['id'].':'.$number;$default=(string)($group->default??'')===(string)$number;$weight=(float)($group->weights->{(string)$number}??0);$entry=$names[$key]??null;
$result[]=['number'=>$number,'name'=>$entry['name']??'','exists'=>in_array($number,$available,true),'registered'=>$entry!==null,'default'=>$default,'in_use'=>$default||$weight>0,'checked_at'=>$checks[$key]['at']??null];
}return $result;
}
public function prepareZip(string $file,int $number): string {
self::number($number);if(!is_file($file)||filesize($file)>67108864)throw new Problem('ZIP превышает лимит 64 МиБ.');
$zip=new \ZipArchive();if($zip->open($file)!==true)throw new Problem('Не удалось открыть ZIP.');$total=0;
try{if($zip->numFiles>91)throw new Problem('ZIP содержит более 90 файлов.');for($i=0;$i<$zip->numFiles;$i++){$stat=$zip->statIndex($i);$size=(int)$stat['size'];$total+=$size;if($size>8388608||$total>67108864)throw new Problem('Превышен лимит распаковки (8 МиБ на файл, 64 МиБ на набор).');$opsys=0;$attr=0;if($zip->getExternalAttributesIndex($i,$opsys,$attr)&&(($attr>>16)&0170000)===0120000)throw new Problem('Символические ссылки в ZIP запрещены.');}}finally{$zip->close();}
$dir=$this->store->dir.'/uploads/'.bin2hex(random_bytes(16));if(!mkdir($dir,0700,true))throw new Problem('Не удалось подготовить проверку ZIP.');
(new IndpicSetStorage($dir))->replaceFromZip($number,$file);return $dir;
}
public function check(array $target,int $number,bool $register=false,string $name=''): array {
$number=self::number($number);$transport=new Transport($this->store,$target);$result=$transport->call(['op'=>'set-read','number'=>$number]);
$dir=$this->store->dir.'/checks/'.bin2hex(random_bytes(16));mkdir($dir.'/'.$number,0700,true);
try {
$expected=IndpicSetStorage::expectedDimensions();if(count($result['files'])!==90)throw new Problem('Набор должен содержать ровно 90 файлов.');$hashes=[];
foreach($result['files'] as $filename=>$encoded){if(!isset($expected[$filename]))throw new Problem('В наборе найден лишний файл.');$data=base64_decode($encoded,true);if($data===false)throw new Problem('Ошибка чтения изображения.');file_put_contents($dir.'/'.$number.'/'.$filename,$data);$hashes[$filename]=hash('sha256',$data);}
(new IndpicSetStorage($dir))->validateDirectory($number);ksort($hashes);$hash=hash('sha256',json_encode($hashes));
$this->store->locked('checks',function() use($target,$number,$hash){$checks=$this->store->read('checks.json');$checks[$target['id'].':'.$number]=['at'=>gmdate(DATE_ATOM),'hash'=>$hash];$this->store->write('checks.json',$checks);});
if($register)$this->name($target,$number,$name);
return ['message'=>'Проверены 90 изображений, размеры и форматы.','hash'=>$hash];
}finally{foreach(glob($dir.'/'.$number.'/*')?:[] as $path)unlink($path);@rmdir($dir.'/'.$number);@rmdir($dir);}
}
public function manifest(array $target,int $number): ?string {return (new Transport($this->store,$target))->call(['op'=>'set-manifest','number'=>self::number($number)])['manifest'];}
public static function preparedManifest(string $dir,int $number): string {$hashes=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.self::number($number).'/'.$filename;if(!is_file($path)||is_link($path))throw new Problem('Подготовленный набор повреждён.');$hashes[$filename]=hash_file('sha256',$path);}ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));}
public function upload(array $target,int $number,string $dir,string $name,?string $expectedManifest=null,bool $hasExpected=false): array {
(new IndpicSetStorage($dir))->validateDirectory($number);$files=[];
foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.$number.'/'.$filename;$files[$filename]=['data'=>base64_encode(file_get_contents($path)),'sha256'=>hash_file('sha256',$path)];}
if(!$hasExpected)$expectedManifest=$this->manifest($target,$number);
$result=(new Transport($this->store,$target))->call(['op'=>'set-write','number'=>$number,'files'=>$files,'expected_manifest'=>$expectedManifest]);
$checked=$this->check($target,$number,true,$name);return $checked;
}
public function name(array $target,int $number,string $name): void {
self::number($number);if(preg_match('//u',$name)!==1||preg_match_all('/./us',$name)>200||preg_match('/[\x00-\x1f]/',$name))throw new Problem('Название набора слишком длинное либо содержит управляющие символы.');
$this->store->locked('sets',function() use($target,$number,$name){$sets=$this->store->read('sets.json');$sets[$target['id'].':'.$number]=['name'=>$name];$this->store->write('sets.json',$sets);});
}
public function delete(array $target,int $number): void {
$config=(new Transport($this->store,$target))->readConfig()['config'];foreach($this->list($target,$config) as $set)if($set['number']===$number&&$set['in_use'])throw new Problem('Нельзя убрать используемый набор. Сначала измените default и веса.');
$this->store->locked('sets',function() use($target,$number){$sets=$this->store->read('sets.json');unset($sets[$target['id'].':'.$number]);$this->store->write('sets.json',$sets);});
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Store
{
public readonly string $dir;
public function __construct(?string $dir=null) {
$this->dir=$dir ?? (getenv('ADMIN_STATE_DIR') ?: '/var/lib/preland-admin');
foreach (['','jobs','backups','uploads','checks'] as $part) { $path=$this->dir . ($part ? '/'.$part : ''); if (!is_dir($path) && !mkdir($path,0700,true)) throw new Problem('Не удалось открыть приватное хранилище.',503); }
}
public function locked(string $key, callable $callback): mixed {
$fp=fopen($this->dir.'/lock-'.hash('sha256',$key),'c'); if (!$fp || !flock($fp,LOCK_EX)) throw new Problem('Не удалось получить блокировку.',503);
try {return $callback();} finally {flock($fp,LOCK_UN);fclose($fp);}
}
public function read(string $name, mixed $default=[]): mixed {
$path=$this->path($name); if (!is_file($path)) return $default;
try {return json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);} catch(\Throwable) {throw new Problem('Повреждено приватное состояние.',503);}
}
public function write(string $name, mixed $value): void {
$path=$this->path($name);$temp=$path.'.'.bin2hex(random_bytes(8));
if (file_put_contents($temp,json_encode($value,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),LOCK_EX)===false || !rename($temp,$path)) throw new Problem('Не удалось сохранить приватное состояние.',503);
chmod($path,0600);
}
public function path(string $name): string { if (!preg_match('~^[a-zA-Z0-9_./-]+$~D',$name) || str_contains($name,'..') || str_starts_with($name,'/')) throw new Problem('Недопустимый идентификатор.'); return $this->dir.'/'.$name; }
public function targets(bool $public=true): array {
$targets=array_values($this->read('targets.json'));
if ($public) foreach($targets as &$target) {$target['hidden']=(bool)($target['hidden']??false);$target['has_secret']=!empty($target['secret']);unset($target['secret']);}
return $targets;
}
public function target(string $id): array {foreach($this->targets(false) as $target) if ($target['id']===$id) return $target;throw new Problem('Подключение не найдено.',404);}
public function saveTarget(array $input, ?string $secret=null): array {
return $this->locked('targets',function() use($input,$secret) {
$targets=$this->read('targets.json');$id=$input['id'] ?? bin2hex(random_bytes(8));
if (!is_string($id) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id)) throw new Problem('Некорректный ID подключения.');
$old=$targets[$id] ?? [];$target=[];
foreach(['name','domain','environment','type','host','user','root','host_key'] as $key) {$value=$input[$key] ?? $old[$key] ?? '';if(!is_string($value)||strlen($value)>2048||preg_match('/[\x00-\x1f]/',$value)) throw new Problem('Некорректное поле подключения.');$target[$key]=trim($value);}
if (!preg_match('/^[a-z0-9.-]+$/iD',$target['domain']) || !$target['domain']) throw new Problem('Некорректный домен.');
if (str_ends_with(strtolower($target['domain']),'.online') && strtolower($target['domain'])!=='zoomstage.online') throw new Problem('Отключённые домены .online подключать нельзя.');
if (!in_array($target['type'],['local','sftp'],true)) throw new Problem('Допускаются только local и SFTP/SSH.');
$root=rtrim($target['root'],'/');if (!$root || !str_starts_with($root,'/') || str_contains($root,'..') || str_contains($root,'\\')) throw new Problem('Корень должен быть абсолютным безопасным путём.');$target['root']=$root;
if ($target['type']==='local') {
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS') ?: '')));
if (!in_array($root,$roots,true) || !is_dir($root) || realpath($root)!==$root) throw new Problem('Локальный корень не входит в явно разрешённые mount.');
} else {
if (!preg_match('/^[a-z0-9_.:-]+$/iD',$target['host']) || !preg_match('/^[a-zA-Z0-9_.-]+$/D',$target['user']) || $target['user']==='root') throw new Problem('Укажите ограниченную SSH-учётную запись и хост.');
if (!preg_match('~^(SHA256:[A-Za-z0-9+/]{43}=?|(?:ssh-|ecdsa-)[A-Za-z0-9@._+-]+ [A-Za-z0-9+/=]+)$~D',$target['host_key'])) throw new Problem('Требуется закреплённый SSH host key либо SHA256 fingerprint.');
}
$target['port']=(int)($input['port'] ?? $old['port'] ?? 22);if($target['port']<1||$target['port']>65535) throw new Problem('Некорректный порт.');
$target['id']=$id;$target['enabled']=(bool)($input['enabled'] ?? $old['enabled'] ?? false);$target['secret']=$secret ?? $old['secret'] ?? '';
$target['hidden']=(bool)($old['hidden']??false);
// Изменение транспорта сбрасывает проверку: запись включается только после test.
$identity=static fn($t)=>array_intersect_key($t,array_flip(['type','host','port','user','root','host_key','secret']));
$target['verified']=isset($old['verified']) && $identity($old)===$identity($target) ? $old['verified'] : false;
$targets[$id]=$target;$this->write('targets.json',$targets);$safe=$target;unset($safe['secret']);$safe['has_secret']=$target['secret']!=='';return $safe;
});
}
public function verifyTarget(string $id, ?array $expected=null): void {$this->locked('targets',function() use($id,$expected){$targets=$this->read('targets.json');if(!isset($targets[$id])) throw new Problem('Подключение не найдено.',404);if($expected!==null&&$targets[$id]!==$expected)throw new Problem('Подключение изменилось во время проверки. Повторите проверку.',409);$targets[$id]['verified']=true;$this->write('targets.json',$targets);});}
public function setTargetVisibility(string $id,bool $hidden): array {
return $this->locked('targets',function() use($id,$hidden){
$targets=$this->read('targets.json');if(!isset($targets[$id]))throw new Problem('Подключение не найдено.',404);
$targets[$id]['hidden']=$hidden;$this->write('targets.json',$targets);return $this->targets();
});
}
public function orderTargets(array $ids): array {
if(!array_is_list($ids))throw new Problem('Порядок должен быть списком ID.');
foreach($ids as $id)if(!is_string($id))throw new Problem('Порядок должен содержать строковые ID.');
return $this->locked('targets',function() use($ids){
$targets=$this->read('targets.json');$visible=[];
foreach($targets as $id=>$target)if(empty($target['hidden']))$visible[]=(string)$id;
if(count($ids)!==count($visible)||count(array_unique($ids,SORT_STRING))!==count($ids)||array_diff($ids,$visible))throw new Problem('Список видимых подключений изменился. Обновите страницу и повторите.',409);
$ordered=[];$position=0;
foreach($targets as $id=>$target){if(empty($target['hidden'])){$id=$ids[$position++];$target=$targets[$id];}$ordered[$id]=$target;}
$this->write('targets.json',$ordered);return $this->targets();
});
}
public function deleteTarget(string $id): void {
$this->locked('targets',function() use($id){
$targets=$this->read('targets.json');if(!isset($targets[$id]))throw new Problem('Подключение не найдено.',404);
if(empty($targets[$id]['hidden']))throw new Problem('Перед удалением скройте подключение.',409);
foreach(glob($this->dir.'/jobs/*.json')?:[] as $path){
$job=$this->read('jobs/'.basename($path));if(!in_array($job['status']??null,['queued','running'],true))continue;
$references=array_column($job['results']??[],'target');
if(isset($job['payload']['source']['id']))$references[]=$job['payload']['source']['id'];
if(in_array($id,$references,true))throw new Problem('Подключение используется незавершённым заданием. Дождитесь его завершения.',409);
}
unset($targets[$id]);$this->write('targets.json',$targets);
});
}
public function backup(string $target,string $content): string {
$id=gmdate('YmdHis').'-'.bin2hex(random_bytes(6));$items=$this->read('backups/index.json');$items[]=['id'=>$id,'target'=>$target,'created_at'=>gmdate(DATE_ATOM)];
$this->write('backups/'.$id.'.json',['content'=>$content]);$this->locked('backup-index',function() use($items){$current=$this->read('backups/index.json');$current[]=end($items);$this->write('backups/index.json',$current);});return $id;
}
public function backups(string $target): array {return array_values(array_map(static fn($item)=>array_diff_key($item,['target'=>true]),array_filter($this->read('backups/index.json'),static fn($item)=>$item['target']===$target)));}
public function backupContent(string $target,string $id): string {foreach($this->backups($target) as $item) if($item['id']===$id) return $this->read('backups/'.$id.'.json')['content'];throw new Problem('Резервная версия не найдена.',404);}
public function audit(array $data): void {$this->locked('audit',function() use($data){file_put_contents($this->dir.'/audit.jsonl',json_encode(['at'=>gmdate(DATE_ATOM)]+$data,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n",FILE_APPEND|LOCK_EX);});}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
use phpseclib3\Net\SSH2;
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
final class Transport
{
private const COMMAND_LIMIT=7000;
private ?SSH2 $ssh=null;
public function __construct(private Store $store, public readonly array $target) {}
private function connect(): SSH2 {
if($this->ssh) return $this->ssh;
$t=$this->target;$ssh=new SSH2($t['host'],$t['port'],20);$key=$ssh->getServerPublicHostKey();
if(!is_string($key)) throw new Problem('Не удалось получить ключ SSH.');
$expected=$t['host_key'];$parts=explode(' ',$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');
if(!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'='))) throw new Problem('Ключ SSH хоста не совпадает с закреплённым.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY')){try{$secret=PublicKeyLoader::loadPrivateKey($secret);}catch(\Throwable){throw new Problem('Некорректный приватный ключ.');}}
if(!$ssh->login($t['user'],$secret)) throw new Problem('Не удалось войти в SSH.');$ssh->setTimeout(60);return $this->ssh=$ssh;
}
public function call(array $payload): array {
$payload['root']=$this->target['root'];
if($this->target['type']==='local') {
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'')));
if(!in_array($this->target['root'],$roots,true))throw new Problem('Локальный путь больше не разрешён.');
$payload['local_private']=$this->store->dir.'/local-'.hash('sha256',$this->target['root']);$result=Remote::execute($payload);
}
else {
$ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code);
// Сжат только доверенный helper из поставки; payload остаётся JSON-данными.
$entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$jsonPayload=json_encode($payload,JSON_THROW_ON_ERROR);
// Размер base64 проверяется ДО inlineCommand/escapeshellarg: для ZIP PHP
// завершает процесс при аргументе >2 МиБ раньше переключения на inbox.
$argumentLength=4*intdiv(strlen($jsonPayload)+2,3);
$command=$argumentLength>self::COMMAND_LIMIT?null:self::inlineCommand($code,$entry,base64_encode($jsonPayload));
$inbox=null;$sftp=null;
try {
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
if($command===null||strlen($command)>self::COMMAND_LIMIT) {
if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.');
$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$candidate=$private.'/inbox-'.bin2hex(random_bytes(16));
// Проверяем конечный bootstrap до рекурсивного preflight: большой helper
// не должен бесконечно пытаться доставить сам себя через inbox.
$command=self::inboxCommand($code,$candidate);
$this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate;
if(!$sftp->put($inbox,$jsonPayload)||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.');
}
$timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout);
$output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null;
if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); }
} finally {
// Helper очищает inbox после чтения; резервная очистка также при обрыве
// exec/ошибке загрузки, не подменяя исходную ошибку транспорта.
if($inbox!==null&&$sftp!==null)try{$sftp->delete($inbox);}catch(\Throwable){}
}
}
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
}
private static function inlineCommand(string $code,string $entry,string $argument): string {
if(!function_exists('gzdeflate'))throw new Problem('Для SSH-транспорта требуется расширение zlib.');
$compressed=gzdeflate($code.$entry,9);if($compressed===false)throw new Problem('Не удалось подготовить SSH helper.');
$runner='if(!function_exists("gzinflate")){echo json_encode(["ok"=>false,"error"=>"На удалённом PHP отсутствует zlib (gzinflate)."]);exit(1);}eval(gzinflate(base64_decode('.var_export(base64_encode($compressed),true).',true)));';
return 'php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($argument);
}
private static function inboxCommand(string $code,string $inbox): string {
$entry="\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}";
$command=self::inlineCommand($code,$entry,$inbox);
if(strlen($command)>self::COMMAND_LIMIT)throw new Problem('SSH helper превышает безопасный предел команды; требуется уменьшить bootstrap.');
return $command;
}
private function sftp(): SFTP {
$t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key'];
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp;
}
public function test(): array {
$result=$this->call(['op'=>'test']);
$read=$this->readConfig();
if (!hash_equals($result['version'],$read['version'])) throw new Conflict();
PercentWeights::validate($read['config']);
return $result;
}
public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];}
public function writeConfig(string $version,string $content): array {
$config=Config::decode($content);Config::assertWritable($config);PercentWeights::validate($config);
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
$this->store->backup($this->target['id'],$before['content']);
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Независимые снимки UTM: без доменов, подключений, заметок и происхождения. */
final class UtmPresets
{
private const GROUPS=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
public function __construct(private Store $store) {}
public function all(): array {
return array_values(array_map(static fn($p)=>['id'=>$p['id'],'name'=>$p['name']],$this->store->read('utm-presets.json')));
}
public function get(string $id): array {
if(!preg_match('/^[a-f0-9]{24}$/D',$id))throw new Problem('Некорректный ID набора.');
$presets=$this->store->read('utm-presets.json');if(!isset($presets[$id]))throw new Problem('Набор удалён или не найден. Обновите список.',404);
$p=$presets[$id];$p['utm']=Config::decode(json_encode($p['utm'],JSON_THROW_ON_ERROR));return $p;
}
public function create(array $data): array {
if(count($data)!==2||array_diff(array_keys($data),['name','utm'])||!is_string($data['name']??null)||!(($data['utm']??null) instanceof \stdClass))throw new Problem('Передайте только название и объект UTM.');
$name=trim($data['name']);if($name===''||!preg_match('/^[^\x00-\x1f\x7f]{1,120}$/uD',$name))throw new Problem('Укажите название от 1 до 120 символов без управляющих знаков.');
$utm=$data['utm'];if(!get_object_vars($utm)||array_diff(array_keys(get_object_vars($utm)),self::GROUPS))throw new Problem('Набор должен содержать только редактируемые группы UTM.');
foreach(get_object_vars($utm) as $group){if(!$group instanceof \stdClass||count(get_object_vars($group))!==2||array_diff(array_keys(get_object_vars($group)),['default','weights']))throw new Problem('В группе разрешены только default и weights, без заметок.');}
// Канонизация использует тот же контракт, что применение на домен.
$changes=Copy::project((object)['utm'=>$utm]);$config=Config::patch((object)[], $changes);Config::validate($config);
return $this->store->locked('utm-presets',function()use($name,$config){
$presets=$this->store->read('utm-presets.json');foreach($presets as $p)if(preg_match('/^'.preg_quote($name,'/').'$/iuD',$p['name']))throw new Problem('Набор с таким названием уже существует. Укажите другое имя.',409);
$id=bin2hex(random_bytes(12));$record=['id'=>$id,'name'=>$name,'utm'=>$config->utm];$presets[$id]=$record;$this->store->write('utm-presets.json',$presets);return $record;
});
}
public function delete(string $id): void {
$this->store->locked('utm-presets',function()use($id){$this->get($id);$presets=$this->store->read('utm-presets.json');unset($presets[$id]);$this->store->write('utm-presets.json',$presets);});
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture-admin-password',PASSWORD_DEFAULT);$env['ADMIN_STATE_DIR']=$root.'/state';$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
$env['PHP_CLI_SERVER_WORKERS']='4';
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($server))throw new RuntimeException('server failed');
$cookie='';
function request(string $path,string $method='GET',?array $body=null,?string $csrf=null): array {
global $port,$cookie;
$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;
$context=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>30]]);
$result=file_get_contents('http://127.0.0.1:'.$port.$path,false,$context);$responseHeaders=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$responseHeaders[0]??'',$match);
foreach($responseHeaders as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];
return [(int)($match[1]??0),json_decode((string)$result,true,64,JSON_THROW_ON_ERROR),$responseHeaders];
}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(50000);}while(microtime(true)<$deadline);
[$status,$body,$anonymousHeaders]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
check(!array_filter($anonymousHeaders,static fn($header)=>str_starts_with(strtolower($header),'set-cookie: preland_admin=')),'HTTP anonymous state cannot overwrite a later authenticated cookie');
[$status,$body]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication');
foreach(['/api/targets/visibility','/api/targets/order'] as $route){[$status]=request($route,'POST',['id'=>'test']);check($status===401,'HTTP target organization requires authentication');}
[$status,$body,$headers]=request('/api/login','POST',['username'=>'test-admin','password'=>'fixture-admin-password']);check($status===200&&isset($body['csrf']),'HTTP login creates authenticated CSRF session');$csrf=$body['csrf'];
check((bool)array_filter($headers,static fn($h)=>str_contains(strtolower($h),'httponly')&&str_contains(strtolower($h),'samesite=strict')),'session cookie HttpOnly and SameSite Strict');
[$status,$body]=request('/api/state');check($status===200&&isset($body['targets'][0])&&!array_key_exists('secret',$body['targets'][0]),'HTTP state redacts transport credentials');
[$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true]);check($status===403,'HTTP visibility requires CSRF');
[$status]=request('/api/targets/order','POST',['ids'=>['test']]);check($status===403,'HTTP order requires CSRF');
[$status,$body]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true],$csrf);check($status===200&&$body['targets'][0]['hidden']===true&&!isset($body['targets'][0]['secret']),'HTTP visibility returns redacted updated targets');
foreach([['id'=>'test','hidden'=>'true'],['id'=>'test','hidden'=>1],['id'=>['test'],'hidden'=>true],['id'=>'test'],['id'=>'test','hidden'=>false,'enabled'=>true]] as $invalid){[$status]=request('/api/targets/visibility','POST',$invalid,$csrf);check($status===422,'HTTP visibility rejects wrong shapes and extra fields');}
[$status]=request('/api/targets/visibility','POST',['id'=>'absent','hidden'=>true],$csrf);check($status===404,'HTTP visibility missing target');
foreach([['ids'=>(object)['0'=>'test']],['ids'=>'test'],['ids'=>[1]],['ids'=>[],'extra'=>true]] as $invalid){[$status]=request('/api/targets/order','POST',$invalid,$csrf);check($status===422,'HTTP order requires only a JSON string array');}
[$status,$body]=request('/api/targets/order','POST',['ids'=>[]],$csrf);check($status===200&&$body['targets'][0]['id']==='test','HTTP all-hidden empty order accepted');
[$status]=request('/api/targets/order','POST',['ids'=>['test']],$csrf);check($status===409,'HTTP stale visible order conflicts after hide');
[$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>false],$csrf);check($status===200,'HTTP restore visible target');
foreach([[],['test','test'],['absent']] as $invalid){[$status]=request('/api/targets/order','POST',['ids'=>$invalid],$csrf);check($status===409,'HTTP incomplete duplicate unknown order conflicts');}
[$status]=request('/api/targets','DELETE',['id'=>'test'],$csrf);check($status===409,'HTTP visible deletion rejected');
[$status]=request('/api/targets','DELETE',['id'=>'absent'],$csrf);check($status===404,'HTTP missing deletion rejected');
[$status]=request('/api/targets','POST',['target'=>['id'=>'delete-fixture','domain'=>'delete.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]],$csrf);check($status===200,'HTTP creates disposable deletion fixture');
[$status]=request('/api/targets/visibility','POST',['id'=>'delete-fixture','hidden'=>true],$csrf);check($status===200,'HTTP hides disposable deletion fixture');
[$status,$body]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===202,'HTTP hidden target remains available to explicit jobs');$deleteJob=$body['job']['id'];
[$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===409,'HTTP queued job prevents hidden deletion');
$pending=$store->read('jobs/'.$deleteJob.'.json');$pending['status']='completed';$store->write('jobs/'.$deleteJob.'.json',$pending);
[$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===200&&is_file($root.'/site/config.json'),'HTTP idle hidden deletion keeps site config');
$countBefore=count(glob($store->dir.'/jobs/*.json'));
[$status]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===404&&count(glob($store->dir.'/jobs/*.json'))===$countBefore,'HTTP stale enqueue cannot create job after connection deletion');
[$status,$body]=request('/api/apply','POST',['targets'=>[],'changes'=>[]]);check($status===403,'HTTP mutation rejects missing CSRF');
[$status,$body]=request('/api/config?target=test');check($status===200&&isset($body['version'],$body['config']),'HTTP config reads actual target version');$version=$body['version'];
file_put_contents($store->dir.'/catalog.json','invalid obsolete catalog');
[$status,$withoutCatalog]=request('/api/config?target=test');check($status===200&&!array_key_exists('catalog',$withoutCatalog)&&$withoutCatalog['warnings']===[],'HTTP config ignores even corrupt obsolete catalog without warnings');
check(is_string($body['read_at']??null)&&preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$body['read_at'])===1&&abs(time()-strtotime($body['read_at']))<10,'HTTP config includes actual successful read timestamp as response metadata');
check(!array_key_exists('read_at',$store->target('test'))&&!property_exists(json_decode(file_get_contents($root.'/site/config.json')),'read_at'),'read timestamp does not enter target or runtime config');
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
(new PrelandAdmin\Jobs($store))->run($id);
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body['selection']===null&&$body['url']===null&&$body['saved_at']===null,'HTTP new link builder state is explicitly empty');
$linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection]);check($status===403,'HTTP link generation requires CSRF');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&$body['selection']===$linkSelection&&str_starts_with($body['url'],'https://test.invalid/?'),'HTTP explicit link generation returns saved independent choice');$savedLink=$body;
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body===$savedLink,'HTTP reload returns exact stored URL without fresh noise');
$linkSelection['focus']='2';[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&array_diff_assoc($body['selection'],$savedLink['selection'])===['focus'=>'2'],'HTTP changing one link selector preserves other choices');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection+['evil'=>'1']],$csrf);check($status===422,'HTTP unexpected selection key rejected');
[$status,$body]=request('/api/link-builder','POST',['target'=>['test'],'selection'=>$linkSelection],$csrf);check($status===422,'HTTP non-string target rejected with safe validation status');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>[]],$csrf);check($status===422,'HTTP selection must be complete object');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection,'url'=>'javascript:alert(1)'],$csrf);check($status===422,'HTTP caller cannot inject URL or extra top-level fields');
[$status,$body]=request('/api/targets','POST',['target'=>['id'=>'test','domain'=>'renamed.invalid']],$csrf);check($status===200,'HTTP target domain rename accepted');
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&str_starts_with($body['url'],'https://renamed.invalid/?')&&$body['selection']===$linkSelection,'HTTP saved selection survives domain rename with current URL base');
// Настоящий медленный SSH read: отдельный TCP fixture принимает соединение без SSH banner.
$slowPort=random_int(30000,39000);$accepted=$root.'/slow-accepted';$ready=$root.'/slow-ready';
$tcpCode='$server=stream_socket_server("tcp://127.0.0.1:".$argv[1]);if(!$server)exit(2);file_put_contents($argv[2],"ready");$client=stream_socket_accept($server,10);if($client){file_put_contents($argv[3],"accepted");sleep(3);fclose($client);}fclose($server);';
$tcp=proc_open([PHP_BINARY,'-r',$tcpCode,(string)$slowPort,$ready,$accepted],[0=>['pipe','r'],1=>$log,2=>$log],$tcpPipes);fclose($tcpPipes[0]);
$slowRead=null;
try{
$until=microtime(true)+5;while(!is_file($ready)&&microtime(true)<$until)usleep(10000);if(!is_file($ready))throw new RuntimeException('Slow TCP fixture unavailable');
$targets=$store->read('targets.json');$targets['slow']=['id'=>'slow','domain'=>'slow.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>$slowPort,'user'=>'fixture','host_key'=>'SHA256:'.str_repeat('A',43),'secret'=>'fixture','root'=>'/not-mounted','enabled'=>true];$store->write('targets.json',$targets);
$readCode='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>10]]);@file_get_contents($argv[1],false,$ctx);';
$slowRead=proc_open([PHP_BINARY,'-r',$readCode,'http://127.0.0.1:'.$port.'/api/config?target=slow',$cookie],[0=>['pipe','r'],1=>$log,2=>$log],$readPipes);fclose($readPipes[0]);
$until=microtime(true)+5;while(!is_file($accepted)&&microtime(true)<$until)usleep(10000);if(!is_file($accepted))throw new RuntimeException('Slow authenticated config read did not reach transport');
$started=microtime(true);[$status,$body]=request('/api/link-builder?target=test');$elapsed=microtime(true)-$started;
check($status===200&&$elapsed<1.5,'slow remote config read does not block link builder in same authenticated session');
}finally{if(is_resource($slowRead))proc_close($slowRead);proc_close($tcp);}
check(hash_file('sha256',$root.'/site/config.json')===$runtimeHash&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'HTTP link generation does not change runtime or enqueue jobs');
[$status,$body]=request('/api/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
echo "HTTP integration complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+11
View File
@@ -0,0 +1,11 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
int rename(const char *oldpath, const char *newpath) {
int (*original)(const char *, const char *) = dlsym(RTLD_NEXT, "rename");
int result = original(oldpath, newpath);
if (result == 0 && strstr(newpath, "/.preland-admin-staging/backup-1-") != NULL) kill(getpid(), SIGKILL);
return result;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
if(($argv[1]??'')==='--crash-child') {require dirname(__DIR__).'/bootstrap.php';$p=json_decode(file_get_contents($argv[2]),true,64,JSON_THROW_ON_ERROR);PrelandAdmin\Remote::execute($p);exit;}
require __DIR__.'/backend-security.php';
use PrelandAdmin\{Config,Sets,Conflict};
require dirname(__DIR__,2).'/components/utm-codec.php';
$resolverConfig=['utm'=>['indpic'=>['default'=>'2','weights'=>['1'=>0,'2'=>0]]]];
check(prelandResolveUtm(['indpic'=>'abcd2'],$resolverConfig)['digits']['indpic']==='2','configured zero-weight indpic remains selectable explicitly by actual resolver');
check(prelandResolveUtm(['indpic'=>'abcd0'],$resolverConfig)['digits']['indpic']==='1','actual generic resolver zero-sum selects first configured variant');
$legacy=Config::decode('{"host":"zoomzilla.ru","utm":{"registration":{"default":"before","weights":{"before":100,"after":0},"extra":true}}}');
$changed=Config::patch($legacy,[['path'=>['utm','reg','default'],'value'=>'2']]);check($changed->utm->registration->default==='after'&&!isset($changed->utm->reg)&&$changed->utm->registration->extra===true,'canonical default adapts to legacy target without duplicate alias');
$changed=Config::patch($legacy,[['path'=>['utm','reg','weights'],'value'=>(object)['1'=>0,'2'=>2]]]);check($changed->utm->registration->weights->after===2,'numeric weight group adapts to legacy representation');
$modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":100,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target');
$changed=Config::patch($modern,[['path'=>['utm','registration','weights','before'],'value'=>5]]);check($changed->utm->reg->weights->{'1'}===5,'legacy weight path adapts to numeric target');
rejects(fn()=>Config::patch(Config::decode('{"utm":{"reg":{},"registration":{}}}'),[['path'=>['utm','reg','default'],'value'=>'2']]),'pre-existing two aliases rejected before adapter');
check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'))===[],'zero-weight indpic can reference manually entered variant');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'))===[],'enum default does not invent weights-membership restriction');
$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);
$manual=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');check(Config::validate($manual)===[],'manual groups require no catalog lookup or availability warnings');
$validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1);
$original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1);
rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images');
$unknown=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$source,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown['id'].'.json',$r);$jobs->retry($unknown['id'],['test']);$jobs->run($unknown['id']);check($jobs->get($unknown['id'])['results'][0]['status']==='conflict'&&$sets->manifest($target,1)===$foreign,'unknown upload retry detects third-party publication and never overwrites it');
$sets->upload($target,1,$validPrepared,'CAS',$foreign,true);$unknown2=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown2['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$foreign,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown2['id'].'.json',$r);$before=count(glob($store->dir.'/local-*/backup-1-*')?:[]);$jobs->retry($unknown2['id'],['test']);$jobs->run($unknown2['id']);check($jobs->get($unknown2['id'])['results'][0]['status']==='success'&&count(glob($store->dir.'/local-*/backup-1-*')?:[])===$before,'already-published desired upload reconciles without another swap or backup');
// SIGKILL в реальном libc rename, после переноса old→backup, без production test hooks.
$library=$root.'/kill-rename.so';$compile=proc_open(['gcc','-shared','-fPIC','-o',$library,__DIR__.'/backend-kill-rename.c','-ldl'],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);fclose($pipes[0]);check(proc_close($compile)===0,'fault injector builds inside Docker');
$files=[];foreach(array_keys(PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $name)$files[$name]=['data'=>base64_encode(file_get_contents($validPrepared.'/1/'.$name)),'sha256'=>hash_file('sha256',$validPrepared.'/1/'.$name)];$private=$store->dir.'/local-'.hash('sha256',$target['root']);$request=['op'=>'set-write','root'=>$target['root'],'local_private'=>$private,'number'=>1,'files'=>$files,'expected_manifest'=>$sets->manifest($target,1)];$input=$root.'/swap.json';file_put_contents($input,json_encode($request,JSON_THROW_ON_ERROR));$env=getenv();$env['LD_PRELOAD']=$library;$child=proc_open([PHP_BINARY,__FILE__,'--crash-child',$input],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$exit=proc_close($child);check($exit!==0&&!is_dir($target['root'].'/1')&&is_file($private.'/swap-1.json'),'SIGKILL after first rename leaves durable recoverable journal');
$crashed=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Crash recovery']);$record=$store->read('jobs/'.$crashed['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_manifest']=$desired;$store->write('jobs/'.$crashed['id'].'.json',$record);
$jobs->interrupted($crashed['id']);clearstatcache();check(is_dir($target['root'].'/1')&&!is_file($private.'/swap-1.json'),'supervisor interruption automatically restores active folder after SIGKILL without user read');
check($jobs->get($crashed['id'])['results'][0]['status']==='unknown'&&$sets->manifest($target,1)===$desired,'automatic journal recovery preserves complete set and unknown operation status');
$unavailable=$jobs->create('upload',['targets'=>['missing'],'number'=>1,'prepared'=>$validPrepared]);$record=$store->read('jobs/'.$unavailable['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$store->write('jobs/'.$unavailable['id'].'.json',$record);$jobs->interrupted($unavailable['id']);$result=$jobs->get($unavailable['id'])['results'][0];check($result['status']==='unknown'&&($result['recovery_checked']??null)===false,'failed automatic recovery remains explicitly unconfirmed and unknown');
echo "Review regression complete\n";
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem};
function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);}
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}')),'old/new UTM alias conflict rejected');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'))===[],'manual numeric theme variant accepted');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}')),'negative UTM weight rejected');
check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'))===[],'config editor does not require a physical set');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'))===[],'legacy semantic enum remains valid without rewriting');
$current=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$current['version']]],'changes'=>[['path'=>['custom','empty'],'value'=>(object)[]]]]);$jobs->run($job['id']);
check($transport->readConfig()['config']->custom->empty instanceof stdClass,'empty JSON object survives persisted job');
$state=$transport->readConfig();$id=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$state['version']]],'changes'=>[['path'=>['host'],'value'=>'resumed.example']]])['id'];
$record=$store->read('jobs/'.$id.'.json');$record['status']='running';$record['results'][0]['status']='running';$desired=Config::encode(Config::patch($state['config'],[['path'=>['host'],'value'=>'resumed.example']]));$record['results'][0]['desired_hash']=hash('sha256',$desired);$store->write('jobs/'.$id.'.json',$record);$transport->writeConfig($state['version'],$desired);(new Jobs($store))->run($id);
check($jobs->get($id)['results'][0]['status']==='success','restart after write reconciles desired hash without overwrite');
$race=$transport->readConfig();$processes=[];foreach(['race-a.example','race-b.example'] as $host){$pid=pcntl_fork();if($pid===0){try{$transport->writeConfig($race['version'],Config::encode(Config::patch($race['config'],[['path'=>['host'],'value'=>$host]])));exit(0);}catch(PrelandAdmin\Conflict){exit(7);}}$processes[]=$pid;}$codes=[];foreach($processes as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);check($codes===[0,7],'two simultaneous writes produce one success and one conflict');
$sets=new Sets($store);$zipPath=$root.'/bad.zip';$zip=new ZipArchive();$zip->open($zipPath,ZipArchive::CREATE);$zip->addFromString('1/../../evil','bad');$zip->close();rejects(fn()=>$sets->prepareZip($zipPath,1),'ZIP traversal rejected');
$zipPath=$root.'/valid.zip';$zip=new ZipArchive();$zip->open($zipPath,ZipArchive::CREATE);$cache=[];
foreach(IndpicSetStorage::expectedDimensions() as $name=>$dimension){$ext=pathinfo($name,PATHINFO_EXTENSION);$key=implode('x',$dimension).'.'.$ext;if(!isset($cache[$key])){$image=new Imagick();$image->newImage($dimension['width'],$dimension['height'],'#345678');$image->setImageFormat($ext);$cache[$key]=$image->getImagesBlob();$image->clear();}$zip->addFromString('1/'.$name,$cache[$key]);}$zip->close();
$prepared=$sets->prepareZip($zipPath,1);check(is_dir($prepared.'/1'),'90 images with CPanel sizes accepted');
$missing=$root.'/missing.zip';copy($zipPath,$missing);$z=new ZipArchive();$z->open($missing);$z->deleteName('1/01_desk_1-source.jpg');$z->close();rejects(fn()=>$sets->prepareZip($missing,1),'ZIP missing required image rejected');
$extra=$root.'/extra.zip';copy($zipPath,$extra);$z=new ZipArchive();$z->open($extra);$z->addFromString('1/extra.jpg','bad');$z->close();rejects(fn()=>$sets->prepareZip($extra,1),'ZIP extra image rejected');
$wrong=$root.'/wrong-size.zip';copy($zipPath,$wrong);$z=new ZipArchive();$z->open($wrong);$image=new Imagick();$image->newImage(10,10,'red');$image->setImageFormat('jpg');$z->addFromString('1/01_desk_1-source.jpg',$image->getImagesBlob());$image->clear();$z->close();rejects(fn()=>$sets->prepareZip($wrong,1),'ZIP incorrect dimensions rejected');
$oversize=$root.'/oversize.zip';$sparse=fopen($oversize,'w');ftruncate($sparse,67108865);fclose($sparse);rejects(fn()=>$sets->prepareZip($oversize,1),'ZIP compressed upload limit enforced before extraction');
function rawZip(array $entries): string {$local='';$central='';foreach($entries as [$name,$data,$declared]){$offset=strlen($local);$size=strlen($data);$crc=crc32($data);$local.=pack('VvvvvvVVVvv',0x04034b50,20,0,0,0,0,$crc,$size,$declared,strlen($name),0).$name.$data;$central.=pack('VvvvvvvVVVvvvvvVV',0x02014b50,20,20,0,0,0,0,$crc,$size,$declared,strlen($name),0,0,0,0,0,$offset).$name;}return $local.$central.pack('VvvvvVVv',0x06054b50,0,0,count($entries),count($entries),strlen($central),strlen($local),0);}
$duplicate=$root.'/duplicate.zip';file_put_contents($duplicate,rawZip([['1/01_desk_1-source.jpg','a',1],['1/01_desk_1-source.jpg','b',1]]));rejects(fn()=>$sets->prepareZip($duplicate,1),'duplicate ZIP entry rejected');
$inflated=$root.'/inflated.zip';file_put_contents($inflated,rawZip([['1/01_desk_1-source.jpg','a',8388609]]));rejects(fn()=>$sets->prepareZip($inflated,1),'declared ZIP expansion limit rejected before image decoding');
$result=$sets->upload($target,1,$prepared,'Тестовый набор');check(str_contains($result['message'],'90'),'local set publication revalidates delivered files');
$sets->check($target,1);check(is_file($root.'/site/1/01_desk_1-source.jpg'),'set directory published');
check((fileperms($root.'/site/1')&0777)===0755,'published set directory is readable by independent web-server UID');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}')),'default without weight variant rejected instead of runtime fallback');
$kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)];
rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image');
// Настоящий формат должен совпадать с расширением, даже если размеры верны.
$jpg=$prepared.'/1/01_desk_1-source.jpg';$image=new Imagick();$image->newImage(1696,920,'red');$image->setImageFormat('png');file_put_contents($jpg,$image->getImagesBlob());$image->clear();
rejects(fn()=>(new IndpicSetStorage($prepared))->validateDirectory(1),'extension and decoded image format mismatch rejected');
echo "Security contract complete\n";
+7
View File
@@ -0,0 +1,7 @@
FROM zoomzilla-api:issue328-591a67c
USER root
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /bin/bash fixture && echo 'fixture:fixture-only-password' | chpasswd \
&& mkdir -p /run/sshd /home/fixture/site && chown -R fixture:fixture /home/fixture/site
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["ssh-keygen -A >/dev/null 2>&1; /usr/sbin/sshd -p 2222; php /work/admin/tests/backend-sftp.php"]
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend-security.php';
use PrelandAdmin\{Store,Transport,Config,Sets};
$site='/home/fixture/site';file_put_contents($site.'/config.json',Config::encode((object)['host'=>'example.org','utm'=>(object)[]]));chown($site.'/config.json','fixture');
$ssh=new phpseclib3\Net\SSH2('127.0.0.1',2222);$key=$ssh->getServerPublicHostKey();check(is_string($key),'real SSH host key available');
$sftpTarget=$store->saveTarget(['id'=>'sftp','name'=>'SFTP fixture','domain'=>'fixture.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>2222,'user'=>'fixture','host_key'=>$key,'root'=>$site,'enabled'=>true],'fixture-only-password');
$store->verifyTarget('sftp');$sftpTarget=$store->target('sftp');$remote=new Transport($store,$sftpTarget);
$remote->test();check(true,'real SSH remote lock and permission preflight');$remoteRead=$remote->readConfig();check($remoteRead['config']->host==='example.org','real SSH config read');
$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'ssh-written.example','utm'=>(object)[]]));check($remote->readConfig()['config']->host==='ssh-written.example','real SSH atomic CAS and reread');
rejects(fn()=>$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'stale.example'])),'real remote stale version rejected');
$bad=$sftpTarget;$bad['host_key']='SHA256:'.str_repeat('A',43);rejects(fn()=>(new Transport($store,$bad))->test(),'pinned SSH key mismatch rejected before login');
// Восстановить проверенный набор после намеренной порчи формата security-тестом.
$preparedRemote=$sets->prepareZip($zipPath,1);$sets->upload($sftpTarget,1,$preparedRemote,'SFTP проверка');check(is_file($site.'/1/01_desk_1-source.jpg'),'real SFTP image transfer, remote activation, checksum and decode');
// Реальный обрыв ожидания после удалённой записи: fixture задерживает завершение команды.
$wrapper=<<<'SH'
#!/bin/sh
/usr/bin/php "$@"
if [ -f /home/fixture/delay-next-write ]; then
op=$(/usr/bin/php -r '$p=json_decode(base64_decode($argv[1]),true);echo $p["op"]??"";' "$5")
if [ "$op" = write ]; then rm /home/fixture/delay-next-write; sleep 3; fi
fi
SH;
file_put_contents('/usr/local/bin/php',$wrapper);chmod('/usr/local/bin/php',0755);
$beforeTimeout=$remote->readConfig();$timeoutJob=$jobs->create('apply',['targets'=>[['id'=>'sftp','version'=>$beforeTimeout['version']]],'changes'=>[['path'=>['host'],'value'=>'timeout-written.example']]]);
file_put_contents('/home/fixture/delay-next-write','1');chown('/home/fixture/delay-next-write','fixture');putenv('ADMIN_TRANSPORT_TIMEOUT=1');$jobs->run($timeoutJob['id']);
check($jobs->get($timeoutJob['id'])['results'][0]['status']==='unknown','real SSH timeout after write persists unknown result');
putenv('ADMIN_TRANSPORT_TIMEOUT=60');$jobs->retry($timeoutJob['id'],['sftp']);$jobs->run($timeoutJob['id']);$retried=$jobs->get($timeoutJob['id']);check($retried['results'][0]['status']==='success','retry reads committed hash after real transport timeout');
echo "SFTP integration complete\n";
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend-security.php';
// Рабочий набор уже опубликован security fixture, сохраняем отдельный свежий subprocess на job.
$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
$queued1=$jobs->create('check',['targets'=>['test'],'number'=>1]);$queued2=$jobs->create('check',['targets'=>['test'],'number'=>1]);
$onceLog=fopen($root.'/once.log','a');$once=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php','--once'],[0=>['pipe','r'],1=>$onceLog,2=>$onceLog],$oncePipes,null,$env);fclose($oncePipes[0]);check(proc_close($once)===0,'worker once exits successfully');fclose($onceLog);
$statuses=[$jobs->get($queued1['id'])['status'],$jobs->get($queued2['id'])['status']];sort($statuses);check($statuses===['completed','queued'],'worker once processes exactly one queued job');
$record=$store->read('jobs/'.$queued1['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_hash']='preserved-reconciliation-marker';$store->write('jobs/'.$queued1['id'].'.json',$record);$jobs->interrupted($queued1['id']);$interrupted=$store->read('jobs/'.$queued1['id'].'.json');check($interrupted['results'][0]['status']==='unknown'&&$interrupted['results'][0]['desired_hash']==='preserved-reconciliation-marker','crashed child becomes unknown and retains reconciliation state');
$log=fopen($root.'/worker.log','a');$started=microtime(true);$process=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);
function awaitJob(string $id,int $seconds=25): array {global $jobs;$until=microtime(true)+$seconds;do{$job=$jobs->get($id);if($job['status']==='completed')return $job;usleep(100000);}while(microtime(true)<$until);throw new RuntimeException('Worker job timeout');}
try{
$first=$jobs->create('check',['targets'=>['test'],'number'=>1]);$one=awaitJob($first['id']);check($one['results'][0]['status']==='success','worker subprocess validates first 90-image job');
while(microtime(true)-$started<65)sleep(1);
$second=$jobs->create('check',['targets'=>['test'],'number'=>1]);$two=awaitJob($second['id']);check($two['results'][0]['status']==='success','worker still validates 90 images after process wall time exceeds 60 seconds');
$status=proc_get_status($process);check($status['running'],'supervisor remains alive beyond Imagick time policy');
echo "Worker isolation complete\n";
}finally{proc_terminate($process);proc_close($process);fclose($log);}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
use PrelandAdmin\Config;
use PrelandAdmin\Store;
use PrelandAdmin\Transport;
use PrelandAdmin\Jobs;
function check(bool $value, string $message): void { if (!$value) throw new RuntimeException($message); echo "PASS $message\n"; }
$root = sys_get_temp_dir() . '/preland-admin-' . bin2hex(random_bytes(6));
mkdir($root, 0700, true); mkdir($root . '/site',0700); mkdir($root . '/state',0700);
putenv('ADMIN_STATE_DIR=' . $root . '/state'); putenv('ADMIN_LOCAL_ROOTS=' . $root . '/site');
$initial = (object)['host'=>'zoomzilla.ru','requisites'=>['one'],'custom'=>(object)['flag'=>true,'zero'=>0,'empty'=>(object)[]],'utm'=>(object)[]];
file_put_contents($root . '/site/config.json', json_encode($initial));
$store = new Store($root . '/state');
$target = $store->saveTarget(['id'=>'test','name'=>'Тест','domain'=>'test.invalid','environment'=>'stage','type'=>'local','root'=>$root . '/site','enabled'=>true]);
$store->verifyTarget('test');$target=$store->target('test');
$transport = new Transport($store, $target);
$before = $transport->readConfig();
$next = Config::patch($before['config'], [['path'=>['host'],'value'=>'example.org']]);
check($next->custom->flag === true && $next->custom->zero === 0 && $next->custom->empty instanceof stdClass, 'selected patch preserves unrelated values and types');
$transport->writeConfig($before['version'], Config::encode($next));
$after = $transport->readConfig(); check($after['config']->host === 'example.org','atomic save and reread');
try { $transport->writeConfig($before['version'], Config::encode($initial)); throw new RuntimeException('missed conflict'); } catch (PrelandAdmin\Conflict $e) { check(true,'stale version conflicts'); }
check(count($store->backups('test')) === 1,'private backup saved');
$jobs = new Jobs($store);
$job = $jobs->create('apply', ['targets'=>[['id'=>'test','version'=>$after['version']],['id'=>'missing','version'=>'x']], 'changes'=>[['path'=>['custom','flag'],'value'=>false]]]);
$jobs->run($job['id']); $job=$jobs->get($job['id']);
check($job['results'][0]['status']==='success' && $job['results'][1]['status']==='error','one target failure does not undo successful target');
$restored=$transport->readConfig();
$backup=$store->backups('test')[0];
$job=$jobs->create('restore',['target'=>'test','id'=>$backup['id'],'version'=>$restored['version']]);$jobs->run($job['id']);
check($jobs->get($job['id'])['results'][0]['status']==='success','backup restores with version protection');
try { $store->saveTarget(['id'=>'escape','type'=>'local','root'=>'/etc','domain'=>'a.invalid']); throw new RuntimeException('unsafe root accepted'); } catch (PrelandAdmin\Problem $e) {check(true,'local root confined to explicit mounts');}
try { Config::patch($initial,[['path'=>['__proto__'],'value'=>1]]); throw new RuntimeException('unsafe key accepted'); } catch(PrelandAdmin\Problem $e) {check(true,'unsafe patch path rejected');}
echo "Backend contract complete\n";
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Store,Config,Jobs,Copy,Problem};
function httpCheck(bool $ok,string $label):void{if(!$ok)throw new RuntimeException($label);echo "PASS $label\n";}
$root=sys_get_temp_dir().'/preset-http-'.bin2hex(random_bytes(6));mkdir($root,0700);$store=new Store($root.'/state');$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture',PASSWORD_DEFAULT);
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);$cookie='';
function presetRequest(string $method,string $path,?array $body=null,?string $csrf=null):array{global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$m);foreach($http_response_header as $h)if(str_starts_with(strtolower($h),'set-cookie: '))$cookie=explode(';',substr($h,12))[0];return [(int)($m[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline);
foreach(['GET','POST','DELETE'] as $method){[$status]=presetRequest($method,'/api/utm-presets');httpCheck($status===401,'presets auth '.$method);}
[$status,$login]=presetRequest('POST','/api/login',['username'=>'test','password'=>'fixture']);$csrf=$login['csrf'];
$payload=['name'=>'HTTP набор','utm'=>Config::decode('{"theme":{"default":3,"weights":{"3":100}}}')];
foreach(['POST','DELETE'] as $method){[$status]=presetRequest($method,'/api/utm-presets',$payload);httpCheck($status===403,'presets CSRF '.$method);}
[$status,$saved]=presetRequest('POST','/api/utm-presets',$payload,$csrf);httpCheck($status===201&&array_keys($saved['preset'])===['id','name','utm'],'HTTP creates strictly domain-free snapshot');$id=$saved['preset']['id'];
[$status,$list]=presetRequest('GET','/api/utm-presets');httpCheck($status===200&&$list['presets']===[['id'=>$id,'name'=>'HTTP набор']],'global list without domain');
[$status]=presetRequest('GET','/api/utm-presets?target=anything');httpCheck($status===422,'domain list filter rejected');
[$status]=presetRequest('POST','/api/utm-presets',$payload,$csrf);httpCheck($status===409,'duplicate name conflict');
foreach(['source','domain','host','notes','target'] as $key){[$status]=presetRequest('POST','/api/utm-presets',$payload+[$key=>'forbidden'],$csrf);httpCheck($status===422,'reject preset binding or unrelated field '.$key);}
[$status]=presetRequest('POST','/api/copy',['source'=>(object)['id'=>'old','version'=>str_repeat('a',64)],'targets'=>[]],$csrf);httpCheck($status===422,'legacy domain-copy API rejected');
[$status]=presetRequest('DELETE','/api/utm-presets',['id'=>$id],$csrf);httpCheck($status===200,'delete independent preset');
[$status]=presetRequest('DELETE','/api/utm-presets',['id'=>$id],$csrf);httpCheck($status===404,'repeat deletion returns not found');
[$status,$list]=presetRequest('GET','/api/utm-presets');httpCheck($list['presets']===[],'deleted preset absent from global list');
echo "Preset HTTP contract complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
use PrelandAdmin\{LinkBuilder, Problem, Store};
function linkCheck(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
echo "PASS {$message}\n";
}
function linkReject(callable $callback, string $message): void
{
try { $callback(); } catch (Problem) { linkCheck(true, $message); return; }
throw new RuntimeException('Приняты неверные данные: ' . $message);
}
$testRoot = sys_get_temp_dir() . '/link-builder-' . bin2hex(random_bytes(8));
$linkStore = new Store($testRoot);
// Недоступные runtime/SSH: конструктор обязан работать без их чтения.
$linkStore->write('targets.json', [
'first' => ['id'=>'first','domain'=>'first.example','type'=>'sftp','host'=>'unreachable.invalid','root'=>'/not-mounted','secret'=>'fixture-only-secret'],
'second' => ['id'=>'second','domain'=>'second.example','type'=>'local','root'=>'/not-mounted'],
]);
$runtimeFile = $testRoot . '/runtime-fixture.json';
file_put_contents($runtimeFile, '{"host":"unchanged.example","utm":{"indpic":{"default":"1"}}}');
$runtimeBefore = hash_file('sha256', $runtimeFile);
$builder = new LinkBuilder($linkStore);
$empty = ['selection'=>null, 'url'=>null, 'saved_at'=>null];
linkCheck($builder->get('first') === $empty, 'first opening has no implicit saved choice or generated URL');
$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd','part'=>'rnd'];
$saved = $builder->save('first', $selection);
linkCheck($saved['selection'] === $selection && is_string($saved['saved_at']), 'explicit save returns complete independent selection and timestamp');
linkCheck(str_starts_with($saved['url'], 'https://first.example/?'), 'URL uses saved target HTTPS origin');
parse_str(parse_url($saved['url'], PHP_URL_QUERY), $parameters);
linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all nine canonical UTM keys');
foreach ($selection as $key=>$value) {
$digit = $value === 'rnd' ? '0' : $value;
linkCheck((bool)preg_match('/^[a-zA-Z0-9]{4}' . $digit . '[a-zA-Z0-9]+$/D', $parameters[$key]), "{$key} has four ASCII prefix characters, selected digit and noise suffix");
}
linkCheck((new LinkBuilder(new Store($testRoot)))->get('first') === $saved, 'reload preserves exact saved URL and selection without regenerating noise');
$regenerated = $builder->save('first', $selection);
linkCheck($regenerated['selection']===$saved['selection']&&$regenerated['url']!==$saved['url'],'only explicit generation refreshes random noise for unchanged selection');
$saved = $regenerated;
linkCheck($builder->get('second') === $empty, 'other target does not inherit saved selection');
$modified = $saved['selection']; $modified['theme'] = '1';
$updated = $builder->save('first', $modified);
linkCheck($updated['selection']['theme']==='1' && array_diff_assoc($updated['selection'], $selection)===['theme'=>'1'], 'changing one selector preserves all other saved choices');
$builder->save('second', array_fill_keys(array_keys($selection), 'rnd'));
linkCheck($builder->get('first') === $updated, 'saving another target does not overwrite first target state');
$targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed.example';$linkStore->write('targets.json',$targets);
$renamed = $builder->get('first');
linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
foreach (array_diff(array_keys($selection),['part']) as $key) {
$manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9';
$manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters);
linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key);
}
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'10']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value');
}
linkReject(fn()=>$builder->get('../targets'),'target traversal rejected');
linkReject(fn()=>$builder->get('missing'),'unknown target rejected');
foreach (['evil.example/path','user@evil.example','https://evil.example','evil.example:443','bad..example','-bad.example',"evil.example\nX: bad"] as $domain) {
$targets['first']['domain']=$domain;$linkStore->write('targets.json',$targets);
linkReject(fn()=>$builder->get('first'),'unsafe registry domain rejected instead of composing executable URL');
}
linkCheck(hash_file('sha256',$runtimeFile)===$runtimeBefore && glob($testRoot.'/jobs/*.json')===[], 'link builder leaves runtime bytes unchanged and creates no jobs');
linkCheck((fileperms($testRoot.'/link-builder-first.json') & 0777)===0600,'saved selection and URL remain in private 0600 state');
echo "Link builder contract complete\n";
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Jobs,Problem};
function manualCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
foreach(['indpic','theme','degr','reg','banch','tp','cons','focus'] as $key){
$config=Config::decode('{"utm":{"'.$key.'":{"default":"3","weights":{"1":0,"3":100}}}}');
manualCheck(Config::validate($config)===[],'manual variant 3 accepted independently for '.$key);
}
$semantic=Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0},"note":"keep"}}}');
$next=Config::patch($semantic,[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>25,'2'=>25,'3'=>50]],['path'=>['utm','theme','default'],'value'=>'3']]);
Config::validate($next);
manualCheck($next->utm->theme->default==='3'&&get_object_vars($next->utm->theme->weights)===['dark'=>25,'light'=>25,3=>50]&&$next->utm->theme->note==='keep','semantic target retains aliases while adding numeric variant 3');
foreach(['0','10','-1','green'] as $variant){try{Config::validate(Config::decode('{"utm":{"theme":{"default":"'.$variant.'","weights":{}}}}'));throw new RuntimeException('Accepted invalid number');}catch(Problem){manualCheck(true,'invalid variant rejected '.$variant);}}
try{Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":50,"1":50}}}}'));throw new RuntimeException('Accepted duplicate');}catch(Problem){manualCheck(true,'semantic numeric duplicate remains rejected');}
$root=sys_get_temp_dir().'/manual-variants-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'site','domain'=>'site.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('site');file_put_contents($root.'/site/config.json','{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0}}}}');$transport=new Transport($store,$store->target('site'));$jobs=new Jobs($store);
$read=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'site','version'=>$read['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>0,'2'=>0,'3'=>100]],['path'=>['utm','theme','default'],'value'=>'3'],['path'=>['utm','indpic'],'value'=>(object)['default'=>'9','weights'=>(object)['9'=>100]]]]]);$jobs->run($job['id']);$read=$transport->readConfig();manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$read['config']->utm->theme->default==='3'&&$read['config']->utm->indpic->default==='9'&&!is_dir($root.'/site/9'),'apply writes theme3 and indpic9 without probing public files');
$backup=$store->backup('site','{"utm":{"theme":{"default":"3","weights":{"3":100}},"degr":{"default":"9","weights":{"9":100}}}}');$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);$job=$jobs->create('restore',['target'=>'site','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$transport->readConfig()['config']->utm->degr->default==='9','restore accepts manual variants without catalog');
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Remote,Jobs,Problem};
function obsoleteCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
function obsoleteReject(callable $call,string $message): void {try{$call();}catch(Problem $e){obsoleteCheck($e->status===422&&str_contains($e->getMessage(),'utm.chb'),$message);return;}throw new RuntimeException('Accepted obsolete key: '.$message);}
$legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":100}}},"custom":{"chb":true}}';
$raw=Config::decode($legacy);obsoleteCheck(property_exists($raw->utm,'chb')&&Config::decode(Config::encode($raw))->custom->chb===true,'legacy raw remains readable and encodable without migration');
obsoleteReject(fn()=>Config::validate($raw),'validation rejects exact deprecated key even null');
foreach([
[['path'=>['host'],'value'=>'other.example']],
[['path'=>['utm','chb'],'value'=>(object)['default'=>'1','weights'=>(object)['1'=>1]]]],
[['path'=>['utm','chb','default'],'value'=>'1']],
[['path'=>['utm'],'value'=>(object)['chb'=>null]]],
] as $changes)obsoleteReject(fn()=>Config::patch($changes[0]['path']===['utm','chb','default']?Config::decode('{"utm":{"chb":{}}}'):$raw,$changes),'patch refuses resulting chb at any depth or parent replacement');
$clean=Config::patch($raw,[['path'=>['utm','chb'],'delete'=>true]]);obsoleteCheck(!property_exists($clean->utm,'chb')&&Config::encode($clean->utm->cons)===Config::encode($raw->utm->cons)&&Config::encode($clean->utm->unknown)===Config::encode($raw->utm->unknown)&&$clean->custom->chb===true,'explicit delete removes only utm.chb and preserves unknown keys and cons');
obsoleteCheck(Config::validate($clean)!==null,'cleaned config passes write validation');
$root=sys_get_temp_dir().'/obsolete-utm-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('a');$target=$store->target('a');$transport=new Transport($store,$target);file_put_contents($root.'/site/config.json',$legacy);$read=$transport->readConfig();obsoleteCheck($read['content']===$legacy&&property_exists($read['config']->utm,'chb'),'actual transport reads obsolete raw without rewriting');
obsoleteReject(fn()=>$transport->writeConfig($read['version'],$legacy),'direct transport rejects obsolete write');obsoleteCheck(file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'rejected direct transport creates no backup and changes no runtime bytes');
$remotePrivate=$root.'/remote-private';$remote=Remote::execute(['op'=>'write','root'=>$root.'/site','local_private'=>$remotePrivate,'version'=>$read['version'],'content'=>$legacy]);obsoleteCheck(!$remote['ok']&&str_contains($remote['error'],'utm.chb'),'standalone remote rejects obsolete write');obsoleteCheck(!file_exists($remotePrivate)&&file_get_contents($root.'/site/config.json')===$legacy,'remote rejection happens before private directory, backup or temp creation');
$jobs=new Jobs($store);$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['host'],'value'=>'other.example']]]);$jobs->run($job['id']);$result=$jobs->get($job['id'])['results'][0];obsoleteCheck($result['status']==='error'&&str_contains($result['message'],'utm.chb')&&file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'actual apply job rejects retained chb with clear error and no publication/backup');
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['utm','chb'],'delete'=>true]]]);$jobs->run($job['id']);obsoleteCheck($jobs->get($job['id'])['results'][0]['status']==='success','actual explicit delete job succeeds');$after=$transport->readConfig();obsoleteCheck(!property_exists($after['config']->utm,'chb')&&Config::encode($after['config']->utm->cons)===Config::encode($raw->utm->cons),'delete publication preserves cons exactly');
$backups=$store->backups('a');$job=$jobs->create('restore',['target'=>'a','id'=>$backups[0]['id'],'version'=>$after['version']]);$jobs->run($job['id']);$result=$jobs->get($job['id'])['results'][0];obsoleteCheck($result['status']==='error'&&str_contains($result['message'],'utm.chb'),'legacy restore is explicitly rejected, never silently sanitized');obsoleteCheck($transport->readConfig()['content']===$after['content']&&$store->backups('a')===$backups&&$store->backupContent('a',$backups[0]['id'])===$legacy,'rejected restore keeps runtime, backups and historical legacy content exact');
echo "Obsolete UTM write guard contract complete\n";
+36
View File
@@ -0,0 +1,36 @@
<?php
/** api#347: общий редактор, копирование и старое приватное состояние. */
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Copy,LinkBuilder,Notes,Store,Problem};
function partCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS $message\n";}
$valid=Config::decode('{"host":"keep.example","metadata":{"keep":true},"utm":{"part":{"default":"2","weights":{"1":0,"2":100}},"unknown":{"keep":true}}}');
Config::validate($valid);
foreach (['{"default":"3","weights":{}}','{"default":"1","weights":{"9":1}}','{"default":"1","weights":{"2":-1}}','{"default":"1","weights":{"1":1}}'] as $group) {
$invalid=Config::decode('{"utm":{"part":'.$group.'}}');$rejected=false;
try {Config::validate($invalid);}catch(Problem){$rejected=true;}
partCheck($rejected,'invalid part enum/weights rejected');
}
$project=new ReflectionMethod(Copy::class,'project');$changes=$project->invoke(null,$valid);
$target=Config::decode('{"host":"before.example","notes":"keep","utm":{"part":{"default":"1","weights":{"1":1},"metadata":"keep"},"unknown":{"keep":true}}}');
$patched=Config::patch($target,$changes);
partCheck($patched->utm->part->default==='2'&&$patched->utm->part->weights->{'2'}===100,'copy includes part default and weights');
partCheck($patched->host==='before.example'&&$patched->notes==='keep'&&$patched->utm->part->metadata==='keep'&&$patched->utm->unknown->keep===true,'copy preserves metadata and unknown groups');
$root=sys_get_temp_dir().'/part347-'.bin2hex(random_bytes(6));$store=new Store($root);
$notes=new Notes($store);$old=$notes->all();unset($old['part']);$store->write('utm-notes.json',$old);
$loaded=$notes->all();partCheck(isset($loaded['part'])&&array_diff_key($loaded,['part'=>true])===$old,'old notes gain empty part without losing notes/versions');
$notes->save(['marker'=>'part','text'=>'Видимость ссылки на сайте','version'=>$loaded['part']['version']]);
partCheck($notes->all()['part']['text']==='Видимость ссылки на сайте','part note saves with CAS');
$store->write('targets.json',['fixture'=>['id'=>'fixture','domain'=>'fixture.example']]);
$oldSelection=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1'];
$params=[];foreach($oldSelection as $key=>$value)$params[$key]='abcd'.$value.'abcdefabcdef';
$store->write('link-builder-fixture.json',['selection'=>$oldSelection,'url'=>'https://fixture.example/?'.http_build_query($params),'saved_at'=>'2026-10-28T01:00:00+00:00']);
$builder=new LinkBuilder($store);$saved=$builder->get('fixture');
partCheck($saved['selection']===$oldSelection+['part'=>'1'],'old saved builder choice gains default1');
parse_str(parse_url($saved['url'],PHP_URL_QUERY),$newParams);
partCheck(array_intersect_key($newParams,$params)===$params&&$newParams['part'][4]==='1','migration preserves old noise and adds masked part');
partCheck($builder->get('fixture')===$saved,'migrated builder reload preserves exact noise');
foreach(['1','2','rnd'] as $part)partCheck($builder->save('fixture',array_replace($saved['selection'],['part'=>$part]))['selection']['part']===$part,'builder supports part '.$part);
try{$builder->save('fixture',array_replace($saved['selection'],['part'=>'3']));throw new RuntimeException('invalid builder part accepted');}catch(Problem){}
echo "Part admin contract complete\n";
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,PercentMigration,Problem};
function migrationCheck(bool $ok,string $label): void {if(!$ok)throw new RuntimeException($label);echo "PASS {$label}\n";}
function migrationReject(string $raw,string $label): void {try{PercentMigration::patch($raw);}catch(Problem){migrationCheck(true,$label);return;}throw new RuntimeException('Accepted '.$label);}
$raw="{\r\n \"host\":\"untouched.example\", \"requisites\": \"weights:1/3\",\n \"utm\": { \"r\\u0065gistration\":{\"default\": 2, \"weights\": {\"after\": 3e0, \"before\": 1.0}, \"extra\": {\"weights\":{\"x\":7}}},\n \"chob\": {\"weights\":{\"b\":0.8,\"a\":0.2},\"default\":\"b\"},\n \"theme\":{\"default\":\"2\",\"weights\":{\"2\":1,\"1\":0}},\n \"unknown\": {\"weights\":{\"1\":1,\"2\":3},\"nested\":[{\"escaped\\\"key\":\"weights\\\":1\"}]}} ,\"unknown\": 9e+10\r\n}\n";
$expected=str_replace(['"after": 3e0','"before": 1.0','"b":0.8','"a":0.2','"2":1,"1":0'],['"after": 75','"before": 25','"b":80','"a":20','"2":100,"1":0'],$raw);
$migrated=PercentMigration::patch($raw);
migrationCheck($migrated===$expected,'only expected numeric token spans change; every other byte stays exact');
$before=Config::decode($raw);$after=Config::decode($migrated);
foreach(['registration','chob','theme'] as $key){migrationCheck(array_keys(get_object_vars($before->utm->$key->weights))===array_keys(get_object_vars($after->utm->$key->weights)),'variant order and keys preserved '.$key);unset($before->utm->$key->weights,$after->utm->$key->weights);}
migrationCheck($before==$after,'all remaining JSON values/default types/aliases/unknown groups preserved');
migrationCheck(PercentMigration::patch($migrated)===$migrated,'second run is byte-identical');
foreach(['{"x":0,"y":0}','{"x":100.0,"y":0e0}'] as $weights){$source='{"utm":{"bchoice":{"default":1,"weights":'.$weights.'}}}';migrationCheck(PercentMigration::patch($source)===$source,'all-zero and already-percent tokens preserved');}
foreach([['1','3',25,75],['2','1',66.67,33.33],['1e308','1e308',50,50],['1e-300','3e-300',25,75]] as [$x,$y,$a,$b]){$source='{"utm":{"focus":{"weights":{"x":'.$x.',"y":'.$y.'}}}}';$out=Config::decode(PercentMigration::patch($source));migrationCheck((float)$out->utm->focus->weights->x===(float)$a&&(float)$out->utm->focus->weights->y===(float)$b,'safe normalization '.$x.':'.$y);}
foreach([
'{"utm":{"reg":{"weights":{"1":1,"2":1,"3":1}}}}',
'{"utm":{"reg":{"weights":{"1":"1","2":3}}}}',
'{"utm":{"reg":{"weights":{"1":-1,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1e999,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1e-999,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1,"1":3}}}}',
'{"utm":{"reg":{"weights":{"1":1,"\\u0031":3}}}}',
'{"utm":{"reg":{"weights":{"1":1}}},"utm":{}}',
'{"utm":{"reg":{"weights":{"1":1}}},"extra":{"x":1,"x":2}}',
'{"utm":{"reg":{"weights":{"1":1}},"registration":{"weights":{"1":1}}}}',
'{"utm":{"theme":{"weights":[1,3]}}}',
'{"utm":null}',
] as $i=>$source)migrationReject($source,'unsafe/ambiguous input rejected '.$i);
echo "Raw percent migration complete\n";
$partRaw='{"metadata":"keep","utm":{"part":{"default":"2","weights":{"1":1,"2":3}}}}';
migrationCheck(PercentMigration::patch($partRaw)===str_replace('{"1":1,"2":3}','{"1":25,"2":75}',$partRaw),'part relative weights normalize only explicit numeric spans');
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,PercentWeights,Problem,Store,Transport,Jobs,Copy};
function percentCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
function percentReject(callable $call,string $message): void {try{$call();}catch(Problem $e){percentCheck($e->status===422,$message);return;}throw new RuntimeException('Accepted: '.$message);}
function percentConfig(mixed $weights,string $key='theme'): stdClass {return (object)['utm'=>(object)[$key=>(object)['default'=>1,'weights'=>$weights,'extra'=>(object)['keep'=>true]]],'custom'=>(object)['weights'=>[1,2]]];}
foreach(['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'] as $key)foreach([(object)['1'=>100],(object)['1'=>33.33,'2'=>66.67],(object)['1'=>0,'2'=>0],new stdClass()] as $weights)PercentWeights::validate(percentConfig($weights,$key));
percentCheck(true,'all canonical groups, aliases, two decimals and zero groups accepted');
$precisionCases=['{"1":33.33000000000001,"2":66.67}','{"1":99.9999999999999}','{"1":0.010000000000000002,"2":99.99}'];$acceptedPrecision=[];
foreach ($precisionCases as $json) {try {PercentWeights::validate(percentConfig(json_decode($json,false,16,JSON_THROW_ON_ERROR)));$acceptedPrecision[]=$json;echo "FAIL excessive precision accepted {$json}\n";}catch(Problem $e){percentCheck($e->status===422,'strict stored precision rejects '.$json);}}
percentCheck($acceptedPrecision===[],'strict precision does not use binary tolerance to accept extra submitted decimals');
foreach([(object)['1'=>1],(object)['1'=>33.333,'2'=>66.667],(object)['1'=>100.01],(object)['1'=>-1,'2'=>101],(object)['1'=>'100'],(object)['1'=>true],(object)['1'=>null],(object)['1'=>INF],(object)['1'=>NAN],(object)['1'=>50,'2'=>49.99],[],null] as $weights)percentReject(fn()=>PercentWeights::validate(percentConfig($weights)),'invalid stored weights rejected');
percentReject(fn()=>PercentWeights::validate((object)['utm'=>[]]),'invalid utm container rejected');
percentReject(fn()=>PercentWeights::validate((object)['utm'=>(object)['theme'=>null]]),'invalid known group rejected');
percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1])),'Config validation rejects relative weights');
$source=percentConfig((object)['1'=>3,'2'=>1],'registration');$before=Config::encode($source);$migrated=PercentWeights::normalizeRelative($source);PercentWeights::validate($migrated);
percentCheck(get_object_vars($migrated->utm->registration->weights)===['1'=>75,'2'=>25],'explicit migration produces integer percentages');
$rounded=PercentWeights::normalizeRelative(percentConfig((object)['1'=>2,'2'=>1]));percentCheck(get_object_vars($rounded->utm->theme->weights)===['1'=>66.67,'2'=>33.33],'migration rounds each share without redistribution');
percentCheck(Config::encode($source)===$before&&$migrated->utm->registration->default===1&&$migrated->custom==$source->custom,'migration clones and preserves defaults types unknown data');
foreach([[1,1,1],[1.0e-300,1.0e300]] as $values)percentReject(fn()=>PercentWeights::normalizeRelative(percentConfig((object)$values)),'unrepresentable shares stop migration');
foreach([[1.0e308,1.0e308],[1.0e-300,1.0e-300]] as $values){$result=PercentWeights::normalizeRelative(percentConfig((object)$values));percentCheck(array_values(get_object_vars($result->utm->theme->weights))===[50,50],'normalization avoids overflow and underflow');}
$zero=percentConfig((object)['2'=>0,'1'=>0]);percentCheck(Config::encode(PercentWeights::normalizeRelative($zero))===Config::encode($zero),'all-zero order and default preserved');
$root=sys_get_temp_dir().'/percent-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store=new Store($root.'/state');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$transport=new Transport($store,$store->target('a'));
$old=Config::encode(percentConfig((object)['1'=>1]));file_put_contents($root.'/site/config.json',$old);percentReject(fn()=>$transport->test(),'connection test refuses relative weights');percentCheck($transport->readConfig()['content']===$old,'read returns old raw without normalization');$store->verifyTarget('a');$transport=new Transport($store,$store->target('a'));$jobs=new Jobs($store);$read=$transport->readConfig();
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['host'],'value'=>'new.example']]]);$jobs->run($job['id']);percentCheck($jobs->get($job['id'])['results'][0]['status']==='error'&&file_get_contents($root.'/site/config.json')===$old&&$store->backups('a')===[],'apply rejects old weights before publication and backup');
$backup=$store->backup('a',$old);$valid=Config::encode(percentConfig((object)['1'=>100]));file_put_contents($root.'/site/config.json',$valid);$backups=$store->backups('a');$read=$transport->readConfig();$job=$jobs->create('restore',['target'=>'a','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);$out=$jobs->get($job['id'])['results'][0];percentCheck($out['status']==='error'&&str_contains($out['message'],'100')&&file_get_contents($root.'/site/config.json')===$valid&&$store->backups('a')===$backups&&$store->backupContent('a',$backup)===$old,'restore rejects historical relative backup with clear error and retains exact archives');
percentCheck($transport->test()['ok']===true,'valid percentages pass actual connection test');
$saved=$transport->readConfig();$backupCount=count($store->backups('a'));
foreach ([(object)['1'=>33.333,'2'=>66.667],(object)['1'=>99.99],(object)['1'=>'100']] as $weights) {
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$saved['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>$weights]]]);$jobs->run($job['id']);
percentCheck($jobs->get($job['id'])['results'][0]['status']==='error'&&$transport->readConfig()['content']===$saved['content']&&count($store->backups('a'))===$backupCount,'submitted precision, sum and type errors reject without normalization or publication');
}
$weights=(object)['1'=>33.33,'2'=>66.67];$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$saved['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>$weights]]]);$jobs->run($job['id']);$saved=$transport->readConfig();
percentCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$saved['config']->utm->theme->weights==$weights&&$saved['config']->utm->theme->default===1,'actual apply saves exact submitted percentages and preserves numeric default');
require dirname(__DIR__,2).'/components/utm-codec.php';
foreach(['theme','indpic'] as $key)foreach([(object)['1'=>3,'2'=>1],(object)['2'=>0,'1'=>0]] as $weights){$relative=percentConfig($weights,$key);$percent=PercentWeights::normalizeRelative($relative);mt_srand(2828);$a=[];for($i=0;$i<500;$i++)$a[]=prelandResolveUtm([$key=>'abcd0'],json_decode(Config::encode($relative),true));mt_srand(2828);$b=[];for($i=0;$i<500;$i++)$b[]=prelandResolveUtm([$key=>'abcd0'],json_decode(Config::encode($percent),true));percentCheck($a===$b,'actual seeded runtime resolver invariant across explicit normalization for '.$key);}
echo "Percent weights contract complete\n";
+8
View File
@@ -0,0 +1,8 @@
// Источник старых регрессий заменён независимым UTM-снимком. Доменные поля в POST не передаются.
module.exports=async function choosePreset(page,id){
const state=await page.request.get('/api/state').then(r=>r.json());
const read=await page.request.get('/api/config?target='+id).then(r=>r.json()),utm={};
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};}
const response=await page.request.post('/api/utm-presets',{headers:{'X-CSRF-Token':state.csrf},data:{name:'UI-'+Date.now()+'-'+Math.random(),utm}}),saved=await response.json();
if(!saved.ok)throw Error(saved.error);await page.locator('#preset-reload').click();await page.waitForFunction(id=>[...document.querySelector('#copy-source').options].some(o=>o.value===id),saved.preset.id);await page.locator('#copy-source').selectOption(saved.preset.id);return saved.preset.id;
};
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
function rememberCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
$rememberRoot=sys_get_temp_dir().'/remember-auth-'.bin2hex(random_bytes(8));mkdir($rememberRoot,0700,true);
$rememberState=$rememberRoot.'/state';$rememberServers=[];
function rememberServer(string $label,?string $state=null): int {
global $rememberRoot,$rememberState,$rememberServers;
$env=getenv();$env['ADMIN_STATE_DIR']=$state??$rememberState;$env['ADMIN_USERNAME']='remember-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('remember-fixture-password',PASSWORD_DEFAULT);$env['ADMIN_COOKIE_SECURE']='0';$env['PHP_CLI_SERVER_WORKERS']='4';
$port=random_int(19000,29000);$log=fopen($rememberRoot.'/'.$label.'.log','a');$default=$rememberRoot.'/ephemeral-'.$label;mkdir($default,0700);
$p=proc_open([PHP_BINARY,'-d','session.save_path='.$default,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($p))throw new RuntimeException('server failed');fclose($pipes[0]);$rememberServers[$label]=[$p,$log];
$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);return $port;}usleep(20000);}while(microtime(true)<$until);throw new RuntimeException('server not ready');
}
function rememberStop(string $label): void {global $rememberServers;if(!isset($rememberServers[$label]))return;[$p,$log]=$rememberServers[$label];proc_terminate($p);proc_close($p);fclose($log);unset($rememberServers[$label]);}
function rememberRequest(int $port,string $path,string $cookie='',?array $body=null,?string $csrf=null): array {
$h=['Content-Type: application/json'];if($cookie!=='')$h[]='Cookie: '.$cookie;if($csrf!==null)$h[]='X-CSRF-Token: '.$csrf;
$ctx=stream_context_create(['http'=>['method'=>$body===null?'GET':'POST','header'=>implode("\r\n",$h),'content'=>$body===null?'':json_encode((object)$body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$text=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$headers=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$headers[0]??'',$m);
$setCookie=null;foreach($headers as $header)if(str_starts_with(strtolower($header),'set-cookie: preland_admin=')){$setCookie=substr($header,12);$cookie=explode(';',$setCookie)[0];}
clearstatcache();return [(int)($m[1]??0),json_decode((string)$text,true,64,JSON_THROW_ON_ERROR),$cookie,$setCookie];
}
function rememberLogin(int $port,mixed $remember=false,string $cookie=''): array {return rememberRequest($port,'/api/login',$cookie,['username'=>'remember-fixture','password'=>'remember-fixture-password','remember'=>$remember]);}
function rememberFile(string $cookie,bool $remember=true): string {global $rememberState;return $rememberState.'/'.($remember?'remember-sessions':'sessions').'/sess_'.substr($cookie,strlen('preland_admin='));}
function rememberEdit(string $cookie,string $field,int $value): void {$file=rememberFile($cookie);$text=file_get_contents($file);$new=preg_replace('/'.preg_quote($field,'/').'\|i:\d+;/', $field.'|i:'.$value.';', $text,1,$count);if($count!==1)throw new RuntimeException('Missing fixture session field '.$field);file_put_contents($file,$new);}
function rememberBacking(string $cookie): array {
global $rememberState;
$code='require $argv[1];$_COOKIE["preland_admin"]=$argv[2];PrelandAdmin\\Auth::session();echo json_encode(["bucket"=>basename(session_save_path()),"gc"=>(int)ini_get("session.gc_maxlifetime"),"cookie_lifetime"=>session_get_cookie_params()["lifetime"]]);session_write_close();';
$env=getenv();$env['ADMIN_STATE_DIR']=$rememberState;$env['ADMIN_COOKIE_SECURE']='0';$p=proc_open([PHP_BINARY,'-r',$code,dirname(__DIR__).'/bootstrap.php',substr($cookie,strlen('preland_admin='))],[0=>['pipe','r'],1=>['pipe','w'],2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);fclose($pipes[1]);if(proc_close($p)!==0)throw new RuntimeException('Backing inspection failed');return json_decode($out,true,64,JSON_THROW_ON_ERROR);
}
try{
$a=rememberServer('a');
[$status]=rememberRequest($a,'/api/state');rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'anonymous request does not create long-lived backing');
[$status,$ignored,$ignoredCookie,$failedCookie]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing');
rememberCheck($failedCookie===null,'failed login cannot replace a newer authenticated cookie');
foreach(['true',1,null,[],(object)['value'=>true]] as $bad){[$status]=rememberLogin($a,$bad);rememberCheck($status===422,'remember input requires strict boolean');}
[$status,$regular,$regularCookie,$header]=rememberLogin($a,false);rememberCheck($status===200&&!str_contains(strtolower((string)$header),'expires=')&&!str_contains(strtolower((string)$header),'max-age='),'unchecked login issues browser-session cookie');
$normalFile=rememberFile($regularCookie,false);rememberCheck(is_file($normalFile)&&!is_dir($rememberState.'/remember-sessions'),'regular login remains in short-lived backing');
$backing=rememberBacking($regularCookie);rememberCheck($backing===['bucket'=>'sessions','gc'=>3600,'cookie_lifetime'=>0],'regular backing keeps GC one hour and browser-session lifetime');
$legacy='preland_admin='.bin2hex(random_bytes(16));$legacyCsrf=bin2hex(random_bytes(32));file_put_contents(rememberFile($legacy,false),'user|'.serialize('remember-fixture').'csrf|'.serialize($legacyCsrf).'last|'.serialize(time()));
[$status,$oldState]=rememberRequest($a,'/api/state',$legacy);rememberCheck($status===200&&$oldState['csrf']===$legacyCsrf&&is_file(rememberFile($legacy,false)),'existing d04-style ordinary session continues without migration or deletion');
$before=time();[$status,$login,$cookie,$header]=rememberLogin($a,true,$regularCookie);
rememberCheck($status===200&&preg_match('/expires=([^;]+)/i',(string)$header,$match)===1&&abs(strtotime($match[1])-($before+7776000))<3,'checked login issues native session cookie expiring 90 days from login');
rememberCheck(str_contains(strtolower($header),'httponly')&&str_contains(strtolower($header),'samesite=strict'),'remembered cookie preserves HttpOnly and SameSite Strict');
rememberCheck($cookie!==$regularCookie&&!is_file($normalFile)&&is_file(rememberFile($cookie)),'regular to remember login destroys old backing and creates fresh native SID');
$backing=rememberBacking($cookie);rememberCheck($backing===['bucket'=>'remember-sessions','gc'=>7776000,'cookie_lifetime'=>7776000],'only authenticated remember bucket uses ninety-day GC and cookie lifetime');
[$status]=rememberRequest($a,'/api/state',$regularCookie);rememberCheck($status===401,'old regular cookie cannot be reused after remembered login');
$text=file_get_contents(rememberFile($cookie));rememberCheck(!str_contains($text,'remember-fixture-password')&&!str_contains($text,'password'),'remembered server session stores no password');
preg_match('/remember_until\|i:(\d+);/',$text,$untilMatch);$absolute=(int)($untilMatch[1]??0);rememberCheck($absolute>=$before+7776000&&$absolute<=time()+7776000,'server stores absolute remember-until bound from login');
rememberEdit($cookie,'last',time()-7200);[$status,$state,$same,$refresh]=rememberRequest($a,'/api/state',$cookie);
rememberCheck($status===200&&$state['csrf']===$login['csrf']&&$same===$cookie&&$refresh===null,'remembered session survives old idle timestamp without rotating cookie or CSRF');
preg_match('/remember_until\|i:(\d+);/',file_get_contents(rememberFile($cookie)),$after);rememberCheck((int)$after[1]===$absolute,'ordinary read does not slide absolute remember deadline');
rememberStop('a');$b=rememberServer('b');[$status,$body]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200&&$body['csrf']===$login['csrf'],'remembered login survives fresh server and separate ephemeral session path');
$clients=[];$outputs=[];$code='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>5]]);$body=file_get_contents($argv[1],false,$ctx);echo json_encode(["body"=>json_decode($body,true),"set_cookie"=>count(array_filter($http_response_header,fn($h)=>str_starts_with(strtolower($h),"set-cookie:")))]);';
for($i=0;$i<4;$i++){$path=$rememberRoot.'/parallel-'.$i.'.json';$out=fopen($path,'w');$p=proc_open([PHP_BINARY,'-r',$code,'http://127.0.0.1:'.$b.'/api/state',$cookie],[0=>['pipe','r'],1=>$out,2=>STDERR],$pipes);fclose($pipes[0]);$clients[]=[$p,$out];$outputs[]=$path;}
foreach($clients as [$p,$out]){if(proc_close($p)!==0)throw new RuntimeException('Parallel request failed');fclose($out);}foreach($outputs as $path){$parallel=json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);rememberCheck(($parallel['body']['user']??null)==='remember-fixture'&&($parallel['body']['csrf']??null)===$login['csrf']&&$parallel['set_cookie']===0,'parallel remembered requests preserve authentication, native cookie and CSRF');}
$other=rememberServer('other',$rememberRoot.'/other-state');[$status]=rememberRequest($other,'/api/state',$cookie);rememberCheck($status===401,'remember cookie cannot authenticate against unrelated state');
// Абсолютный срок важнее свежего last; пограничный срок ещё до 90 дней допустим.
rememberEdit($cookie,'remember_until',time()+5);rememberEdit($cookie,'last',time()-7200);[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200,'remembered session before absolute deadline ignores regular idle expiry');
rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&$expiredHeader===null,'absolute expiry revokes backing without sending a cookie that can erase a newer login');
[$status,$ignored,$ignoredCookie,$replayedHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&$replayedHeader===null&&!is_file(rememberFile($cookie)),'expired cookie remains unauthenticated on replay after server revocation');
[$status,$login,$cookie]=rememberLogin($b,true);$longFile=rememberFile($cookie);[$status,$newLogin,$newCookie,$header]=rememberLogin($b,false,$cookie);rememberCheck($status===200&&$newCookie!==$cookie&&!is_file($longFile)&&is_file(rememberFile($newCookie,false))&&!str_contains(strtolower((string)$header),'expires='),'remember to regular login destroys long-lived SID and restores session-only cookie');
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'old remembered cookie cannot be reused after regular login');
$normal=rememberFile($newCookie,false);$raw=file_get_contents($normal);file_put_contents($normal,preg_replace('/last\|i:\d+;/','last|i:'.(time()-3601).';',$raw));[$status]=rememberRequest($b,'/api/state',$newCookie);rememberCheck($status===401,'unchecked login still enforces one-hour idle expiry');
[$status,$login,$cookie]=rememberLogin($b,true);$file=rememberFile($cookie);[$status]=rememberRequest($b,'/api/logout',$cookie,[],'invalid');rememberCheck($status===403&&is_file($file),'remembered logout requires existing CSRF');
[$status,$body,$ignored,$clear]=rememberRequest($b,'/api/logout',$cookie,[],$login['csrf']);rememberCheck($status===200&&!is_file($file)&&str_contains(strtolower((string)$clear),'expires=')&&str_contains(strtolower((string)$clear),'httponly')&&str_contains(strtolower((string)$clear),'samesite=strict'),'logout immediately destroys remembered SID and clears same cookie flags');
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'captured pre-logout remembered cookie cannot be replayed');
$longCount=count(glob($rememberState.'/remember-sessions/sess_*')?:[]);for($i=0;$i<3;$i++)rememberRequest($b,'/api/state','preland_admin='.bin2hex(random_bytes(16)));rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'arbitrary anonymous SIDs create no 90-day files');
rememberRequest($b,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'failed remembered login creates no long-lived files in existing bucket');
rememberCheck((fileperms($rememberState.'/remember-sessions')&0777)===0700&&(fileperms($rememberState.'/sessions')&0777)===0700,'both backing directories keep private 0700 modes');
[$status,$normal,$normalCookie]=rememberLogin($b,false);$normalPath=rememberFile($normalCookie,false);$link=rememberFile($normalCookie,true);symlink($normalPath,$link);[$status]=rememberRequest($b,'/api/state',$normalCookie);rememberCheck($status===503&&is_file($normalPath),'remembered SID symlink is rejected without reading regular backing through it');unlink($link);
[$status,$fresh,$freshCookie]=rememberLogin($b,true,$normalCookie);rememberCheck((fileperms(rememberFile($freshCookie))&0777)===0600,'authenticated remember file has private 0600 mode');
mkdir($rememberRoot.'/external',0700);mkdir($rememberRoot.'/unsafe',0700);symlink($rememberRoot.'/external',$rememberRoot.'/unsafe/remember-sessions');$unsafe=rememberServer('unsafe',$rememberRoot.'/unsafe');[$status]=rememberRequest($unsafe,'/api/state');rememberCheck($status===503&&glob($rememberRoot.'/external/*')===[],'remember-directory symlink fails closed without external writes');
echo "Remember auth contract complete\n";
}finally{foreach(array_keys($rememberServers) as $label)rememberStop($label);}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
function sessionCheck(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
echo "PASS {$message}\n";
}
$fixture = sys_get_temp_dir() . '/persistent-session-' . bin2hex(random_bytes(8));
mkdir($fixture, 0700, true);
mkdir($fixture . '/shared', 0700);
mkdir($fixture . '/shared/sessions', 0777);chmod($fixture . '/shared/sessions', 0777);
$passwordHash = password_hash('persistent-session-fixture-password', PASSWORD_DEFAULT);
$servers = [];
function startSessionServer(string $state, string $label): array
{
global $fixture, $passwordHash, $servers;
$ephemeral = $fixture . '/ephemeral-' . $label;mkdir($ephemeral, 0700);
$environment = getenv();
unset($environment['PHP_CLI_SERVER_WORKERS']);
$environment['ADMIN_STATE_DIR'] = $state;
$environment['ADMIN_USERNAME'] = 'session-fixture';
$environment['ADMIN_PASSWORD_HASH'] = $passwordHash;
$environment['ADMIN_COOKIE_SECURE'] = '0';
$port = random_int(19000, 29000);
$log = fopen($fixture . '/' . $label . '.log', 'a');
$process = proc_open([PHP_BINARY, '-d', 'session.save_path=' . $ephemeral, '-S', '127.0.0.1:' . $port,
'-t', dirname(__DIR__) . '/public', dirname(__DIR__) . '/public/index.php'],
[0=>['pipe','r'], 1=>$log, 2=>$log], $pipes, null, $environment);
if (!is_resource($process)) throw new RuntimeException('Не удалось запустить PHP server fixture.');
fclose($pipes[0]);
$servers[$label] = [$process, $log];
$deadline = microtime(true) + 5;
do {
$socket = @fsockopen('127.0.0.1', $port);
if ($socket) { fclose($socket); return ['port'=>$port, 'label'=>$label]; }
usleep(20000);
} while (microtime(true) < $deadline);
throw new RuntimeException('PHP server fixture не отвечает.');
}
function stopSessionServer(string $label): void
{
global $servers;
if (!isset($servers[$label])) return;
[$process, $log] = $servers[$label];
proc_terminate($process);proc_close($process);fclose($log);unset($servers[$label]);
}
function sessionRequest(array $server, string $path, string $cookie = '', ?array $body = null, ?string $csrf = null): array
{
$headers = ['Content-Type: application/json'];
if ($cookie !== '') $headers[] = 'Cookie: ' . $cookie;
if ($csrf !== null) $headers[] = 'X-CSRF-Token: ' . $csrf;
$context = stream_context_create(['http'=>['method'=>$body===null?'GET':'POST',
'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode((object)$body, JSON_THROW_ON_ERROR),
'ignore_errors'=>true,'timeout'=>10]]);
$text = file_get_contents('http://127.0.0.1:' . $server['port'] . $path, false, $context);
$responseHeaders = $http_response_header ?? [];
preg_match('~HTTP/\S+ (\d+)~', $responseHeaders[0] ?? '', $match);
foreach ($responseHeaders as $header) if (str_starts_with(strtolower($header), 'set-cookie: ')) $cookie=explode(';',substr($header,12))[0];
return [(int)($match[1]??0), json_decode((string)$text,true,64,JSON_THROW_ON_ERROR), $cookie];
}
try {
$a = startSessionServer($fixture . '/shared', 'a');
[$status, $login, $cookie] = sessionRequest($a, '/api/login', '', ['username'=>'session-fixture','password'=>'persistent-session-fixture-password']);
sessionCheck($status===200 && isset($login['csrf']), 'server A logs in and returns session cookie');
stopSessionServer('a');
$b = startSessionServer($fixture . '/shared', 'b');
[$status, $body] = sessionRequest($b, '/api/state', $cookie);
sessionCheck($status===200 && $body['user']==='session-fixture' && $body['csrf']===$login['csrf'], 'fresh server B with separate ephemeral default path restores same cookie from shared state');
sessionCheck((fileperms($fixture.'/shared/sessions')&0777)===0700, 'persistent session directory has private 0700 permissions');
parse_str(str_replace(';','&',$cookie), $cookieValues);$sessionId=$cookieValues['preland_admin'];
$sessionFile=$fixture.'/shared/sessions/sess_'.$sessionId;
sessionCheck(is_file($sessionFile), 'session data exists inside ADMIN_STATE_DIR sessions');
sessionCheck((fileperms($sessionFile)&0777)===0600, 'native persistent session file has private 0600 permissions');
$c = startSessionServer($fixture . '/isolated', 'c');
[$status] = sessionRequest($c, '/api/state', $cookie);
sessionCheck($status===401, 'same cookie cannot authenticate fresh server with different state');
stopSessionServer('c');
[$status] = sessionRequest($b, '/api/logout', $cookie, [], 'wrong-token');
sessionCheck($status===403, 'persisted session still rejects invalid CSRF');
[$status] = sessionRequest($b, '/api/logout', $cookie, [], $login['csrf']);
sessionCheck($status===200, 'persisted session logs out with its original CSRF');
[$status] = sessionRequest($b, '/api/state', $cookie);
sessionCheck($status===401, 'logout invalidates original persistent cookie');
[$status, $newLogin, $newCookie] = sessionRequest($b, '/api/login', '', ['username'=>'session-fixture','password'=>'persistent-session-fixture-password']);
sessionCheck($status===200 && $newCookie!==$cookie && $newLogin['csrf']!==$login['csrf'], 'new login regenerates session ID and CSRF after persistent logout');
parse_str($newCookie, $values);$expiredFile=$fixture.'/shared/sessions/sess_'.$values['preland_admin'];
$expired=preg_replace('/last\|i:\d+;/', 'last|i:'.(time()-3601).';', file_get_contents($expiredFile));file_put_contents($expiredFile,$expired);
[$status] = sessionRequest($b, '/api/state', $newCookie);
sessionCheck($status===401, 'persistent file does not extend the existing 3600-second idle expiry');
mkdir($fixture.'/external',0700);mkdir($fixture.'/symlink-session',0700);symlink($fixture.'/external',$fixture.'/symlink-session/sessions');
$bad = startSessionServer($fixture.'/symlink-session','bad-session');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503 && glob($fixture.'/external/*')===[], 'symlink sessions directory fails closed without writing outside state');stopSessionServer('bad-session');
symlink($fixture.'/shared',$fixture.'/state-link');
$bad = startSessionServer($fixture.'/state-link','bad-state');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'symlink state root fails closed');stopSessionServer('bad-state');
$bad = startSessionServer($fixture.'/state-link/nested','bad-ancestor');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503 && !file_exists($fixture.'/shared/nested'), 'symlink state ancestor rejected before creating nested directory');stopSessionServer('bad-ancestor');
$publicState=dirname(__DIR__).'/public/session-state-'.bin2hex(random_bytes(6));
$bad = startSessionServer($publicState,'bad-public');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503 && !file_exists($publicState), 'public webroot state path rejected before creating any directory');stopSessionServer('bad-public');
$bad = startSessionServer('relative-session-state','bad-relative');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'relative state path fails closed');stopSessionServer('bad-relative');
file_put_contents($fixture.'/file-state','not-a-directory');
$bad = startSessionServer($fixture.'/file-state','bad-file');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'unusable state path returns safe HTTP 503');stopSessionServer('bad-file');
mkdir($fixture.'/file-session',0700);file_put_contents($fixture.'/file-session/sessions','not-a-directory');
$bad = startSessionServer($fixture.'/file-session','bad-session-file');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'regular file in place of sessions directory returns safe HTTP 503');stopSessionServer('bad-session-file');
echo "Session persistence contract complete\n";
} finally {
foreach (array_keys($servers) as $label) stopSessionServer($label);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
function targetProblem(callable $action,int $status,string $message): void {
try {$action();throw new RuntimeException($message.': accepted');}
catch(PrelandAdmin\Problem $e){check($e->status===$status,$message);}
}
$legacy=$store->read('targets.json');
unset($legacy['test']['hidden']);$store->write('targets.json',$legacy);
check($store->targets()[0]['hidden']===false,'legacy target is visible by default');
check($store->target('test')===$legacy['test'],'legacy raw target identity remains unchanged');
foreach(['b','c','d'] as $id)$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$root.'/site']);
$store->setTargetVisibility('b',true);
$before=$store->target('b');
check($before['hidden']===true,'visibility persists independently of transport');
$unchanged=$legacy['test'];$store->setTargetVisibility('test',true);$afterVisibility=$store->target('test');unset($afterVisibility['hidden']);
check($afterVisibility===$unchanged,'visibility changes no credentials enabled verified or transport fields');
$store->setTargetVisibility('test',false);
$store->saveTarget(['id'=>'b','hidden'=>false]);
check($store->target('b')['hidden']===true,'editing connection cannot override hidden state');
$store->saveTarget(['id'=>'new','domain'=>'new.invalid','type'=>'local','root'=>$root.'/site','hidden'=>true]);
check($store->target('new')['hidden']===false,'new connection is visible and appended');
$store->orderTargets(['d','test','new','c']);
check(array_column((new PrelandAdmin\Store($root.'/state'))->targets(),'id')===['d','b','test','new','c'],'reorder persists while preserving hidden slot');
$store->setTargetVisibility('b',false);
check(array_column($store->targets(),'id')===['d','b','test','new','c'],'restore returns target to preserved slot');
$unchanged=$store->read('targets.json');
foreach([['d','b','test','new'],['d','b','test','new','new'],['d','b','test','new','missing']] as $ids)targetProblem(fn()=>$store->orderTargets($ids),409,'stale incomplete duplicate or unknown order is rejected');
check($store->read('targets.json')===$unchanged,'rejected reorder does not alter persisted state');
targetProblem(fn()=>$store->orderTargets(['d',1,'test','new','c']),422,'order requires string IDs');
targetProblem(fn()=>$store->setTargetVisibility('missing',true),404,'visibility missing target returns 404');
targetProblem(fn()=>$store->deleteTarget('missing'),404,'delete missing target returns 404');
targetProblem(fn()=>$store->deleteTarget('b'),409,'visible target cannot be deleted');
$store->setTargetVisibility('b',true);
$job=$jobs->create('check',['targets'=>['b'],'number'=>1]);
targetProblem(fn()=>$store->deleteTarget('b'),409,'queued target cannot be deleted');
$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='running';$store->write('jobs/'.$job['id'].'.json',$rawJob);
targetProblem(fn()=>$store->deleteTarget('b'),409,'running target cannot be deleted');
check($store->target('b')['root']===$before['root'],'hiding and rejected deletion retain usable target transport');
$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob);
$job=$jobs->create('copy',['source'=>['id'=>'b'],'targets'=>[['id'=>'test']]]);
targetProblem(fn()=>$store->deleteTarget('b'),409,'queued copy source cannot be deleted');
$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob);
$store->deleteTarget('b');
check(array_column($store->targets(),'id')===['d','test','new','c'],'hidden idle target deletes only its connection');
check(is_file($root.'/site/config.json'),'connection deletion keeps site files');
foreach(['d','test','new','c'] as $id)$store->setTargetVisibility($id,true);
$store->orderTargets([]);
check(array_column($store->targets(),'id')===['d','test','new','c'],'empty visible order preserves all hidden connections');
echo "Targets Store contract complete\n";
+97
View File
@@ -0,0 +1,97 @@
<?php
/** #22: сервер разрывает SSH exec длиннее 8 KiB; helper и payload не теряются. */
declare(strict_types=1);
namespace phpseclib3\Net {
class SSH2 {
public static array $commands=[];
public static bool $withoutZlib=false;
public static bool $dropInbox=false;
public function __construct(...$args) {}
public function getServerPublicHostKey(): string {return 'ssh-ed25519 '.base64_encode('fixture-key');}
public function login(...$args): bool {return true;}
public function setTimeout($seconds): void {}
public function isTimeout(): bool {return false;}
public function exec(string $command): string|false {
self::$commands[]=$command;
if(strlen($command)>8192)return false; // фактическая граница REG.RU, старая реализация падает
if(self::$dropInbox&&str_contains($command,'/inbox-'))return false;
if(self::$withoutZlib)$command=preg_replace('/^php /','php -d disable_functions=gzinflate ',$command);
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
fclose($pipes[0]);$output=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);proc_close($process);
if($error!=='')throw new \RuntimeException($error);
return $output;
}
}
class SFTP extends SSH2 {
public static array $uploads=[];
public function put(string $path,string $bytes): bool {self::$uploads[]=$path;return file_put_contents($path,$bytes)!==false;}
public function chmod(int $mode,string $path): bool {return chmod($path,$mode);}
public function delete(string $path): bool {return !file_exists($path)||unlink($path);}
}
}
namespace {
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Store,Transport,Problem};
use phpseclib3\Net\{SSH2,SFTP};
function ensure(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo 'PASS: '.$message."\n";}
$fixture=sys_get_temp_dir().'/preland22-command-'.bin2hex(random_bytes(6));mkdir($fixture);mkdir($fixture.'/site');
$site=$fixture.'/site';file_put_contents($site.'/config.json','{"host":"initial.example","utm":{}}');
$store=new Store($fixture.'/state');
$target=['id'=>'limit','type'=>'sftp','host'=>'fixture','port'=>22,'user'=>'fixture','secret'=>'fixture','host_key'=>'ssh-ed25519 '.base64_encode('fixture-key'),'root'=>$site];
$transport=new Transport($store,$target);
try {
$transport->test();$before=$transport->readConfig();
ensure($before['config']->host==='initial.example','compressed helper reads under SSH 8KiB limit');
$transport->writeConfig($before['version'],'{"host":"small.example","utm":{}}');
ensure($transport->readConfig()['config']->host==='small.example','compressed RPC performs CAS write/read');
$large=json_encode(['host'=>'large.example','utm'=>(object)[],'padding'=>str_repeat('abcdefgh',3000)],JSON_THROW_ON_ERROR);
$before=$transport->readConfig();$transport->writeConfig($before['version'],$large);
ensure($transport->readConfig()['content']===$large,'large payload uses inbox and preserves bytes');
ensure(count(SFTP::$uploads)===1,'large payload uploaded through SFTP exactly once');
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'SFTP inbox removed after RPC');
$maximum=max(array_map('strlen',SSH2::$commands));
ensure($maximum<=7000,'all final shell commands stay within 7000 bytes (maximum '.$maximum.')');
$commandCount=count(SSH2::$commands);$uploads=count(SFTP::$uploads);
// Inline test может оказаться длиннее inbox runner из-за base64 длинного
// root, даже когда сам inbox помещается: этот preflight нельзя рекурсировать.
$longRootTarget=$target;$longRootTarget['root']='/'.str_repeat('a',1800);
try{(new Transport($store,$longRootTarget))->test();throw new RuntimeException('oversized test recursed');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'SSH bootstrap'),'oversized test RPC rejected before inbox/preflight recursion');}
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized test performs no exec/upload');
$bootstrap=new ReflectionMethod(Transport::class,'inboxCommand');
try{$bootstrap->invoke(null,'/*'.base64_encode(random_bytes(16000)).'*/',$site.'/inbox-test');throw new RuntimeException('oversized bootstrap accepted');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'bootstrap'),'oversized helper rejected before recursive preflight');}
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized bootstrap performs no remote call/upload');
$before=$transport->readConfig();SSH2::$dropInbox=true;
try{$transport->writeConfig($before['version'],$large);throw new RuntimeException('dropped exec accepted');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'Неизвестный результат'),'dropped inbox exec remains unknown result');}
SSH2::$dropInbox=false;
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'inbox cleaned even when exec is interrupted');
SSH2::$withoutZlib=true;
try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');}
SSH2::$withoutZlib=false;
// Настоящие 90 изображений: два слоя base64 дают аргумент заметно больше PHP shell-limit 2 MiB.
$sourceSet=dirname(__DIR__,2).'/1';$files=[];
foreach(array_keys(\PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $filename){$bytes=file_get_contents($sourceSet.'/'.$filename);$files[$filename]=['data'=>base64_encode($bytes),'sha256'=>hash('sha256',$bytes)];}
$hugePayload=['op'=>'set-write','number'=>3,'files'=>$files,'expected_manifest'=>null];
ensure(count($files)===90&&strlen(base64_encode(json_encode($hugePayload,JSON_THROW_ON_ERROR)))>2097152,'real 90-image set-write payload exceeds native 2 MiB shell argument limit');
$uploadsBeforeHuge=count(SFTP::$uploads);$configBeforeHuge=$transport->readConfig();
$transport->call($hugePayload);
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===\PrelandAdmin\Remote::manifest($sourceSet),'multi-MiB set-write activates all 90 real files with exact hashes');
ensure(count(SFTP::$uploads)===$uploadsBeforeHuge+1,'multi-MiB set-write uses exactly one SFTP inbox');
$hugeInbox=SFTP::$uploads[array_key_last(SFTP::$uploads)];
ensure(!file_exists($hugeInbox),'multi-MiB set-write inbox removed after actual RPC');
ensure(max(array_map('strlen',SSH2::$commands))<=7000,'multi-MiB set-write final exec stays below bootstrap limit');
ensure($transport->readConfig()['version']===$configBeforeHuge['version'],'image upload preserves config and the 2 MiB config contract');
// Обрыв до исполнения большой команды не должен трогать опубликованный набор; inbox удаляется.
$hugePayload['expected_manifest']=\PrelandAdmin\Remote::manifest($site.'/3');$keptManifest=$hugePayload['expected_manifest'];SSH2::$dropInbox=true;
try{$transport->call($hugePayload);throw new RuntimeException('dropped multi-MiB set-write accepted');}
catch(Problem $error){ensure($error->status===504,'interrupted multi-MiB set-write remains unknown, not false success');}
finally{SSH2::$dropInbox=false;}
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===$keptManifest,'interrupted multi-MiB exec preserves previously active set');
ensure(!file_exists(SFTP::$uploads[array_key_last(SFTP::$uploads)]),'interrupted multi-MiB set-write cleans private inbox');
} finally {
$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture);
}
}
+46
View File
@@ -0,0 +1,46 @@
/* Approved overview + independent link builder, preland#22/issue3767.
* Automated Docker QA; production CUA verification is owned by the lead.
* No mocked routes; constructor must never write config or create jobs. */
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
const errors=[],mutations=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
page.on('request',r=>{if(r.method()!=='GET'&&/\/api\/(apply|restore|sets\/|jobs\/retry)/.test(r.url()))mutations.push(r.url());});
try{
await page.goto(process.env.ADMIN_URL||'http://admin');
assert.match(await page.locator('script[src*="admin.js"]').getAttribute('src'),/^\/assets\/admin[.]js[?]v=[a-f0-9]{12}$/);
assert.match(await page.locator('link[href*="admin.css"]').getAttribute('href'),/^\/assets\/admin[.]css[?]v=[a-f0-9]{12}$/);
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();
await page.locator('#workspace').waitFor({state:'visible'});
assert.equal(await page.locator('#workspace > section').first().getAttribute('id'),'overview','saved runtime overview is first section');
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),9);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0);
const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
const before=await Promise.all(['a','b'].map(read));
await page.locator('#builder-target').selectOption('a');
await page.locator('#builder-fields select[data-marker=theme]').waitFor();
const generate=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/link-builder')&&r.request().method()==='POST');await page.getByRole('button',{name:'Сформировать и сохранить',exact:true}).click();const result=await(await response).json();assert.ok(result.ok,result.error);await page.waitForFunction(url=>document.querySelector('#builder-result').hidden===false&&document.querySelector('#builder-url').value===url,result.url);return result.url;};
const expectedTheme='rnd';await page.locator('#builder-fields select[data-marker=theme]').selectOption(expectedTheme);
const first=await generate();assert.equal(new URL(first).searchParams.get('theme')[4],'0');
const selectionA=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(selectionA.selection.theme,'rnd');assert.equal(selectionA.url,first);
const nextReg=selectionA.selection.reg==='rnd'?'1':'rnd';await page.locator('#builder-fields select[data-marker=reg]').selectOption(nextReg);
assert.equal(await page.locator('#builder-result').isVisible(),false,'old saved URL hidden for unsaved constructor changes');
const updated=await generate();const saved=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(saved.selection.reg,nextReg);for(const marker of Object.keys(selectionA.selection))if(marker!=='reg')assert.equal(saved.selection[marker],selectionA.selection[marker]);assert.notEqual(updated,first);
await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-only.example.org');
await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const version=await page.locator('#read-state').innerText();
await page.getByRole('button',{name:'Обновить настройки',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);
assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/);
assert.equal((await read('a')).config.host,before[0].config.host,'overview/constructor did not save editor draft');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-desktop.png',fullPage:true});
await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await page.locator('#builder-target').selectOption('a');await page.locator('#builder-url').waitFor({state:'visible'});assert.equal(await page.locator('#builder-url').inputValue(),updated);assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),'rnd');assert.equal(await page.locator('#builder-fields select[data-marker=reg]').inputValue(),nextReg);
await page.locator('#builder-target').selectOption('b');await page.locator('#builder-fields select[data-marker=theme]').waitFor();const persistedB=await page.request.get('/api/link-builder?target=b').then(r=>r.json());if(persistedB.selection)assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),persistedB.selection.theme);
const after=await Promise.all(['a','b'].map(read));for(let i=0;i<2;i++){assert.equal(after[i].version,before[i].version);assert.deepEqual(after[i].config,before[i].config);}
assert.deepEqual(mutations,[],'no config/job/sets mutations from overview or constructor');assert.deepEqual(errors,[]);
await page.setViewportSize({width:390,height:844});await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/overview-builder-evidence.json',JSON.stringify({configVersions:after.map(r=>r.version),selection:saved.selection,url:saved.url,mutations,pageErrors:errors},null,2));
console.log('PASS real overview / independent constructor / saved selection URL reload / no config writes / editor isolation / desktop mobile');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exit(1);});
+8
View File
@@ -0,0 +1,8 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#30: native manual config editing; Browser plugin not available. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b','d'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(7000);const cell=(id,m)=>page.locator('[data-matrix-target='+id+'][data-marker='+m+']'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const settled=async id=>page.waitForFunction(id=>/^(Прочитано с хостинга|Записано на хостинг)$/.test(document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent),id,{timeout:60000});const add=async(id,m,n)=>{await cell(id,m).getByRole('button',{name:'Добавить вариант '+m+' '+id,exact:true}).click();const number=cell(id,m).getByLabel('Новый вариант '+m+' '+id,{exact:true}).last();assert.equal(await number.inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' вес новый '+id,{exact:true}).last().inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' default новый '+id,{exact:true}).last().isChecked(),false);assert.equal(await save(id).isDisabled(),true);await number.fill('0');assert.equal(await save(id).isDisabled(),true);await number.fill(n);await cell(id,m).getByLabel(m+' вес '+n+' '+id,{exact:true}).last().fill('0');};try{const semantic=read('a');semantic.utm.theme={default:'dark',weights:{dark:100,light:0}};semantic.utm.focus={default:'1',weights:{'2':100}};fs.writeFileSync(root+'/a/config.json',JSON.stringify(semantic));const d=read('d');delete d.utm.cons;fs.writeFileSync(root+'/d/config.json',JSON.stringify(d));await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await settled(id);
for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus']){await add('a',m,'9');assert.equal(await cell('a',m).getByLabel(m+' вес 9 a',{exact:true}).inputValue(),'0');assert.equal(await cell('a',m).getByLabel(m+' default 9 a',{exact:true}).isChecked(),false);}await save('a').click();await settled('a');for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus'])assert.equal(read('a').utm[m].weights['9'],0,'new zero variant must persist '+m);
assert.equal(Object.hasOwn(read('a').utm.focus.weights,'1'),false,'synthetic missing default row must not be materialized by a manual zero addition');await add('a','theme','9');assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/уникальны/);await cell('a','theme').getByLabel('Новый вариант theme a',{exact:true}).last().fill('3');await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).check();await save('a').click();await settled('a');assert.equal(read('a').utm.theme.default,'3');assert.equal(read('a').utm.theme.weights['3'],0);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await settled('a');assert.equal(await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).isChecked(),true);
await add('d','cons','3');assert.equal(await save('d').isDisabled(),true,'new group requires explicit default');await cell('d','cons').getByLabel('cons default 3 d',{exact:true}).check();await save('d').click();await settled('d');assert.deepEqual(read('d').utm.cons,{default:'3',weights:{'3':0}});
await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,null,{timeout:60000});assert.equal(read('b').utm.theme.default,'3');assert.equal(read('b').utm.theme.weights['3'],0);assert.equal(read('b').utm.focus.weights['9'],0);await add('a','theme','8');await cancel('a').click();assert.equal(await cell('a','theme').getByLabel('theme вес 8 a',{exact:true}).count(),0);await page.locator('#builder-target').selectOption('a');await page.getByLabel('Ссылка: theme',{exact:true}).selectOption('3');await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.equal((await page.request.get('/api/link-builder?target=a').then(r=>r.json())).selection.theme,'3');assert.doesNotMatch(await page.locator('#builder-warnings').innerText(),/каталог/);await page.locator('#overview').screenshot({path:out+'/manual-variants-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/manual-variants-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS all8 manual variants, zero Save/reread, theme3/default/builder, duplicate recovery, missing group/default, cancel, mobile, no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
+41
View File
@@ -0,0 +1,41 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#25: real UI, actual CAS/jobs and isolated fixture. Browser plugin not available. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);let csrf='';
const host=id=>page.locator('[data-matrix-target="'+id+'"] input[data-host]'),cell=(id,m)=>page.locator('[data-matrix-target="'+id+'"][data-marker="'+m+'"]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),header=id=>page.locator('[data-overview-target="'+id+'"]');
const request=async(route,data)=>{const response=data===undefined?await page.request.get('/api/'+route):await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}});const result=await response.json();assert.ok(result.ok,result.error);return result;};
const get=id=>request('config?target='+id);const readFile=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const writeFile=(id,c)=>fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));
const saved=async id=>{await page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent.match(/^(Прочитано с хостинга|Записано на хостинг)$/),id,{timeout:60000});};
const refresh=async()=>{await page.locator('#overview-refresh').click();await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);};
try{
const d=readFile('d');delete d.utm.cons;writeFile('d',d);
await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await saved(id);csrf=(await request('state')).csrf;
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.matrix-read-at').count(),4);assert.equal(await page.locator('#overview').getByText(/requisites|chb/).count(),0);
const weights=cell('a','theme').locator('input[type=number]');assert.equal(await page.locator('.matrix-percent').count(),0);assert.equal(await weights.first().getAttribute('min'),'0');assert.equal(await weights.first().getAttribute('max'),'100');assert.equal(await weights.first().getAttribute('step'),'0.01');assert.match(await cell('a','theme').locator('.matrix-labels').innerText(),/Вес, %/);
const defaultBefore=readFile('a').utm.theme.default;await weights.nth(0).fill('33.33');assert.equal(await weights.nth(1).inputValue(),'75','typing never redistributes other weights');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/сумма/);await weights.nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 100%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':33.33,'2':66.67});assert.equal(readFile('a').utm.theme.default,defaultBefore);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await weights.first().inputValue(),'33.33');
await weights.first().fill('33.333');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('0.00000000001');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('-1');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('101');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/от 0 до 100/);await weights.first().fill('');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('0');await weights.nth(1).fill('0');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 0%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':0,'2':0});assert.equal(readFile('a').utm.theme.default,defaultBefore);await weights.first().fill('25');await weights.nth(1).fill('75');await save('a').click();await saved('a');
assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);
assert.equal(await cell('d','cons').locator('.matrix-variant').count(),0);assert.equal(await cell('d','cons').locator('.matrix-add-variant').count(),1);assert.equal(readFile('d').utm.cons,undefined);
const beforeDefault=readFile('a');await cell('a','theme').locator('input[type=radio]').first().check();await save('a').click();await saved('a');assert.equal(await header('a').locator('.matrix-status').innerText(),'Записано на хостинг');assert.deepEqual(readFile('a').utm.theme.weights,beforeDefault.utm.theme.weights);assert.equal(readFile('a').utm.theme.default,'1');assert.equal(typeof readFile('a').utm.theme.default,typeof beforeDefault.utm.theme.default);await cell('a','theme').locator('input[type=radio]').nth(1).check();await save('a').click();await saved('a');assert.deepEqual(readFile('a'),beforeDefault);
console.log('PASS edit/default/missing'); // Two independent drafts and note cursor remain through foreign-column save and read.
await host('a').fill('draft-a.invalid');const handle=await host('a').elementHandle();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});await cell('b','theme').locator('input[type=number]').first().fill('70');await cell('b','theme').locator('input[type=number]').nth(1).fill('30');await save('b').click();await saved('b');assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);assert.equal(readFile('b').utm.theme.weights.dark,70);assert.equal(readFile('b').utm.theme.default,'light');assert.deepEqual(readFile('b').custom,JSON.parse(original.get('b')).custom);assert.deepEqual(readFile('b').requisites,JSON.parse(original.get('b')).requisites);await refresh();assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);await cancel('a').click();
console.log('PASS foreign save'); // A late force GET must not erase input typed after reload starts.
let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);let held=false;const delayed=async route=>{if(!held){held=true;const response=await route.fetch();started();await gate;await route.fulfill({response});}else await route.continue();};await page.route('**/api/config?target=a',delayed);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click({noWaitAfter:true});await seen;await host('a').fill('after-get.invalid');release();await page.waitForTimeout(200);assert.equal(await host('a').inputValue(),'after-get.invalid');await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.doesNotMatch(await page.locator('#copy-reason').innerText(),/после ошибки чтения/,'successful late read must finish its loading snapshot');await page.locator('#copy-none').click();await page.unroute('**/api/config?target=a',delayed);await cancel('a').click();
console.log('PASS late get'); // Real remote version conflict, explicit reload and offline recovery preserve drafts.
await host('a').fill('my-draft.invalid');const external=readFile('a');external.custom.external='concurrent';writeFile('a',external);await save('a').click();await header('a').getByText(/измен|верс|конфликт/i).waitFor();assert.equal(await host('a').inputValue(),'my-draft.invalid');await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await host('a').inputValue(),external.host);
await cell('b','theme').locator('input[type=number]').first().fill('50');await cell('b','theme').locator('input[type=number]').nth(1).fill('50');await context.setOffline(true);await save('b').click();await header('b').getByText(/Связь/).waitFor();assert.equal(await cell('b','theme').locator('input[type=number]').first().inputValue(),'50');await context.setOffline(false);await save('b').click();await saved('b');assert.equal(readFile('b').utm.theme.weights.dark,50);
console.log('PASS conflict/offline'); // Source and recipient drafts block copying. Copy selected recipients only, semantic aliases preserved.
await choosePreset(page,'a');assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),1);await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.getByLabel('Копировать в c.invalid',{exact:true}).check();await host('a').fill('source-draft.invalid');assert.equal(await page.locator('#copy-submit').isEnabled(),true,'independent preset ignores original domain drafts');await cancel('a').click();await host('b').fill('target-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/получателей/);await cancel('b').click();
const beforeD=fs.readFileSync(root+'/d/config.json','utf8'),beforeB=readFile('b'),source=(await get('a')).config;await page.locator('#copy-submit').click();assert.equal(await page.locator('#copy-all').isDisabled(),true);assert.equal(await page.locator('#copy-none').isDisabled(),true);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await saved('b');await saved('c');assert.equal(await header('b').locator('.matrix-status').innerText(),'Записано на хостинг');assert.equal(await header('c').locator('.matrix-status').innerText(),'Записано на хостинг');const copiedB=readFile('b');assert.deepEqual(copiedB.utm.theme.weights,{dark:source.utm.theme.weights['1'],light:source.utm.theme.weights['2']});assert.deepEqual(copiedB.requisites,beforeB.requisites);assert.deepEqual(copiedB.custom,beforeB.custom);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),beforeD);
// Real target CAS partial failure and source CAS preflight failure.
const staleC=readFile('c');staleC.custom.concurrent='target';writeFile('c',staleC);const partialResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();const partial=(await(await partialResponse).json()).job;await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});const partialJob=(await request('jobs?id='+partial.id)).job;assert.deepEqual(partialJob.results.map(r=>r.status),['success','conflict']);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.equal(await page.locator('#copy-results .status-conflict').count(),1);assert.equal(readFile('c').custom.concurrent,'target');await header('c').getByRole('button',{name:'Обновить настройки c',exact:true}).click();await saved('c');const staleSource=readFile('a');staleSource.custom.concurrent='source';writeFile('a',staleSource);const sourceResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();assert.equal((await sourceResponse).status(),202);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');console.log('PASS actual partial copy and source version conflict'); await page.locator('#copy-none').click();assert.equal(await page.locator('#copy-targets input:checked').count(),0);await page.locator('#copy-all').click();assert.equal(await page.locator('#copy-targets input:checked').count(),4);await page.locator('#copy-none').click();
console.log('PASS copy'); // Operations keep their own selection independent from copy UI.
await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();await choosePreset(page,'c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['a','b']);await page.locator('#source-target').selectOption('c');assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['c']);await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();
const unchangedBeforeZip=(await get('a')).version;await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Invalid ZIP #25');await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not ZIP')});const zipResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));await page.locator('#set-add button').click();const invalidZip=await(await zipResponse).json();if(invalidZip.ok){await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.locator('#result-body').getByText(/Ошибка/).waitFor({timeout:60000});await page.getByRole('button',{name:'Готово',exact:true}).click();}else await page.locator('#notice.alert-danger').waitFor();assert.equal((await get('a')).version,unchangedBeforeZip);console.log('PASS actual invalid ZIP preserves runtime'); await page.locator('#builder-target').selectOption('a');await page.locator('#builder-fields select').first().waitFor();await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.match(await page.locator('#builder-url').inputValue(),/^https:\/\/a.invalid/);
// Notes preserve the same textarea node, caret and draft during matrix refresh.
const note=page.locator('[data-utm-note=reg]');await note.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const textarea=note.getByLabel('Заметка reg',{exact:true});await textarea.fill('Заметка #25\nПроверка курсора');const noteHandle=await textarea.elementHandle();await textarea.evaluate(n=>{n.focus();n.setSelectionRange(3,7);document.querySelector('#overview-refresh').click();});await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);assert.deepEqual(await noteHandle.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:3,end:7});await note.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
await page.locator('#overview').screenshot({path:out+'/matrix-desktop.png'});await page.locator('#copy-panel').screenshot({path:out+'/copy-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/matrix-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#copy-panel').screenshot({path:out+'/copy-mobile.png'});assert.deepEqual(errors,[]);console.log('PASS inline weights/default/static numbers/absent/save/cancel; multi drafts/late GET/CAS/offline; saved copy selected-only/aliases/unknown preservation; independent operations/builder/notes/mobile/no JS errors');
}finally{await context.setOffline(false);await browser.close();for(const [id,content]of original)fs.writeFileSync(root+'/'+id+'/config.json',content);}
})().catch(e=>{console.error(e);process.exit(1);});
+44
View File
@@ -0,0 +1,44 @@
/* preland#23: real compact overview and CAS notes. Runtime configs/jobs stay unchanged. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());const errors=[],consoleMessages=[];page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});});
const output=process.env.UI_OUTPUT||'/tmp';let initialNote=null,csrf='',primaryError=null;
const request=async(route,data)=>{const response=data===undefined?await page.request.get('/api/'+route):await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}});const result=await response.json();assert.ok(result.ok,result.error);return result;};
const row=()=>page.locator('tr[data-utm-note=reg]'),textarea=()=>row().getByLabel('Заметка reg',{exact:true});
const enterEdit=async()=>{await row().getByRole('button',{name:/(?:Добавить|Редактировать) заметку reg/}).click();await textarea().waitFor();};
const withDelayedNotesGet=async operation=>{
let release,ready;const gate=new Promise(resolve=>release=resolve),captured=new Promise(resolve=>ready=resolve);let held=false;
const handler=async route=>{if(route.request().method()==='GET'&&!held){held=true;const response=await route.fetch();ready();await gate;await route.fulfill({response});}else await route.continue();};
await page.route('**/api/utm-notes',handler);
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([captured,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET notes was not captured')),5000))]);await operation();release();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
finally{release();await page.unroute('**/api/utm-notes',handler);}
};
const postStartThenOldGet=async()=>{
let releasePost,releaseGet,postReady,getReady;const postGate=new Promise(resolve=>releasePost=resolve),getGate=new Promise(resolve=>releaseGet=resolve),postSeen=new Promise(resolve=>postReady=resolve),getSeen=new Promise(resolve=>getReady=resolve);let heldPost=false,heldGet=false;
const handler=async route=>{if(route.request().method()==='POST'&&!heldPost){heldPost=true;postReady();await postGate;await route.continue();}else if(route.request().method()==='GET'&&!heldGet){heldGet=true;const response=await route.fetch();getReady();await getGate;await route.fulfill({response});}else await route.continue();};
await page.route('**/api/utm-notes',handler);
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);const posted=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await Promise.race([postSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('POST notes was not captured')),5000))]);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([getSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET during POST was not captured')),5000))]);releasePost();const response=await(await posted).json();assert.ok(response.ok,response.error);await textarea().waitFor({state:'hidden'});releaseGet();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
finally{releasePost();releaseGet();await page.unroute('**/api/utm-notes',handler);}
};
const save=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();const body=await(await response).json();assert.ok(body.ok,body.error);await textarea().waitFor({state:'hidden'});return body.note;};
try{
await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-total').first().waitFor({timeout:6000});
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort();
const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
const alias={reg:['registration'],banch:['chob','bchoice']};
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>String(w));assert.deepEqual(await cell.locator('input[type=number]').evaluateAll(ns=>ns.map(n=>n.value)),expected);assert.equal(await cell.locator('.matrix-percent').count(),0);assert.equal(await cell.locator('.matrix-total').innerText(),'Сумма: '+sum+'%');assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
await enterEdit();const draft='Заметка UI — '+Date.now()+'\n<img src=x onerror=alert(1)>\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11});
await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor();
const newer=draft+'\nСохранено после начала старого GET';
await withDelayedNotesGet(async()=>{await enterEdit();await textarea().fill(newer);await save();});
await row().getByText(newer,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,newer,'late GET cannot roll back successful Save');
const postFirst=newer+'\nPOST начат раньше GET';await enterEdit();await textarea().fill(postFirst);await postStartThenOldGet();await row().getByText(postFirst,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,postFirst,'GET started during Save cannot roll back committed result');
await enterEdit();const conflictDraft=postFirst+'\nМой конфликтный черновик';await textarea().fill(conflictDraft);const current=(await request('utm-notes')).notes.reg;const external='Внешняя заметка '+Date.now();await request('utm-notes',{marker:'reg',text:external,version:current.version});
const conflictResponse=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();assert.equal((await conflictResponse).status(),409);assert.equal(await textarea().inputValue(),conflictDraft);await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,external);await withDelayedNotesGet(async()=>{const latest=(await request('utm-notes')).notes.reg;const finalExternal=external+' — новая версия';await request('utm-notes',{marker:'reg',text:finalExternal,version:latest.version});await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).click();await page.waitForFunction(text=>document.querySelector('textarea[aria-label="Заметка reg"]').value===text,finalExternal);});assert.equal(await textarea().inputValue(),external+' — новая версия','late GET cannot roll back explicit server reload');
const networkDraft=external+'\nВосстановление связи';await textarea().fill(networkDraft);await context.setOffline(true);await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await row().locator('[role=status]').getByText(/Связь|соединени|ошибк/i).waitFor();assert.equal(await textarea().inputValue(),networkDraft);await context.setOffline(false);await save();assert.equal((await request('utm-notes')).notes.reg.text,networkDraft);
await page.screenshot({path:output+'/notes-desktop.png',fullPage:true});await page.setViewportSize({width:390,height:844});await enterEdit();await textarea().fill('Мобильный черновик\nНесколько строк');await page.screenshot({path:output+'/notes-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.equal(await textarea().evaluate(n=>getComputedStyle(n).resize),'vertical');await row().getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
for(const before of snapshots){const after=await page.request.get('/api/config?target='+before.id).then(r=>r.json());if(before.ok){assert.ok(after.ok,after.error);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);}}assert.deepEqual((await request('jobs')).jobs.map(j=>j.id).sort(),jobsBefore);assert.deepEqual(errors,[]);fs.writeFileSync(output+'/notes-evidence.json',JSON.stringify({configVersions:snapshots.filter(r=>r.ok).map(r=>({id:r.id,version:r.version})),jobsUnchanged:true,pageErrors:errors,console:consoleMessages},null,2));console.log('PASS actual compact percentages / notes input-save-reload / cursor survives refresh / CAS conflict explicit reload / offline recovery / desktop mobile / runtime jobs unchanged');
}catch(error){primaryError=error;}finally{const cleanup=[];try{await context.setOffline(false);if(initialNote){if(!(await textarea().isVisible()))await enterEdit();await textarea().fill(initialNote.text);const latest=(await request('utm-notes')).notes.reg;await request('utm-notes',{marker:'reg',text:initialNote.text,version:latest.version});}}catch(error){cleanup.push(error);}try{await browser.close();}catch(error){cleanup.push(error);}if(primaryError&&cleanup.length)throw new AggregateError([primaryError,...cleanup],'notes scenario failed; separate cleanup errors',{cause:primaryError});if(primaryError)throw primaryError;if(cleanup.length)throw new AggregateError(cleanup,'notes cleanup failed');}
})().catch(error=>{console.error(error);process.exit(1);});
+7
View File
@@ -0,0 +1,7 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#28: actual stored percentages, strict inline validation and saved Copy gates. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());const cell=id=>page.locator('[data-matrix-target='+id+'][data-marker=theme]'),weight=id=>cell(id).locator('input[type=number]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});const reload=async id=>{await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await page.waitForFunction(id=>!document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent.includes('Чтение'),id);};const invalid=id=>{const c=JSON.parse(original.get(id)),weights=c.utm.theme.weights;for(const key of Object.keys(weights))weights[key]=0;weights[Object.keys(weights)[0]]=1;fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true);
invalid('a');await reload('a');assert.equal(await weight('a').first().inputValue(),'1','GET must show stored 1%, without relative conversion');assert.equal(await cell('a').locator('.matrix-total').innerText(),'Сумма: 1%');assert.match(await cell('a').locator('.matrix-validation').innerText(),/100%.*0%/);assert.equal(await save('a').isDisabled(),true);assert.equal(await page.locator('#copy-submit').isEnabled(),true,'saved preset independent from changed origin');await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();assert.equal(await page.locator('#preset-dialog').isVisible(),false);assert.match(await page.locator('#notice').innerText(),/сумма/);await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-percent.invalid');assert.equal(await save('a').isDisabled(),true,'unrelated host edit cannot publish an invalid untouched weight group');await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();assert.match(await cell('a').locator('.matrix-validation').innerText(),/сумма/,'cancel retains invalid saved config warning');await page.locator('#overview').screenshot({path:out+'/percent-invalid-saved.png'});fs.writeFileSync(root+'/a/config.json',original.get('a'));await reload('a');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
invalid('b');await reload('b');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/Получатель:.*сумма/);fs.writeFileSync(root+'/b/config.json',original.get('b'));await reload('b');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
const before=JSON.parse(original.get('a'));await weight('a').first().click();await weight('a').first().press('ControlOrMeta+A');await weight('a').first().pressSequentially('33.33');assert.equal(await weight('a').nth(1).inputValue(),String(before.utm.theme.weights['2']));assert.equal(await save('a').isDisabled(),true);await weight('a').nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a').locator('input[type=radio]:checked').getAttribute('aria-label'),'theme default '+before.utm.theme.default+' a');await save('a').click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});await reload('a');const actual=JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),expected=structuredClone(before);expected.utm.theme.weights={'1':33.33,'2':66.67};assert.deepEqual(actual,expected);assert.equal(await weight('a').first().inputValue(),'33.33');assert.equal(await weight('a').nth(1).inputValue(),'66.67');await page.locator('#overview').screenshot({path:out+'/percent-saved-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.screenshot({path:out+'/percent-mobile.png',fullPage:false});assert.deepEqual(errors,[]);console.log('PASS raw stored1%/inline sum; saved invalid source+target Copy blocking/recovery; unrelated edits blocked; native typing 33.33+66.67 Save+reread/default+other fields preserved; desktop/mobile/no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
+6
View File
@@ -0,0 +1,6 @@
/* preland#29: actual rendered read lifecycle and immutable UTM identifiers. Browser plugin not available. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));const header=id=>page.locator('[data-overview-target="'+id+'"]'),reload=id=>page.getByRole('button',{name:'Обновить настройки '+id,exact:true});const idle=()=>page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));try{await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await idle();
assert.equal(await page.locator('[data-overview-utm] select').count(),0,'UTM numbers must be static');assert.equal(await page.locator('[data-overview-utm] .matrix-remove').count(),0,'UTM variants cannot be removed');assert.equal(await page.locator('[data-overview-utm] .matrix-add-variant').count(),32,'manual add on every UTM/domain');assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);const date=header('a').locator('.matrix-read-at');assert.match(await date.innerText(),/Последнее чтение: .*\d{2}:\d{2}:\d{2} МСК/);const before=await date.innerText(),foreign=await header('b').locator('.matrix-read-at').innerText();let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);const delay=async route=>{const response=await route.fetch();started();await gate;await route.fulfill({response});};await page.route('**/api/config?target=a',delay);await reload('a').click();await seen;assert.equal(await reload('a').isDisabled(),true);assert.match(await header('a').locator('.matrix-status').innerText(),/Чтение с хостинга/);assert.equal(await date.innerText(),before);await page.waitForTimeout(1100);release();await page.waitForFunction(()=>!document.querySelector('[data-overview-target=a] .matrix-status').textContent.includes('Чтение'));await page.unroute('**/api/config?target=a',delay);assert.equal(await reload('a').isEnabled(),true);assert.equal(await header('b').locator('.matrix-read-at').innerText(),foreign);const success=await date.innerText();
const fail=route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({ok:false,error:'Ошибка чтения fixture #29'})});await page.route('**/api/config?target=a',fail);await reload('a').click();await header('a').getByText(/Ошибка чтения fixture/).waitFor();assert.equal(await date.innerText(),success);assert.equal(await reload('a').isEnabled(),true);await page.unroute('**/api/config?target=a',fail);await page.waitForTimeout(1100);await reload('a').click();await idle();assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);assert.notEqual(await date.innerText(),success);
const host=page.getByLabel('Основной сайт a',{exact:true});await host.fill('draft-29.invalid');page.once('dialog',d=>d.dismiss());await reload('a').click();assert.equal(await host.inputValue(),'draft-29.invalid');page.once('dialog',d=>d.accept());await reload('a').click();await idle();assert.notEqual(await host.inputValue(),'draft-29.invalid');assert.deepEqual(errors,[]);await page.locator('#overview').screenshot({path:'/output/read-desktop.png'});await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:'/output/read-mobile.png'});console.log('PASS static UTM / actual GET loading+disabled/date / error retains date / retry / foreign date / dirty dismiss+confirm / desktop/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+3
View File
@@ -0,0 +1,3 @@
/* preland#25 consolidates partial copy/CAS/offline/ZIP-error regressions in the
* actual new matrix flow. This compatibility entry executes that full scenario. */
require('./ui-matrix.cjs');
+39
View File
@@ -0,0 +1,39 @@
/* preland#22/comment3770. Real local Docker HTTP + persistent Chromium profile.
* Cookie values/passwords are never written to evidence. */
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs');const os=require('node:os');const path=require('node:path');
(async()=>{
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'preland-remember-'));
const origin=process.env.ADMIN_URL||'http://admin',output=process.env.UI_OUTPUT||'/tmp';
const launch=()=>chromium.launchPersistentContext(profile,{executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox'],baseURL:origin,viewport:{width:1440,height:1000}});
let context=await launch();const errors=[];let page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
try{
await page.goto(origin);
const remember=page.getByLabel('Запомнить меня на 90 дней',{exact:true});
assert.equal(await remember.count(),1,'remember checkbox exists');assert.equal(await remember.isChecked(),false,'unchecked HTML default');
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await remember.check();
const loginRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await loginRequest).postDataJSON().remember,true,'explicit boolean true');
await page.locator('#workspace').waitFor({state:'visible'});
const cookies=await context.cookies(origin),now=Date.now()/1000;
const durable=cookies.find(c=>process.env.REMEMBER_COOKIE?c.name===process.env.REMEMBER_COOKIE:c.httpOnly&&c.expires>now+86400*60);
assert.ok(durable,'persistent HttpOnly remember cookie');assert.ok(Math.abs((durable.expires-now)-90*86400)<600,'expires approximately 90 days');assert.equal(durable.httpOnly,true);assert.equal(durable.sameSite,'Strict');
const target=process.env.TARGET_ID||'a';
const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read();
assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required');
await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0,'requisites overview hidden');
await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт '+target,{exact:true}).waitFor();
assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden');
assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields');
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0,'old generic editor removed');
const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config,'runtime unchanged');
await page.screenshot({path:output+'/remember-requisites-editor.png',fullPage:true});
await context.close();context=await launch();page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(origin);await page.locator('#workspace').waitFor({state:'visible'});assert.equal(await page.locator('#login-panel').isVisible(),false,'reopen authenticates without password');
await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByLabel('Запомнить меня на 90 дней',{exact:true}).uncheck();
const shortRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await shortRequest).postDataJSON().remember,false,'explicit boolean false');await page.locator('#workspace').waitFor({state:'visible'});
const shortCookies=await context.cookies(origin);assert.ok(!shortCookies.some(c=>c.name===durable.name&&c.expires>Date.now()/1000+86400),'unchecked login does not retain 90-day cookie');
assert.deepEqual(errors,[]);fs.writeFileSync(output+'/remember-evidence.json',JSON.stringify({cookie:{name:durable.name,expires:durable.expires,httpOnly:durable.httpOnly,sameSite:durable.sameSite,secure:durable.secure},configVersion:after.version,pageErrors:errors,reopenAuthenticated:true,uncheckedRememberFalse:true},null,2));
console.log('PASS actual remember90 checkbox/boolean/cookie/persistent reopen + session login + hidden requisites/config unchanged');
}finally{await context.close();}
})().catch(error=>{console.error(error);process.exit(1);});
+3
View File
@@ -0,0 +1,3 @@
/* preland#25: protection surface now uses only the inline matrix, unknown fields readonly. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+id).then(r=>r.json()),before=await read();await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('[data-matrix-extra^=requisites],[data-matrix-extra^="utm.chb"]').count(),0);if(before.config.utm.custom_visible){await page.locator('[data-matrix-extra="utm.custom_visible"]').waitFor();assert.equal(await page.locator('[data-matrix-extra="utm.custom_visible"] input').count(),0);}const after=await read();assert.deepEqual(after.config,before.config);assert.equal(after.version,before.version);console.log('PASS requisites/chb hidden / unknown fields readonly / unchanged runtime');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+11
View File
@@ -0,0 +1,11 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#25: real accepted jobs, delayed completed HTTP response across logout/login. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root='/fixture',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),context=await browser.newContext({baseURL:'http://host.docker.internal:18093'}),page=await context.newPage();page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);const errors=[];page.on('pageerror',e=>errors.push(e.message));
const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();};
const logout=async()=>{await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});};
const host=id=>page.getByLabel('Основной сайт '+id,{exact:true});
const holdJob=async()=>{let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);const handler=async route=>{if(held)return route.continue();held=true;const url=route.request().url();let body;for(let n=0;n<160;n++){body=await page.request.get(url).then(r=>r.json());assert.ok(body.ok,body.error);if(body.job.status==='completed')break;await new Promise(r=>setTimeout(r,100));}assert.equal(body.job.status,'completed');ready(body.job);await gate;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});};await page.route('**/api/jobs?id=*',handler);return {captured,release,stop:()=>page.unroute('**/api/jobs?id=*',handler)};};
try{await page.goto('/');await login();let held=await holdJob();const weight=page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').first();await weight.fill('40');await page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').nth(1).fill('60');await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await held.captured;await logout();await login();await host('a').fill('new-session-draft.invalid');const node=await host('a').elementHandle(),notice=await page.locator('#notice').innerText();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});held.release();await page.waitForTimeout(400);assert.equal(await host('a').inputValue(),'new-session-draft.invalid','old Save completion cannot reset a new session draft');assert.deepEqual(await node.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:2,end:5});assert.equal(await page.locator('#notice').innerText(),notice);await held.stop();await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();console.log('PASS old accepted Save completion ignored after logout/login');
held=await holdJob();await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await held.captured;await logout();await login();await host('b').fill('new-session-recipient-draft.invalid');await choosePreset(page,'c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true,'new login is not blocked by old copyBusy');const current=await holdJob();await page.locator('#copy-submit').click();await current.captured;const currentNotice=await page.locator('#notice').innerText();held.release();await page.waitForTimeout(400);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');assert.equal(await page.locator('#copy-source').isDisabled(),true,'old finally cannot unlock new copy');assert.equal(await page.locator('#copy-results').innerText(),'','old outcomes cannot replace pending new operation');assert.equal(await page.locator('#notice').innerText(),currentNotice);current.release();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.match(await page.locator('#copy-results').innerText(),/d.invalid/);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');await current.stop();await held.stop();assert.deepEqual(errors,[]);console.log('PASS old accepted Copy completion/finally ignored; new copyBusy/outcomes/notice/draft preserved');
}finally{await browser.close();for(const [id,text]of original)fs.writeFileSync(root+'/'+id+'/config.json',text);}})().catch(e=>{console.error(e);process.exit(1)});
+5
View File
@@ -0,0 +1,5 @@
/* preland#30: upload a new physical set, manually add its UTM, replace through row control. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',raw=fs.readFileSync(root+'/a/config.json','utf8'),stateFiles=['sets.json','checks.json'],snap=new Map(stateFiles.map(name=>[name,fs.existsSync(root+'/state/'+name)?fs.readFileSync(root+'/state/'+name):null]));assert.ok(!fs.existsSync(root+'/a/9'),'isolated fixture must have no set9');const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[],dialogs=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>{dialogs.push(d.message());d.accept();});page.setDefaultTimeout(15000);const config=()=>JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),done=async()=>{await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Новый сет 9');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add').screenshot({path:out+'/set-new-form.png'});await page.locator('#set-add button').click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),raw,'new ZIP does not change config');assert.equal(fs.readdirSync(root+'/a/9').length,90);const row=page.locator('#sets-body tr').filter({has:page.getByLabel('Название Set 9',{exact:true})});await row.waitFor();assert.equal(await row.getByLabel('Название Set 9',{exact:true}).inputValue(),'Новый сет 9');assert.equal(dialogs.length,0,'new set upload does not ask replacement');
const cell=page.locator('[data-matrix-target=a][data-marker=indpic]');assert.equal(await cell.getByLabel('indpic вес 9 a',{exact:true}).count(),0);await cell.getByRole('button',{name:'Добавить вариант indpic a',exact:true}).click();await cell.screenshot({path:out+'/variant-empty.png'});await cell.getByLabel('Новый вариант indpic a',{exact:true}).fill('9');await cell.getByLabel('indpic вес 9 a',{exact:true}).fill('0');const refreshedSets=page.waitForResponse(r=>r.url().endsWith('/api/sets?target=a'));await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});assert.equal(config().utm.indpic.weights['9'],0);const after=fs.readFileSync(root+'/a/config.json','utf8');await (await refreshedSets).finished();await row.getByLabel('Название Set 9',{exact:true}).waitFor();
await row.getByLabel('ZIP Set 9',{exact:true}).setInputFiles(process.env.VALID_ZIP);await row.getByRole('button',{name:'Загрузить ZIP',exact:true}).click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),after,'replace ZIP does not change config');assert.equal(dialogs.length,1);assert.match(dialogs[0],/Заменить существующий набор 9/);assert.equal(fs.readdirSync(root+'/a/9').length,90);await page.locator('#indpic').screenshot({path:out+'/set-new-replaced.png'});assert.deepEqual(errors,[]);console.log('PASS absent set9 upload90files/no configwrites/no replaceconfirm; manual indpic9 zero Save; existing set9 ZIP replace90files/confirm/no configwrites');}finally{await browser.close();fs.writeFileSync(root+'/a/config.json',raw);fs.rmSync(root+'/a/9',{recursive:true,force:true});for(const[name,content]of snap){if(content===null)fs.rmSync(root+'/state/'+name,{force:true});else fs.writeFileSync(root+'/state/'+name,content);}}})().catch(e=>{console.error(e);process.exit(1)});
+3
View File
@@ -0,0 +1,3 @@
/* preland#25: readonly smoke + real local draft/cancel. Writes are covered by ui-matrix.cjs. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=(await page.request.get('/api/state').then(r=>r.json())).targets[0].id,host=page.getByLabel('Основной сайт '+id,{exact:true});await host.waitFor();const original=await host.inputValue();await host.fill('draft-smoke.invalid');assert.match(await page.locator('[data-overview-target="'+id+'"] .matrix-status').innerText(),/Есть изменения/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await host.inputValue(),original);assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);assert.match(await page.locator('.matrix-read-at').first().innerText(),/МСК/);assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS login/nonblank/matrix input-cancel/ZIP table/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+22
View File
@@ -0,0 +1,22 @@
/* preland#26: late anonymous401 must preserve UI, notice and actual authenticated session. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
const options={baseURL:process.env.ADMIN_URL||'http://admin'};const page=await browser.newPage(options);let original=[];
const login=async p=>{await p.goto('/');await p.getByLabel('Логин',{exact:true}).fill('admin');await p.getByLabel('Пароль',{exact:true}).fill('test-password');await p.getByRole('button',{name:'Войти',exact:true}).click();await p.locator('#workspace').waitFor({state:'visible'});};
try{
let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);
await page.route('**/api/state',async route=>{if(held)return route.continue();held=true;const response=await route.fetch();assert.equal(response.status(),401);ready();await gate;await route.fulfill({response});});
const entered=login(page);await captured;await entered;
original=await page.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget));const id=original[0];
await page.locator('[data-overview-target="'+id+'"] .domain-grip').press('ArrowRight');await page.waitForFunction(()=>document.querySelector('#notice').textContent==='Порядок доменов сохранён.');
release();await page.waitForTimeout(500);
assert.equal(await page.locator('#workspace').isVisible(),true,'late anonymous401 must not destroy successful login');
assert.equal(await page.locator('#notice').innerText(),'Порядок доменов сохранён.','late response must not replace successful mutation notice');
const current=await page.request.get('/api/state');assert.equal(current.status(),200,'late anonymous401 must not overwrite authenticated session cookie');
console.log('PASS late anonymous401 preserves workspace/success notice/authenticated session');
}finally{
if(original.length){const cleanup=await browser.newPage(options);await login(cleanup);const state=await cleanup.request.get('/api/state').then(r=>r.json());const response=await cleanup.request.post('/api/targets/order',{data:{ids:original},headers:{'X-CSRF-Token':state.csrf}});assert.ok(response.ok(),'cleanup restores order');}
await browser.close();
}
})().catch(e=>{console.error(e);process.exit(1)});
+10
View File
@@ -0,0 +1,10 @@
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL,viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const before=await page.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget));assert.equal(await page.locator('.domain-hide').count(),before.length,'each domain has hide control');const first=before[0];await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowRight');await page.waitForFunction(id=>document.querySelectorAll('[data-overview-target]')[1]?.dataset.overviewTarget===id,first);await page.reload();await page.locator('[data-overview-target]').first().waitFor();assert.equal(await page.locator('[data-overview-target]').nth(1).getAttribute('data-overview-target'),first);await page.locator('[data-overview-target="'+first+'"] .domain-hide').click();await page.locator('[data-overview-target="'+first+'"]').waitFor({state:'detached'});assert.equal(await page.locator('#notice').innerText(),'Домен скрыт.');for(const select of ['#source-target','#copy-source','#builder-target'])assert.equal(await page.locator(select+' option[value="'+first+'"]').count(),0);await page.getByRole('button',{name:/Скрытые домены/}).click();await page.locator('[data-hidden-target="'+first+'"] button').filter({hasText:'Вернуть'}).click();await page.locator('[data-overview-target="'+first+'"]').waitFor();await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowLeft');await page.waitForFunction(id=>document.querySelector('[data-overview-target]')?.dataset.overviewTarget===id,first);
// Native pointer path, preserving unrelated drafts, selection and shared note nodes.
await page.locator('[data-host]').first().waitFor();const host=page.locator('[data-host]').first(),original=await host.inputValue();await host.fill('draft.example');const draftNode=await host.elementHandle();const noteRow=page.locator('tr[data-utm-note=reg]');await noteRow.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const note=noteRow.getByLabel('Заметка reg',{exact:true});await note.fill('Несохранённая заметка при переносе');const noteNode=await note.elementHandle();
const second=before[1],grip=page.locator('[data-overview-target="'+second+'"] .domain-grip');await grip.scrollIntoViewIfNeeded();const box=await grip.boundingBox(),destination=await page.locator('[data-overview-target="'+first+'"]').boundingBox();await page.mouse.move(box.x+10,box.y+10);await page.mouse.down();await page.mouse.move(destination.x+15,destination.y+20,{steps:12});assert.equal(await page.locator('.domain-drag-preview').count(),1);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-drag.png'});await page.mouse.up();await page.waitForFunction(id=>document.querySelector('[data-overview-target]').dataset.overviewTarget===id,second);assert.equal(await draftNode.inputValue(),'draft.example');assert.equal(await noteNode.inputValue(),'Несохранённая заметка при переносе');assert.ok(await draftNode.evaluate(n=>n.isConnected));assert.ok(await noteNode.evaluate(n=>n.isConnected));
await page.locator('[data-overview-target="'+first+'"] .domain-hide').click();assert.equal(await page.locator('[data-overview-target="'+first+'"]').count(),1);assert.match(await page.locator('#notice').innerText(),/Сначала сохраните/);await page.getByLabel('Отменить настройки '+first,{exact:true}).click();await noteRow.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowLeft');await page.waitForFunction(id=>document.querySelector('[data-overview-target]').dataset.overviewTarget===id,first);
// Server persistence for an independent authenticated device/context.
const another=await browser.newContext({baseURL:process.env.ADMIN_URL});const remote=await another.newPage();await remote.goto('/');await remote.getByLabel('Логин',{exact:true}).fill('admin');await remote.getByLabel('Пароль',{exact:true}).fill('test-password');await remote.getByRole('button',{name:'Войти',exact:true}).click();await remote.locator('[data-overview-target]').first().waitFor();assert.deepEqual(await remote.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget)),before);await another.close();
for(const id of before){await page.locator('[data-overview-target="'+id+'"] .domain-hide').click();await page.locator('[data-overview-target="'+id+'"]').waitFor({state:'detached'});}assert.equal(await page.locator('#connection-new').isVisible(),true);assert.equal(await page.locator('#jobs table').count(),0);assert.match(await page.locator('#jobs').innerText(),/Заданий для видимых доменов пока нет/);await page.locator('#hidden-toggle').click();assert.equal(await page.locator('[data-hidden-target]').count(),before.length);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-hidden.png',fullPage:true});for(const id of before){await page.locator('[data-hidden-target="'+id+'"] button').filter({hasText:'Вернуть'}).click();await page.locator('[data-overview-target="'+id+'"]').waitFor();}await page.locator('#hidden-close').click();await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-mobile.png'});assert.deepEqual(errors,[]);assert.equal(await page.locator('#notice').innerText(),'Домен возвращён.');assert.equal(await page.locator('#notice').getAttribute('class'),'alert alert-success');console.log('PASS visibility/order/native drag/drafts/notes/new context/all hidden/responsive UI');
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+16
View File
@@ -0,0 +1,16 @@
/* Browser plugin not available; local disposable fixture, real HTTP/jobs and rendered UI. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({args:['--no-sandbox']}),context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.dismiss());page.setDefaultTimeout(12000);let csrf;const created=[];
const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(e=>!e.textContent.includes('Чтение')));csrf=(await page.request.get('/api/state').then(r=>r.json())).csrf;};
const read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const weight=(id,n)=>page.getByLabel('theme вес '+n+' '+id,{exact:true});const name='UI набор '+Date.now();
try{await page.goto('/');await login();await weight('a','1').fill('80');await weight('a','2').fill('20');await page.getByLabel('Основной сайт a',{exact:true}).fill('invalid host /');await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();await page.getByLabel('Название набора',{exact:true}).fill(' ');await page.locator('#preset-save').click();await page.locator('#preset-error').getByText(/Укажите название/).waitFor();await page.getByLabel('Название набора',{exact:true}).fill(name);await page.locator('#preset-save').click();await page.locator('#preset-dialog').waitFor({state:'hidden'});const id=await page.locator('#copy-source').inputValue();created.push(id);assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),original.get('a'),'saving draft does not publish');
await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();await page.getByLabel('Название набора',{exact:true}).fill(name);await page.locator('#preset-save').click();await page.locator('#preset-error').getByText(/уже существует/).waitFor();assert.equal(await page.locator('#preset-name').inputValue(),name);await page.locator('#preset-cancel').click();
await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();for(const target of ['b','c'])await page.getByLabel('Копировать в '+target+'.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>document.querySelectorAll('#copy-results .status-success').length===2,null,{timeout:60000});assert.equal(read('b').utm.theme.weights.dark,80);assert.equal(read('c').utm.theme.weights['1'],80);assert.equal(read('b').host,JSON.parse(original.get('b')).host);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),original.get('d'));await page.locator('#copy-panel').screenshot({path:out+'/presets-copy.png'});
await page.reload();await page.locator('#copy-source option[value="'+id+'"]').waitFor({state:'attached'});await page.locator('#copy-source').selectOption(id);assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),1,'origin has no special status');
// A pending list refresh must disable deletion, then recover.
let release,seen;const gate=new Promise(r=>release=r),captured=new Promise(r=>seen=r);await page.route('**/api/utm-presets',async route=>{if(route.request().method()!=='GET')return route.continue();const response=await route.fetch();seen();await gate;await route.fulfill({response});});await page.locator('#preset-reload').click();await captured;assert.equal(await page.locator('#preset-delete').isDisabled(),true,'delete blocked while list GET pending');release();await page.waitForFunction(()=>!document.querySelector('#preset-reload').disabled);await page.unroute('**/api/utm-presets');
// 401 during save must not leave controls disabled after re-login.
await page.route('**/api/utm-presets',async route=>route.request().method()==='POST'?route.fulfill({status:401,contentType:'application/json',body:JSON.stringify({ok:false,error:'Истекла сессия теста'})}):route.continue());await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();await page.locator('#preset-name').fill(name+' session');await page.locator('#preset-save').click();await page.locator('#login-panel').waitFor();await page.unroute('**/api/utm-presets');await login();await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();assert.equal(await page.locator('#preset-name').isEnabled(),true,'new session resets preset form');assert.equal(await page.locator('#preset-save').isEnabled(),true);assert.equal(await page.locator('#preset-cancel').isEnabled(),true);await page.locator('#preset-cancel').click();
await page.locator('#copy-source').selectOption(id);await page.locator('#preset-delete').click();assert.equal(await page.locator('#copy-source option[value="'+id+'"]').count(),1,'cancel deletion leaves preset');const deleted=await page.request.delete('/api/utm-presets',{headers:{'X-CSRF-Token':csrf},data:{id}});assert.equal(deleted.ok(),true);await page.locator('#preset-reload').click();await page.locator('#copy-source option[value="'+id+'"]').waitFor({state:'detached'});assert.equal(read('b').utm.theme.weights.dark,80,'deleting preset does not revert recipient');
await page.setViewportSize({width:390,height:844});await page.locator('#copy-panel').screenshot({path:out+'/presets-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS preset draft/name/duplicate/apply2/host preservation/reload/any recipient/delete cancel+API/list race/session recovery/mobile');
}catch(error){console.error('UI evidence',await page.locator('#copy-panel').innerText(),errors);await page.screenshot({path:out+'/presets-failure.png'});throw error;}finally{for(const id of created)if(csrf)await page.request.delete('/api/utm-presets',{headers:{'X-CSRF-Token':csrf},data:{id}}).catch(()=>{});await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
+8
View File
@@ -0,0 +1,8 @@
/* preland#25: UTM inline editor and two-target ZIP regression, disposable fixtures only. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));const originals=new Map();
const cell=id=>page.locator('[data-matrix-target="'+id+'"][data-marker=indpic]');const saved=async id=>page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent.match(/^(Прочитано с хостинга|Записано на хостинг)$/),id,{timeout:90000});const save=async id=>{await page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}).click();await saved(id);};const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b']){await saved(id);originals.set(id,(await read(id)).config);assert.ok(!Object.hasOwn(originals.get(id).utm.indpic.weights,'3'),'fixture originally omits3');}
await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();assert.ok(process.env.VALID_ZIP,'VALID_ZIP with set3 required');await page.locator('#set-add input[name=number]').fill('3');await page.locator('#set-add input[name=name]').fill('UI #25 — набор 3');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add button').click();await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();for(const id of ['a','b']){assert.deepEqual((await read(id)).config,originals.get(id),'ZIP leaves config unchanged');assert.equal(await cell(id).locator('input[type=radio][aria-label="indpic default 3 '+id+'"]').count(),0,'missing variant cannot be silently selected as default');assert.equal(await cell(id).locator('select,.matrix-remove').count(),0);const rows=cell(id).locator('.matrix-variant');const changed=rows.first(),oldWeight=await changed.locator('input[type=number]').inputValue();await changed.locator('input[type=number]').fill(String(Number(oldWeight)+1));await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await rows.first().locator('input[type=number]').inputValue(),oldWeight);const choice=rows.nth(await rows.evaluateAll(ns=>ns.findIndex(n=>!n.querySelector('input[type=radio]').checked))),digit=await choice.locator('.matrix-variant-number').innerText();await choice.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?Number(digit):digit;assert.deepEqual((await read(id)).config,expected);await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await saved(id);assert.equal(await cell(id).locator('input[type=radio]:checked').evaluate(n=>n.closest('.matrix-variant').querySelector('.matrix-variant-number').textContent),digit);}
assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(n=>n.map(i=>i.value)),['a','b']);assert.deepEqual(errors,[]);console.log('PASS actual two-target ZIP / static variants + existing default + weight cancel / per-column Save+reread / otherfields preserved');
}finally{for(const [id,config]of originals){const def=String(config.utm.indpic.default);for(const row of await cell(id).locator('.matrix-variant').all())if(await row.locator('.matrix-variant-number').innerText()===def)await row.locator('input[type=radio]').check();const button=page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});if(await button.isEnabled()){await save(id);assert.deepEqual((await read(id)).config,config);}}await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+28
View File
@@ -0,0 +1,28 @@
/* preland#22: real verified status refresh, isolated to disposable LOCAL fixtures. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));let original=null,csrf='',primaryError=null;
const api=async(route,data)=>{const r=await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}}).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
const state=async()=>{const r=await page.request.get('/api/state').then(r=>r.json());assert.ok(r.ok,r.error);return r;};
const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
try{
await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
let current=await state();csrf=current.csrf;original=current.targets.find(t=>t.id===(process.env.TARGET_ID||'a'));assert.ok(original);assert.equal(original.type,'local','this test MUST NOT modify SSH targets');assert.ok(current.targets.some(t=>t.id==='b'&&t.enabled));
// A local adapter ignores port. Identity change invalidates verification without touching host/secret/root/config.
const changed={...original,port:original.port===22?23:22};const saved=await api('targets',{target:changed});assert.equal(saved.target.verified,false,'real server flag invalidated');
await page.reload();await page.locator('#workspace').waitFor({state:'visible'});current=await state();csrf=current.csrf;
const row=page.locator('#connection-list .connection').filter({hasText:original.domain}),header=page.locator('#overview-table th[data-overview-target="'+original.id+'"]');await header.waitFor();assert.match(await row.innerText(),/требуется проверка/);assert.match(await header.innerText(),/соединение не проверено/);
const before=await read(original.id);
await page.locator('#source-target').selectOption(original.id);await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('unsaved-verification.example.org');await page.locator('#targets input[value=b]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));
await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const versionText=await page.locator('#read-state').innerText(),selected=await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value));assert.equal(selected.length,2);
await page.locator('#builder-target').selectOption(original.id);const theme=page.locator('#builder-fields select[data-marker=theme]');await theme.waitFor();await theme.selectOption((await theme.inputValue())==='rnd'?'1':'rnd');const constructorDraft=await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value]));
await row.getByRole('button',{name:'Проверить соединение',exact:true}).click();await page.locator('#indpic-result-modal').waitFor({state:'visible'});assert.match(await page.locator('#result-body').innerText(),/проверен/i);assert.equal((await state()).targets.find(t=>t.id===original.id).verified,true,'real backend verified flag true');
assert.match(await row.innerText(),/соединение проверено/,'connection row refreshed without reload');assert.doesNotMatch(await row.innerText(),/требуется проверка/);assert.doesNotMatch(await header.innerText(),/соединение не проверено/,'overview header refreshed without reload');
assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'unsaved-verification.example.org');assert.equal(await page.locator('#read-state').innerText(),versionText);assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(inputs=>inputs.map(i=>i.value)),selected);assert.deepEqual(await page.locator('#builder-fields select').evaluateAll(inputs=>inputs.map(i=>[i.dataset.marker,i.value])),constructorDraft);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/);
const after=await read(original.id);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);assert.deepEqual(errors,[]);
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/verified-refresh.png',fullPage:true});fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/verified-refresh-evidence.json',JSON.stringify({target:original.id,serverVerified:true,configVersion:after.version,selected,constructorDraftPreserved:true,pageErrors:errors},null,2));console.log('PASS real targets/test modal + connection/overview verified refresh + editor/selected2/CAS/constructor preserved/config unchanged');
}catch(error){primaryError=error;}finally{
const cleanup=[];if(original)try{await api('targets',{target:original});if(original.verified)await api('targets/test',{id:original.id});}catch(error){cleanup.push(error);}try{await browser.close();}catch(error){cleanup.push(error);}if(primaryError&&cleanup.length)throw new AggregateError([primaryError,...cleanup],'verification UI failure with separate fixture cleanup errors',{cause:primaryError});if(primaryError)throw primaryError;if(cleanup.length)throw new AggregateError(cleanup,'fixture cleanup failed');
}
})().catch(error=>{console.error(error);process.exit(1);});
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Notes,Problem,Store};
function notesCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
function notesReject(callable $callback,int $status,string $message): void {try{$callback();}catch(Problem $e){notesCheck($e->status===$status,$message);return;}throw new RuntimeException('Accepted invalid operation: '.$message);}
notesCheck(class_exists(Notes::class),'Notes service is available');
$root=sys_get_temp_dir().'/utm-notes-'.bin2hex(random_bytes(8));$store=new Store($root);$notes=new Notes($store);$markers=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
$runtime=$root.'/runtime.json';file_put_contents($runtime,'{"host":"untouched.example","utm":{}}');$runtimeHash=hash_file('sha256',$runtime);
$all=$notes->all();notesCheck(array_keys($all)===$markers&&count(array_filter($all,static fn($n)=>$n['text']===''&&$n['saved_at']===null&&is_string($n['version'])))===9,'GET contract provides all nine empty notes with stable versions');
notesCheck(!file_exists($root.'/utm-notes.json')&&$notes->all()===$all,'initial read does not create state or regenerate versions');
$text="Общее описание для всех доменов\n<script>alert('literal')</script>\tпример\r\n😀";
$saved=$notes->save(['marker'=>'theme','text'=>$text,'version'=>$all['theme']['version']]);notesCheck($saved['text']===$text&&$saved['version']!==$all['theme']['version']&&is_string($saved['saved_at']),'save stores exact multiline Unicode plaintext with new revision and timestamp');
notesCheck((new Notes(new Store($root)))->all()['theme']===$saved,'fresh service reload returns exact saved note and version');
notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'stale','version'=>$all['theme']['version']]),409,'stale per-note version conflicts instead of overwriting');
$indpic=$notes->save(['marker'=>'indpic','text'=>'Independent note','version'=>$all['indpic']['version']]);notesCheck($notes->all()['theme']===$saved&&$indpic['text']==='Independent note','another marker can save its original revision without losing sibling note');
$cleared=$notes->save(['marker'=>'theme','text'=>'','version'=>$saved['version']]);notesCheck($cleared['text']===''&&$cleared['version']!==$all['theme']['version']&&$cleared['version']!==$saved['version'],'explicit clear keeps a new revision and prevents ABA');
notesReject(fn()=>$notes->save(['marker'=>'theme','text'=>'old initial','version'=>$all['theme']['version']]),409,'initial version remains stale after save then clear');
$large=str_repeat('😀',10000);$max=$notes->save(['marker'=>'cons','text'=>$large,'version'=>$all['cons']['version']]);notesCheck($max['text']===$large,'limit counts 10000 Unicode characters rather than UTF8 bytes');
foreach([['marker'=>'unknown','text'=>'x','version'=>'x'],['marker'=>'registration','text'=>'x','version'=>'x'],['marker'=>'theme','text'=>1,'version'=>'x'],['marker'=>'theme','text'=>'x','version'=>null],['marker'=>'theme','text'=>'x'],['marker'=>'theme','text'=>'x','version'=>'x','domain'=>'evil.example'],['marker'=>['theme'],'text'=>'x','version'=>'x'],['marker'=>'theme','text'=>str_repeat('a',10001),'version'=>'x'],['marker'=>'theme','text'=>"invalid\xFF",'version'=>'x'],['marker'=>'theme','text'=>"NUL\0",'version'=>'x'],['marker'=>'theme','text'=>"DEL\x7f",'version'=>'x'],['marker'=>'theme','text'=>"C1\u{0085}",'version'=>'x']] as $invalid)notesReject(fn()=>$notes->save($invalid),422,'invalid marker, fields, types, length, UTF8 or controls rejected');
$pids=[];$snapshot=$notes->all();foreach(['degr','tp'] as $marker){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>$marker,'text'=>'parallel-'.$marker,'version'=>$snapshot[$marker]['version']]);exit(0);}catch(Throwable){exit(2);}}$pids[]=$pid;}foreach($pids as $pid){pcntl_waitpid($pid,$status);notesCheck(pcntl_wexitstatus($status)===0,'parallel unrelated note save succeeds');}notesCheck($notes->all()['degr']['text']==='parallel-degr'&&$notes->all()['tp']['text']==='parallel-tp','global file lock preserves concurrent writes to different notes');
$snapshot=$notes->all();$pids=[];foreach(['first','second'] as $text){$pid=pcntl_fork();if($pid===0){try{(new Notes(new Store($root)))->save(['marker'=>'reg','text'=>$text,'version'=>$snapshot['reg']['version']]);exit(0);}catch(Problem $e){exit($e->status===409?9:2);}}$pids[]=$pid;}$codes=[];foreach($pids as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);notesCheck($codes===[0,9],'concurrent same-note saves produce one success and one conflict');
notesCheck((fileperms($root.'/utm-notes.json')&0777)===0600,'notes state is private 0600');$good=file_get_contents($root.'/utm-notes.json');
foreach(['{broken','null','[]','{"unexpected":{}}',str_replace('Independent note','bad\\u0000text',$good)] as $bad){file_put_contents($root.'/utm-notes.json',$bad);notesReject(fn()=>$notes->all(),503,'corrupt private note state fails closed');}file_put_contents($root.'/utm-notes.json',$good);
file_put_contents($root.'/utm-notes.json','null');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not reset','version'=>$indpic['version']]),503,'write never resets corrupt note state');notesCheck(file_get_contents($root.'/utm-notes.json')==='null','failed corrupt-state write leaves evidence intact');
unlink($root.'/utm-notes.json');symlink($runtime,$root.'/utm-notes.json');notesReject(fn()=>$notes->all(),503,'note symlink read fails closed');notesReject(fn()=>$notes->save(['marker'=>'indpic','text'=>'must not follow','version'=>$indpic['version']]),503,'note symlink write fails closed');unlink($root.'/utm-notes.json');file_put_contents($root.'/utm-notes.json',$good);chmod($root.'/utm-notes.json',0600);
notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'notes never modify runtime, targets or jobs');
// Настоящий HTTP-контракт, отдельный PHP server, обычный auth/CSRF.
$env=getenv();$env['ADMIN_STATE_DIR']=$root;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='notes-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('notes-fixture-password',PASSWORD_DEFAULT);unset($env['PHP_CLI_SERVER_WORKERS']);$port=random_int(19000,29000);$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);fclose($pipes[0]);$cookie='';
function notesRequest(string $method='GET',?array $data=null,?string $csrf=null,string $path='/api/utm-notes'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$data===null?'':json_encode((object)$data,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$h=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$h[0]??'',$m);foreach($h as $line)if(str_starts_with(strtolower($line),'set-cookie: '))$cookie=explode(';',substr($line,12))[0];return [(int)($m[1]??0),json_decode($raw,true,64,JSON_THROW_ON_ERROR)];}
try{$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);break;}usleep(20000);}while(microtime(true)<$until);
[$status]=notesRequest();notesCheck($status===401,'HTTP notes require authentication');[$status,$login]=notesRequest('POST',['username'=>'notes-fixture','password'=>'notes-fixture-password'],null,'/api/login');notesCheck($status===200,'HTTP fixture login succeeds');$csrf=$login['csrf'];
[$status,$body]=notesRequest();notesCheck($status===200&&array_keys($body['notes'])===$markers,'HTTP GET returns nine global note objects without target');$version=$body['notes']['focus']['version'];
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'csrf','version'=>$version]);notesCheck($status===403,'HTTP notes mutation requires CSRF');
[$status,$body]=notesRequest('POST',['marker'=>'focus','text'=>'HTTP global note','version'=>$version],$csrf);notesCheck($status===200&&$body['note']['text']==='HTTP global note','HTTP POST saves exact note response');$httpSaved=$body['note'];
[$status,$body]=notesRequest();notesCheck($body['notes']['focus']===$httpSaved,'HTTP reload restores note across requests');
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'stale HTTP','version'=>$version],$csrf);notesCheck($status===409,'HTTP stale editor gets 409 conflict');
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'x','version'=>$httpSaved['version'],'target'=>'any'],$csrf);notesCheck($status===422,'HTTP note refuses domain scope and unknown top-level fields');
[$status]=notesRequest('POST',['marker'=>'focus','text'=>false,'version'=>$httpSaved['version']],$csrf);notesCheck($status===422,'HTTP strict note value types return 422');
file_put_contents($root.'/utm-notes.json','{bad');[$status]=notesRequest();notesCheck($status===503,'HTTP corrupt file returns safe 503');file_put_contents($root.'/utm-notes.json',$good);
notesCheck(hash_file('sha256',$runtime)===$runtimeHash&&glob($root.'/jobs/*.json')===[]&&!file_exists($root.'/targets.json'),'HTTP notes leave runtime, jobs and target registry unchanged');
[$status]=notesRequest('POST',[],$csrf,'/api/logout');notesCheck($status===200,'HTTP note fixture logs out');
}finally{proc_terminate($server);proc_close($server);fclose($log);}
echo "UTM notes contract complete\n";
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{UtmPresets,Store,Copy,Config,Jobs,Transport,Problem};
function checkPreset(bool $ok,string $label):void{if(!$ok)throw new RuntimeException($label);echo "PASS $label\n";}
function rejectsPreset(callable $f,string $label):void{try{$f();}catch(Problem){checkPreset(true,$label);return;}throw new RuntimeException($label);}
$root=sys_get_temp_dir().'/presets-'.bin2hex(random_bytes(6));mkdir($root,0700);$store=new Store($root.'/state');$presets=new UtmPresets($store);
$utm=Config::decode('{"theme":{"default":"3","weights":{"1":33.33,"3":66.67}},"cons":{"default":2,"weights":{"1":0,"2":0}},"part":{"default":"2","weights":{"1":0,"2":100}}}');
$saved=$presets->create(['name'=>'Осень','utm'=>$utm]);
checkPreset(array_keys($saved)===['id','name','utm'],'preset has no source domain or target binding');
$utm->theme->weights->{'1'}=100;checkPreset($presets->get($saved['id'])['utm']->theme->weights->{'1'}===33.33,'draft is an immutable independent snapshot');
checkPreset((new UtmPresets(new Store($store->dir)))->get($saved['id'])['name']==='Осень','preset survives store reload');
foreach([['name'=>'','utm'=>$saved['utm']],['name'=>' ','utm'=>$saved['utm']],['name'=>'осень','utm'=>$saved['utm']],['name'=>'x','utm'=>$saved['utm'],'domain'=>'a.invalid'],['name'=>'x','utm'=>(object)[]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>80]]]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>100],'note'=>'secret']]],['name'=>'x','utm'=>(object)['unknown'=>(object)[]]]] as $bad)rejectsPreset(fn()=>$presets->create($bad),'strict independent preset validation');
$paths=[];foreach(['a','b'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id]);}putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths));
$raw='{"host":"keep.invalid","utm":{"theme":{"default":"dark","weights":{"dark":100},"note":"keep"},"reg":{"default":2,"weights":{"2":100}},"part":{"default":"1","weights":{"1":100},"metadata":{"keep":true}},"custom":{"a":1}},"requisites":["keep"],"unknown":{}}';
foreach($paths as $id=>$path){file_put_contents($path.'/config.json',$raw);$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);}
$store->write('utm-notes.json',['theme'=>['text'=>'keep note']]);$notes=file_get_contents($store->path('utm-notes.json'));
$read=fn($id)=>(new Transport($store,$store->target($id)))->readConfig();$selection=fn($id)=>(object)['id'=>$id,'version'=>$read($id)['version']];
$copy=new Copy($store);$job=$copy->create(['preset'=>$saved['id'],'targets'=>[$selection('a'),$selection('b')]]);
$presets->delete($saved['id']);rejectsPreset(fn()=>$presets->get($saved['id']),'deleted preset unavailable');
file_put_contents($paths['b'].'/config.json',str_replace('keep.invalid','foreign.invalid',$raw));$jobs=new Jobs($store);$jobs->run($job['id']);$done=$jobs->get($job['id']);
checkPreset($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','queued snapshot survives deletion and isolates CAS conflict');
$a=$read('a')['config'];checkPreset($a->utm->part->default==='2'&&$a->utm->part->weights->{'1'}===0&&$a->utm->part->weights->{'2'}===100&&$a->utm->part->metadata->keep===true,'part preserved through independent preset queue, CAS, metadata and exact backup');checkPreset($a->host==='keep.invalid'&&$a->utm->theme->note==='keep'&&$a->utm->reg->default===2&&$a->utm->custom->a===1&&$a->unknown instanceof stdClass,'host, absent groups, metadata and unknown types unchanged');
checkPreset($a->utm->theme->default==='3'&&$a->utm->theme->weights->dark===33.33&&$a->utm->theme->weights->{'3'}===66.67&&$a->utm->cons->weights->{'2'}===0,'manual variants, semantic targets and all-zero percentages copied');
checkPreset($store->backupContent('a',$store->backups('a')[0]['id'])===$raw&&$store->backups('b')===[],'exact backup only for successful recipient');
checkPreset(file_get_contents($store->path('utm-notes.json'))===$notes,'notes stay byte identical');
$retry=$store->read('jobs/'.$job['id'].'.json');$retry['status']='running';$retry['results'][0]['status']='running';$store->write('jobs/'.$job['id'].'.json',$retry);$jobs->run($job['id']);checkPreset(count($store->backups('a'))===1,'desired hash recovery does not repeat write');
$next=$presets->create(['name'=>'После удаления','utm'=>$saved['utm']]);$store->setTargetVisibility('b',true);$store->deleteTarget('b');checkPreset($presets->get($next['id'])['name']==='После удаления','target deletion cannot delete or detach preset');
foreach([['source'=>$selection('a'),'targets'=>[$selection('a')]],['preset'=>$next['id'],'targets'=>[]],['preset'=>$next['id'],'targets'=>[$selection('a'),$selection('a')]],['preset'=>$next['id'],'targets'=>[(object)['id'=>'a','version'=>'bad']]],['preset'=>$next['id'],'targets'=>[$selection('a')],'utm'=>(object)[]]] as $bad)rejectsPreset(fn()=>$copy->create($bad),'strict copy request');
$same=$copy->create(['preset'=>$next['id'],'targets'=>[$selection('a')]]);$jobs->run($same['id']);checkPreset($jobs->get($same['id'])['results'][0]['status']==='success','any domain can receive independent preset');
echo "ALL PRESET CHECKS PASS\n";
+19
View File
@@ -0,0 +1,19 @@
<?php
$adminCssVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.css'), 0, 12);
$adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.js'), 0, 12);
?>
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Прелендинги — UTM и наборы изображений</title><link rel="stylesheet" href="/assets/admin.css?v=<?= $adminCssVersion ?>"><script src="/assets/admin.js?v=<?= $adminJsVersion ?>" defer></script></head>
<body><header class="navbar"><div class="container nav-inner"><strong>Прелендинги</strong><span>UTM и наборы изображений</span><button id="logout" class="btn btn-default" hidden>Выйти</button></div></header>
<main class="container"><div id="notice" role="status" aria-live="polite" hidden></div>
<section id="login-panel" class="panel login-panel"><h1>Вход в админку</h1><form id="login-form"><label>Логин<input class="form-control" name="username" autocomplete="username" required></label><label>Пароль<input class="form-control" name="password" type="password" autocomplete="current-password" required></label><label class="checkbox remember-choice"><input name="remember" type="checkbox"> Запомнить меня на 90 дней</label><button class="btn btn-primary" type="submit">Войти</button></form></section>
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#overview">Настройки</a><a href="#copy-panel">Копирование</a><a href="#link-builder">Конструктор ссылки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
<section id="overview" class="panel"><h2>Настройки доменов</h2><p class="text-muted">Редактируйте веса в процентах и default прямо в ячейках. Сумма группы — 100% либо все веса — 0%. Допустимо до двух знаков после запятой. Сохранение — отдельно для каждого домена.</p><div class="overview-toolbar"><button id="overview-refresh" type="button" class="btn btn-default">Обновить настройки</button><button id="hidden-toggle" type="button" class="hidden-link">Скрытые домены (0)</button></div><div class="table-responsive overview-wrapper"><table id="overview-table" class="table table-bordered overview-table"><caption class="text-muted">Таблицу можно прокручивать по горизонтали.</caption><thead></thead><tbody></tbody></table></div></section>
<section id="hidden-domains" class="panel" hidden><div class="overview-toolbar"><h2>Скрытые домены</h2><button id="hidden-close" type="button" class="hidden-link">Вернуться к настройкам</button></div><p class="text-muted">Скрытые домены убраны из рабочих списков. Сайт и файлы продолжают работать.</p><div id="hidden-list"></div></section><section id="copy-panel" class="panel"><h2>Копировать настройки</h2><div class="copy-grid"><div><label for="copy-source">Сохранённые настройки</label><div class="preset-select-row"><select id="copy-source" class="form-control"><option value="">Выберите сохранённый набор</option></select><button id="preset-delete" class="preset-icon btn btn-default" type="button" aria-label="Удалить выбранный набор" title="Удалить выбранный набор" disabled><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></svg></button></div><span class="text-muted copy-hint">Именованные UTM-наборы из админки, без привязки к доменам.</span><div class="preset-list-tools"><button id="preset-reload" class="hidden-link" type="button">Обновить список</button></div><p id="preset-list-status" class="text-muted" role="status" aria-live="polite"></p></div><div><strong>Куда</strong><div id="copy-targets" class="target-options"></div><div class="toolbar"><button id="copy-all" type="button" class="btn btn-default btn-xs">Выбрать все</button><button id="copy-none" type="button" class="btn btn-default btn-xs">Снять выбор</button></div></div></div><div class="copy-bottom"><p class="text-muted">Только UTM: варианты, веса и default.<br>Основной сайт, заметки и файлы сетов не меняются. Отсутствующая в наборе группа сохраняется у получателя.</p><button id="copy-submit" class="btn btn-primary" type="button" disabled>Копировать в 0 доменов</button></div><div id="copy-results" aria-live="polite"></div><p id="copy-reason" class="text-muted" role="status" aria-live="polite"></p></section><section id="link-builder" class="panel"><h2>Конструктор ссылки</h2><p class="text-muted">Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.</p><label class="builder-domain">Домен ссылки<select id="builder-target" class="form-control"><option value="">Выберите домен</option></select></label><p id="builder-state" class="text-muted" role="status" aria-live="polite">Выберите домен.</p><div id="builder-fields" class="form-grid"></div><div id="builder-warnings" aria-live="polite"></div><button id="builder-save" type="button" class="btn btn-primary" disabled>Сформировать и сохранить</button><div id="builder-result" hidden><label>Готовая ссылка<input id="builder-url" class="form-control" type="url" readonly spellcheck="false"></label><div class="toolbar"><button id="builder-copy" type="button" class="btn btn-default">Скопировать</button><a id="builder-open" class="btn btn-default" target="_blank" rel="noopener noreferrer">Открыть ссылку</a></div><p id="builder-saved-at" class="text-muted"></p></div></section>
<section id="indpic" class="panel"><h2>indpic= UTM</h2><p class="text-muted">Выбор домена и целей ниже относится только к наборам и резервным копиям. Загрузка не меняет default и веса.</p><div class="toolbar"><label class="grow">Открытый домен<select id="source-target" class="form-control"><option value="">Выберите домен</option></select></label><button id="reload" class="btn btn-default" type="button">Перечитать</button></div><p id="read-state" class="text-muted">Выберите домен.</p><p class="text-muted">Цели загрузки ZIP и проверки</p><div id="targets" class="target-options"></div><div class="table-responsive"><table class="table table-bordered table-hover indpic-sets-table"><thead><tr><th>Номер / название</th><th class="indpic-default-column">Default</th><th class="indpic-check-column">Проверка</th><th>ZIP</th><th class="indpic-actions-column">Действия</th></tr></thead><tbody id="sets-body"></tbody></table></div><h3>Добавить сет</h3><form id="set-add" class="indpic-add-form"><label>Номер сета<input name="number" class="form-control indpic-number-input" type="number" min="1" max="9" required></label><label>Название<input name="name" class="form-control" maxlength="255"></label><label class="checkbox"><input name="sftp" type="checkbox"> Загружен по SFTP</label><label class="zip-field">ZIP<input name="file" type="file" accept=".zip,application/zip" class="form-control" required></label><button class="btn btn-default">Загрузить сет</button></form></section>
<section id="connections" class="panel"><h2>Домены и подключения</h2><p class="text-muted">Удаление подключения оставляет сайт и файлы на хостинге. Секрет можно заменить; прочитать сохранённый секрет нельзя.</p><div id="connection-list"></div><h3 id="connection-title">Добавить подключение</h3><form id="connection-form"><div class="form-grid"><label>ID<input name="id" class="form-control" required pattern="[A-Za-z0-9_-]+"></label><label>Название<input name="name" class="form-control" required></label><label>Домен<input name="domain" class="form-control" required></label><label>Окружение<select name="environment" class="form-control"><option value="stage">stage</option><option value="production">production</option></select></label><label>Тип<select name="type" class="form-control"><option value="sftp">SFTP / SSH</option><option value="local">Локальная папка</option></select></label><label>Хост SSH<input name="host" class="form-control"></label><label>Порт<input name="port" type="number" min="1" max="65535" value="22" class="form-control"></label><label>Пользователь SSH<input name="user" class="form-control"></label><label>Корневая папка<input name="root" class="form-control" required></label><label>Проверенный ключ хоста<input name="host_key" class="form-control" autocomplete="off"></label><label>Новый секрет / пароль<input name="secret" type="password" class="form-control" autocomplete="new-password"></label><label class="checkbox"><input name="enabled" type="checkbox"> Включено для записи</label></div><div class="toolbar"><button class="btn btn-primary">Сохранить подключение</button><button class="btn btn-default" id="connection-new" type="button">Очистить форму</button></div></form></section>
<section id="history" class="panel"><h2>Журнал и резервные копии</h2><button id="refresh-history" class="btn btn-default">Обновить журнал и резерв</button><h3>Резерв открытого домена</h3><div id="backups"></div><h3>Задания</h3><p class="text-muted">Успешные цели не повторяются. После конфликта перечитайте домен и проверьте изменения; неизвестный результат требует проверки фактического состояния.</p><div id="jobs" aria-live="polite"></div></section>
</div></main>
<dialog id="indpic-result-modal" aria-labelledby="result-title"><div class="modal-header"><button id="result-close" class="close" aria-label="Закрыть">×</button><h2 id="result-title">Результат проверки</h2></div><div id="result-body" class="modal-body"></div><div class="modal-footer"><button id="result-done" class="btn btn-default">Готово</button></div></dialog>
<dialog id="preset-dialog" class="preset-dialog" aria-labelledby="preset-title"><form id="preset-form"><div class="modal-header"><h2 id="preset-title">Сохранить UTM-настройки</h2></div><div class="modal-body"><label for="preset-name">Название набора</label><input id="preset-name" class="form-control" maxlength="120" required autocomplete="off"><p class="text-muted preset-help">Сохраняются текущие UTM-поля. Настройки на хостинге и заметки не меняются.</p><p id="preset-error" class="overview-error" role="alert" aria-live="polite"></p></div><div class="modal-footer"><button id="preset-cancel" class="btn btn-default" type="button">Отмена</button> <button id="preset-save" class="btn btn-primary" type="submit">Сохранить набор</button></div></form></dialog>
</body></html>
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* preland#22: проверка runtime indpic без сравнения с Git.
* Контракт имён/размеров: api 591a67cc8302 components/indpic/IndpicSetStorage.php.
* HTTP проверяет все обязательные файлы; только локальный режим может исключить лишние.
*/
declare(strict_types=1);
function runtimeSetDimensions(): array
{
$files = [];
for ($example = 1; $example <= 5; $example++) {
foreach (['desk' => [[1696, 920], [200, 200]], 'mob' => [[400, 217], [60, 60]]] as $device => [$full, $thumb]) {
foreach (['1-source' => $full, '2-result' => $full, '3-thumb' => $thumb] as $part => $dimensions) {
foreach (['avif', 'webp', 'jpg'] as $extension) $files[sprintf('%02d_%s_%s.%s', $example, $device, $part, $extension)] = $dimensions;
}
}
}
return $files;
}
function runtimeSetNumbers(array $config): array
{
$settings = $config['utm']['indpic'] ?? ['default' => '1', 'weights' => ['1' => 1]];
if (!is_array($settings)) throw new RuntimeException('Некорректный config utm.indpic');
$number = static function ($value): int {
if ((!is_string($value) && !is_int($value)) || !preg_match('/^[1-9]$/D', (string) $value)) throw new RuntimeException('Номер indpic должен быть 1–9');
return (int) $value;
};
$sets = [$number(array_key_exists('default', $settings) ? $settings['default'] : '1')];
$weights = $settings['weights'] ?? [];
if (!is_array($weights)) throw new RuntimeException('Некорректные веса indpic');
foreach ($weights as $variant => $weight) {
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float) $weight) || $weight < 0) throw new RuntimeException('Некорректный вес indpic');
// Даже weight=0 доступен по явной ссылке indpic=abcdN и участвует в legacy random.
$sets[] = $number($variant);
}
$sets = array_values(array_unique($sets)); sort($sets); return $sets;
}
function runtimeLocalReader(string $root): Closure
{
return static function (string $path) use ($root): string {
$bytes = @file_get_contents($root . '/' . $path);
if ($bytes === false) throw new RuntimeException('Не прочитан runtime ресурс ' . $path);
return $bytes;
};
}
function runtimeHttpReader(string $origin): Closure
{
if (!preg_match('~^https://[A-Za-z0-9.-]+$~D', $origin)) throw new RuntimeException('Требуется HTTPS origin');
return static function (string $path) use ($origin): string {
$context = stream_context_create(['http' => ['timeout' => 30, 'follow_location' => 0, 'header' => "Cache-Control: no-cache\r\n"], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$bytes = @file_get_contents($origin . '/' . $path . '?runtime_check=' . bin2hex(random_bytes(8)), false, $context, 0, 20971521);
if ($bytes === false || strlen($bytes) > 20971520 || !preg_match('~^HTTP/\S+ 200(?: |$)~', $http_response_header[0] ?? '')) throw new RuntimeException('Не прочитан runtime ресурс ' . $path);
return $bytes;
};
}
function checkRuntimeSets(array $config, callable $read, ?string $localRoot = null): array
{
if (!class_exists(Imagick::class)) throw new RuntimeException('Для полной проверки indpic требуется Imagick');
$sets = runtimeSetNumbers($config);
$expected = runtimeSetDimensions();
foreach ($sets as $set) {
if ($localRoot !== null) {
$directory = $localRoot . '/' . $set;
if (is_link($directory) || !is_dir($directory)) throw new RuntimeException('Не найден каталог indpic ' . $set);
$actual = [];
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $file) {
if ($file->isLink() || !$file->isFile()) throw new RuntimeException('Лишний элемент indpic ' . $set);
$actual[] = $file->getFilename();
}
$names = array_keys($expected); sort($names); sort($actual);
if ($actual !== $names) throw new RuntimeException('Набор indpic ' . $set . ' должен содержать ровно 90 обязательных файлов');
}
foreach ($expected as $name => [$width, $height]) {
$path = $set . '/' . $name;
$image = new Imagick();
try {
$image->readImageBlob($read($path));
$format = strtolower($image->getImageFormat());
$extension = pathinfo($name, PATHINFO_EXTENSION);
$formats = ['jpg' => ['jpeg', 'jpg'], 'webp' => ['webp'], 'avif' => ['avif', 'heic']];
if (!in_array($format, $formats[$extension], true) || $image->getNumberImages() !== 1 || $image->getImageWidth() !== $width || $image->getImageHeight() !== $height) throw new RuntimeException('Неверный формат/размер изображения');
} catch (Throwable $error) {
throw new RuntimeException('Невалидный runtime indpic ' . $path . ': ' . $error->getMessage(), 0, $error);
} finally { $image->clear(); }
}
}
return $sets;
}
function verifyRemoteRuntimeSets(string $origin): void
{
$read = runtimeHttpReader($origin);
$bytes = $read('config.json');
$config = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($config)) throw new RuntimeException('Некорректный runtime config');
$sets = checkRuntimeSets($config, $read);
if ($read('config.json') !== $bytes) throw new RuntimeException('Runtime config изменился во время проверки; повторите проверку');
fwrite(STDERR, 'OK: runtime indpic ' . implode(',', $sets) . '; все обязательные изображения декодированы, inventory лишних файлов по HTTP недоступен' . "\n");
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
try {
if (($argv[1] ?? '') === '--remote') verifyRemoteRuntimeSets($argv[2] ?? '');
else {
$root = $argv[1] ?? dirname(__DIR__);
$config = json_decode(runtimeLocalReader($root)('config.json'), true, 512, JSON_THROW_ON_ERROR);
checkRuntimeSets($config, runtimeLocalReader($root), $root);
echo "OK: локальный runtime indpic, ровно 90 файлов в каждом используемом наборе\n";
}
} catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . "\n"); exit(1); }
}
+6
View File
@@ -56,6 +56,12 @@ try {
// Валидация всей грамматики без последующей сериализации decoded данных.
json_decode($raw,false,512,JSON_THROW_ON_ERROR|JSON_BIGINT_AS_STRING);
$offset=0; $root=utmConfigNode($raw,$offset);
// Проверяем source keys, а не decoded object: escaped/повторные имена
// тоже не должны скрыть chb перед публикацией enum replacement.
foreach($root['properties']??[] as $property){
if($property['key']['value']!=='utm'||($property['value']['type']??null)!=='object')continue;
foreach($property['value']['properties'] as $setting)if($setting['key']['value']==='chb')throw new RuntimeException('Устаревший ключ utm.chb нельзя публиковать. Явно удалите его перед миграцией.');
}
$utm=utmConfigProperty($root,'utm')['value'] ?? null;
if (($root['type'] ?? null)!=='object' || ($utm['type'] ?? null)!=='object') throw new RuntimeException('Отсутствует объект utm');
$replacements=[];
+11 -4
View File
@@ -2,6 +2,7 @@
/** preland#11: проверенный состав ресурсов; исполняется до SFTP. */
declare(strict_types=1);
require_once __DIR__ . '/brand-contract.php';
require_once __DIR__ . '/check-runtime-sets.php';
function releaseAssetBytes(string $path, string $bytes): string
{
@@ -32,7 +33,8 @@ function releaseStaticFiles(string $root): array
function validateReleaseAssets(string $root): array
{
$manifest = json_decode((string) file_get_contents($root . '/release/assets.json'), true, 512, JSON_THROW_ON_ERROR);
$files = $manifest['files'] ?? [];
// Исторические /N/ записи manifest — только архивный снимок, не runtime guard.
$files = array_filter($manifest['files'] ?? [], static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path), ARRAY_FILTER_USE_KEY);
$brands = json_decode(file_get_contents($root . '/release/brands.json'), true, 512, JSON_THROW_ON_ERROR);
foreach (array_keys($brands) as $domain) prelandBrand($root, $domain);
$required = ['docs/logo.svg', 'docs/logo-alt.svg', 'docs/privacy_policy.pdf', 'docs/public_offer.pdf', 'docs/tariffs.pdf', 'docs/user_agreement.pdf'];
@@ -40,7 +42,8 @@ function validateReleaseAssets(string $root): array
if (!isset($files[$path])) throw new RuntimeException('В manifest отсутствует обязательный ресурс ' . $path);
}
$names = array_keys($files); sort($names, SORT_STRING);
if ($names !== releaseStaticFiles($root)) throw new RuntimeException('Состав статических ресурсов отличается от manifest; синхронизируйте актуальный набор');
$static = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path)));
if ($names !== $static) throw new RuntimeException('Состав статических ресурсов отличается от manifest; синхронизируйте актуальный набор');
foreach ($files as $path => $record) {
if (!preg_match('~^[A-Za-z0-9._/-]+$~', $path) || str_contains($path, '..')) throw new RuntimeException('Недопустимый путь ресурса');
$bytes = releaseAssetBytes($path, (string) file_get_contents($root . '/' . $path));
@@ -55,7 +58,9 @@ function validateReleaseAssets(string $root): array
function releaseFiles(string $root): array
{
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^(?:[0-9]+/|docs/logo(?:-alt)?\.svg$)~', $path)));
// Положительный контракт публичных ресурсов: admin/, его state/секреты и /N/ не поставляются.
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool =>
preg_match('~^(?:[^/]+\.css|docs/[^/]+\.pdf|favicon/[^/]+\.(?:ico|png|svg|webmanifest)|fonts/[^/]+\.(?:woff2?|ttf|otf)|images/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+\.(?:avif|webp|jpe?g|png|gif|svg))$~D', $path) === 1));
foreach (['.htaccess', 'index.php', 'i18n.php', 'template.php'] as $path) $files[] = $path;
foreach (['components', 'messages'] as $directory) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/' . $directory, FilesystemIterator::SKIP_DOTS));
@@ -70,13 +75,15 @@ function releaseFiles(string $root): array
function verifyRemoteReleaseAssets(array $files, string $base, bool $setsOnly): void
{
if (!preg_match('~^https://[A-Za-z0-9.-]+$~', $base)) throw new RuntimeException('Требуется HTTPS origin');
verifyRemoteRuntimeSets($base);
if ($setsOnly) return;
if (!$setsOnly) {
$brand = prelandBrand(dirname(__DIR__), (string) parse_url($base, PHP_URL_HOST));
foreach ($brand['logos'] as $path => $record) $files[$path]['sha256'] = $record['sha256'];
}
$count = 0;
foreach ($files as $path => $record) {
if ($setsOnly && !preg_match('~^[0-9]+/~', $path)) continue;
if (preg_match('~^[0-9]+/~', $path)) continue;
$context = stream_context_create(['http' => ['timeout' => 30, 'header' => "Cache-Control: no-cache\r\n"], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$bytes = @file_get_contents($base . '/' . $path . '?release_sha=' . $record['sha256'], false, $context);
if ($bytes === false || hash('sha256', releaseAssetBytes($path, $bytes)) !== $record['sha256']) {
+63
View File
@@ -0,0 +1,63 @@
<?php
/** Inline клиент: bounded delivery переживает SSR-переход, не задерживая пользовательский путь. */
return <<<'JS'
(() => {
if(window.ZoomzillaAnalytics)return;
const config=window.ZoomzillaAnalyticsConfig||{},endpoint=config.endpoint||location.origin;
const uuid=()=>{if(crypto.randomUUID)return crypto.randomUUID();const b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;return Array.from(b,(v,i)=>([4,6,8,10].includes(i)?'-':'')+v.toString(16).padStart(2,'0')).join('')};
const random=()=>btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const read=(storage,key,fallback)=>{try{const raw=storage().getItem(key);return raw&&raw.length<=65536?JSON.parse(raw):fallback}catch(_){return fallback}};
const write=(storage,key,value)=>{try{storage().setItem(key,JSON.stringify(value))}catch(_){}};
const local=()=>localStorage,session=()=>sessionStorage,browserKey='zoomzilla.analytics.browser.v1',queueKey='zoomzilla.analytics.delivery.v1';
let browser=read(local,browserKey,null)||read(session,browserKey,null);
if(!/^[0-9a-f-]{36}$/.test(browser||''))browser=uuid();
write(local,browserKey,browser);write(session,browserKey,browser);
const page=uuid(),queue=[];let token=null,contextTry=0,timer=null,sending=false,current=null,departureKey=null;
const captureCandidate=config.capture||new URL(location.href).searchParams.get('azv');
let capture=/^[A-Za-z0-9_-]{43}$/.test(captureCandidate||'')?captureCandidate:null;
const pageCapture=capture;
function prune(){for(let i=queue.length-1;i>=0;i--)if(queue[i].tries>=5||Date.now()-queue[i].created>86400000)queue.splice(i,1);while(queue.length>64||JSON.stringify(queue).length>62000)queue.shift()}
function persist(){prune();write(session,queueKey,{browser,endpoint,pending:queue})}
function restoreQueue(){const saved=read(session,queueKey,null);if(saved?.browser!==browser||saved?.endpoint!==endpoint||!Array.isArray(saved.pending))return;for(const item of saved.pending.slice(-64)){if(!item||!item.event||typeof item.event.id!=='string'||JSON.stringify(item.event).length>1024||!Number.isFinite(item.created)||!Number.isInteger(item.tries)||item.tries<0)continue;const existing=queue.find(x=>x.event.id===item.event.id);if(existing)existing.tries=Math.max(existing.tries,item.tries);else queue.push(item)}prune()}
restoreQueue();
const post=async(path,data,unload=false)=>{const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),5000);try{const response=await fetch(endpoint+'/api/analytics/public/'+path,{method:'POST',credentials:'omit',cache:'no-store',headers:{'Content-Type':unload?'text/plain;charset=UTF-8':'application/json'},body:JSON.stringify(data),signal:controller.signal,keepalive:true});if(!response.ok)throw new Error('analytics');return await response.json()}finally{clearTimeout(timeout)}};
function batch(){prune();const items=queue.slice(0,20);for(const item of items)item.tries++;persist();return items.map(item=>item.event)}
function acknowledge(result){const accepted=new Set(result.accepted||[]);for(let i=queue.length-1;i>=0;i--)if(accepted.has(queue[i].event.id))queue.splice(i,1);persist()}
const schedule=()=>{if(timer)return;const tries=queue.length?Math.max(...queue.map(item=>item.tries)):0;timer=setTimeout(()=>{timer=null;flush()},Math.min(30000,500*2**tries)+Math.random()*500)};
function contextBody(events,extra={}){const oldCapture=events.find(event=>event.capture)?.capture;return {browser,...((capture||oldCapture)?{capture:capture||oldCapture}:{}),...(current?{handoff:current.handoff,journey:current.journey}:{}),...extra,events}}
async function connect(extra={}){
const requestedBrowser=browser,events=batch();
try{const result=await post('context',contextBody(events,extra));if(browser!==requestedBrowser)return;token=result.token;contextTry=0;capture=null;acknowledge(result);flush()}catch(_){if(contextTry++<5)setTimeout(()=>connect(extra),Math.min(30000,500*2**contextTry)+Math.random()*500)}
}
async function flush(){
if(sending||!token||!queue.length)return;sending=true;
try{acknowledge(await post('events',{token,events:batch()}))}catch(_){}
sending=false;persist();if(queue.length)schedule();
}
function depart(){
persist();if(!queue.length)return;
const key=queue.map(item=>item.event.id).join(':');if(key===departureKey)return;departureKey=key;
const events=batch();
// fetch keepalive отправляется синхронно; ни pagehide, ни навигация его не ждут.
const delivery=token?post('events',{token,events},true):post('context',contextBody(events),true);
delivery.then(acknowledge).catch(()=>{});
}
function emit(kind,details={},operation=current){
const event={id:uuid(),page_id:page,kind,at:new Date().toISOString(),...(operation?.journey?{journey_id:operation.journey}:(config.operationProof?{operation_proof:config.operationProof}:{})),...(pageCapture?{capture:pageCapture}:{}),...details};
if(JSON.stringify(event).length>1024)return;queue.push({event,tries:0,created:Date.now()});persist();schedule();
}
window.ZoomzillaAnalytics={emit,uuid,
begin(saved){if(saved?.browser&&saved.browser!==browser){browser=saved.browser;token=null;write(local,browserKey,browser);write(session,browserKey,browser)}current=saved||{journey:uuid(),handoff:random(),browser};if(current.browser===browser)connect({handoff:current.handoff,journey:current.journey});return {...current}},
restore(saved){if(saved)this.begin(saved);return current},
metadata(){return current?{...current}:null},
append(data,operation=current){if(operation?.browser===browser){if(token)data.append('analytics_context',token);data.append('analytics_journey',operation.journey);data.append('analytics_handoff',operation.handoff)}},
headers(operation=current){return token&&operation?.browser===browser?{'X-Analytics-Context':token,'X-Analytics-Journey':operation.journey}:{}},
handoff(operation=current){return operation?.handoff||null}
};
let visited=false;
function visible(){if(config.observePage===false||visited||document.visibilityState!=='visible'||!document.body)return;visited=true;emit('visit',config.entryProof?{entry_proof:config.entryProof}:{},null);if(config.join)emit('join_visit',{},null);if(config.accountProof)emit('account_visit',{account_proof:config.accountProof},null)}
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')depart();else{departureKey=null;visible()}});
document.addEventListener('DOMContentLoaded',visible);window.addEventListener('pagehide',depart);
window.addEventListener('pageshow',()=>{restoreQueue();visible();flush()});visible();connect();
})();
JS;
+2
View File
@@ -91,6 +91,7 @@
event.preventDefault();
event.stopPropagation();
if (form.querySelector('.dropzone-form-button')?.disabled) return;
if(event.isTrusted&&window.ZoomzillaAnalyticsConfig?.guestUpload!==false)window.ZoomzillaAnalytics?.emit('upload_click');
other.value = '';
other.click();
});
@@ -117,6 +118,7 @@
const reader = new FileReader();
const timer = window.setTimeout(() => reader.abort(), 30000);
const failed = error => {
if(window.ZoomzillaAnalyticsConfig?.guestUpload!==false)window.ZoomzillaAnalytics?.emit('photo_error',{stage:'read',reason_code:'file_read_failed'});
window.clearTimeout(timer);
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
reject(error);
+20 -11
View File
@@ -108,7 +108,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
let leavingUploadPage = false;
let uploadAttempt = 0;
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm, utmDigits: {...utmDigits}});
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm, utmDigits: {...utmDigits}, analytics: window.ZoomzillaAnalytics?.metadata()});
const clearRecovery = () => recovery.clear().catch(() => {});
let uploadDots = 1;
let uploadDotsTimer = null;
@@ -157,6 +157,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
return;
}
if (e.target !== fileInput) {
if(e.isTrusted)window.ZoomzillaAnalytics?.emit('upload_click');
fileInput.click();
}
});
@@ -193,6 +194,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
});
function handleSelectedFile(file) {
window.ZoomzillaAnalytics?.begin();
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
rejectOversizedFile(file);
return;
@@ -202,9 +204,11 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
}
function redirectToTour(record) {
const azv=window.ZoomzillaAnalytics?.handoff(record.meta.analytics);
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
const redirectQuery = window.ZoomzillaUtmHandoff.query(record.meta, utmDigits);
if (redirectQuery) targetUrl += '&' + redirectQuery;
if(azv)targetUrl+='&azv='+encodeURIComponent(azv);
const shownTheme = window.ZoomzillaTheme.current();
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
@@ -222,6 +226,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
formData.append('original_name', file.name || 'upload');
formData.append('original_size', String(file.size || 0));
formData.append('client_key', clientKey);
window.ZoomzillaAnalytics?.append(formData,record.meta.analytics);
const xhr = new XMLHttpRequest();
xhr.open('POST', record.meta.actionUrl, true);
xhr.withCredentials = true;
@@ -230,12 +235,12 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
if (attempt !== uploadAttempt || leavingUploadPage) return;
if (xhr.status >= 200 && xhr.status < 300) {
try { const result = JSON.parse(xhr.responseText); if (!result?.id) throw new Error(); await recovery.accepted({id: result.id}); redirectToTour(record); }
catch (_) { showUploadError(genericErrorMsg); }
catch (_) { showUploadError(genericErrorMsg, false, false, true, xhr); }
return;
}
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500);
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500, true, xhr);
};
xhr.onerror = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true); };
xhr.onerror = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true, true, xhr); };
xhr.send(formData);
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
}
@@ -249,7 +254,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
const record = saved || await recovery.remember(new File([bytes], file.name, {type: file.type, lastModified: file.lastModified}), metadata());
if (attempt === uploadAttempt && !leavingUploadPage) uploadPreparedFile(file, bytes, record);
})
.catch(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); });
.catch(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, false, false, false); });
}
function uploadPreparedFile(file, bytes, record) {
const attempt = uploadAttempt;
@@ -260,6 +265,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
const clientKey = record.key;
formData.append('file', bytes, file.name || 'upload');
formData.append('client_key', clientKey);
window.ZoomzillaAnalytics?.append(formData,record.meta.analytics);
const actionUrl = record.meta.actionUrl;
xhr.open('POST', actionUrl, true);
@@ -280,13 +286,13 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
try {
response = JSON.parse(xhr.responseText);
} catch (e) {
showUploadError(genericErrorMsg);
showUploadError(genericErrorMsg, false, false, true, xhr);
return;
}
const imageId = response?.id || response?.data?.id || response?.image?.id || response?.image_id;
if (!imageId) {
showUploadError(genericErrorMsg);
showUploadError(genericErrorMsg, false, false, true, xhr);
return;
}
@@ -297,21 +303,23 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
catch (_) { showUploadError(genericErrorMsg); }
catch (_) { showUploadError(genericErrorMsg, false, false, true, xhr); }
} else {
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500);
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500, true, xhr);
}
};
xhr.onerror = () => {
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true);
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true, true, xhr);
};
xhr.send(formData);
}
function showUploadError(msg, keepRecovery = false, connection = false) {
function showUploadError(msg, keepRecovery = false, connection = false, recordAnalytics = true, analyticsRequest = null) {
if (leavingUploadPage) return;
const failureProof=analyticsRequest?.getResponseHeader?.('X-Analytics-Failure');
if(recordAnalytics)window.ZoomzillaAnalytics?.emit('photo_error',{stage:'upload',reason_code:'upload_failed',...(analyticsRequest?.status>=100?{http_status:analyticsRequest.status}:{}),...(failureProof?{failure_proof:failureProof}:{})});
uploadAttempt++;
if (!keepRecovery) clearRecovery();
resetUploadPending();
@@ -333,6 +341,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
(async () => {
try {
const saved = await recovery.load();
window.ZoomzillaAnalytics?.restore(saved?.meta?.analytics);
if (!saved) return;
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); return; }
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
function prelandUtmEnums(): array
{
return ['theme'=>['1'=>'dark','2'=>'light'], 'reg'=>['1'=>'before','2'=>'after'], 'banch'=>['1'=>'b','2'=>'a'], 'focus'=>['1'=>'face','2'=>'center']];
return ['theme'=>['1'=>'dark','2'=>'light'], 'reg'=>['1'=>'before','2'=>'after'], 'banch'=>['1'=>'b','2'=>'a'], 'focus'=>['1'=>'face','2'=>'center'], 'part'=>['1'=>'1','2'=>'2']];
}
/** Совместимость host-config при доставке кода до точечной миграции. */
@@ -36,7 +36,7 @@ function prelandNumericUtmConfig(array $config): array
function prelandResolveUtm(array $query, array $config): array
{
$config=prelandNumericUtmConfig($config);
$defaults=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1'];
$defaults=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1','part'=>'1'];
$enums=prelandUtmEnums(); $digits=[]; $context=$query;
foreach (['registration','bchoice','chob','chb'] as $old) unset($context[$old]);
foreach ($defaults as $key=>$fallback) {
@@ -44,7 +44,7 @@ function prelandResolveUtm(array $query, array $config): array
$settings=$config['utm'][$key] ?? [];
$valid=static fn($digit): bool => preg_match('/^[1-9]$/D',(string)$digit)===1
&& (!isset($enums[$key]) || isset($enums[$key][$digit]))
&& (in_array($key,['cons','theme','reg','banch','focus'],true) || isset($settings['weights'][$digit]));
&& (in_array($key,['cons','theme','reg','banch','focus','part'],true) || isset($settings['weights'][$digit]));
$default=(string)($settings['default'] ?? $fallback);
if (!$valid($default)) $default=$fallback;
$value=$query[$key] ?? null;
+2 -2
View File
@@ -2,8 +2,8 @@
/** api#326: новый шум при каждой навигации; recovery хранит выбранные цифры. */
return <<<'JS'
(function () {
const keys = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
const enums = {theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
const keys = ['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
const enums = {theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'},part:{'1':'1','2':'2'}};
const legacy = {reg:'registration',banch:'bchoice'};
const valid = (key, value) => /^[1-9]$/.test(value || '') && (!enums[key] || Object.values(enums[key]).includes(value));
function restore(meta, defaults) {
+13 -13
View File
@@ -10,7 +10,7 @@
"utm": {
"indpic": {
"weights": {
"1": 1,
"1": 100,
"2": 0
},
"default": "1"
@@ -18,42 +18,42 @@
"theme": {
"weights": {
"2": 0,
"1": 1
"1": 100
},
"default": "1"
},
"degr": {
"weights": {
"1": 1,
"1": 100,
"2": 0
},
"default": "1"
},
"reg": {
"weights": {
"1": 0,
"2": 1
"1": 100,
"2": 0
},
"default": "2"
"default": "1"
},
"banch": {
"weights": {
"2": 1,
"1": 0
"1": 100,
"2": 0
},
"default": "2"
},
"tp": {
"weights": {
"1": 0,
"2": 1
"1": 100,
"2": 0
},
"default": "2"
"default": "1"
},
"focus": {
"weights": {
"2": 0,
"1": 1
"1": 100
},
"default": "1"
},
@@ -62,7 +62,7 @@
"1": 100,
"2": 0
},
"default": "2"
"default": "1"
}
}
}
@@ -0,0 +1,118 @@
# preland#22 — план реализации единой админки
Спецификация: ../specs/2026-10-25-preland22-admin-design.md; основной реестр preland#22.
Владелец 25.10 поручил выполнение командой до production без повторного подтверждения.
Метод: writing-plans, dispatching-parallel-agents для независимых файловых областей,
TDD, систематическая диагностика при сбоях, независимое review и verification-before-completion.
Работа в уже изолированном wt/preland21; исходные checkout/ветки не переключаются.
**Цель:** безопасно редактировать поля runtime-конфигов и публиковать indpic на выбранных доменах.
**Архитектура:** отдельное PHP-приложение admin/, прямые local/SFTP+SSH-адаптеры,
приватный файловый реестр/очередь заданий, повторное использование CPanel indpic UI/валидатора.
**Стек:** PHP8.3, phpseclib3, Imagick/ZIP, Apache, отдельный CLI-worker, Docker, nginx/TLS.
Python используется в инструментах QA/инфраструктуры, Yii2 — источник совместимого контракта,
новые прикладные микросервисы без функциональной причины не добавляются.
## Общие ограничения и карта поверхности
- Русский интерфейс/документация, все поля config через формы, JSON-editor отсутствует.
- Четыре начальные цели, произвольный дальнейший реестр; .online кроме stage не включать.
- Изменения только выбранных полей/целей, конфликты версий, резерв вне webroot,
атомарная публикация и повторное чтение, статусы/повтор каждой цели независимо.
- Секреты только вне Git/webroot; ключ root приложению не передаётся.
- indpic1–9, ZIP и 90файлов проверяются действующим валидатором CPanel.
- Сохранить runtime config и /N/ при кодовом deploy; guard runtime проверяет контракт,
а статические ресурсы остаются привязаны к manifest Git.
- Все зависимости/тесты в Docker. Browser plugin not available: реальный UI в Docker Playwright.
- Карта UI: login, домены/подключения, настройки/доп.поля, выбор целей/полей,
задания/частичный успех/конфликт/повтор, резерв/восстановление, indpic ZIP/SFTP/default.
## Общий HTTP-контракт для независимой реализации
Все ответы JSON: {ok:true,...} либо {ok:false,error:"понятный текст"}; безопасные HTTP-коды.
POST/PUT/DELETE требуют header X-CSRF-Token после login; multipart также использует header.
- POST /api/login {username,password}; GET /api/state → {csrf,user,targets}; POST /api/logout.
- targets имеют id,name,domain,environment,type(local|sftp),host,port,user,root,host_key,
enabled,has_secret; сервер никогда не возвращает secret/private_key.
- POST /api/targets {target:{...},secret?:string}; DELETE /api/targets {id};
POST /api/targets/test {id} проверяет подключение и безопасные пути.
- GET /api/config?target=id → {config,version,warnings,sets}; version=SHA256 текущего файла.
- POST /api/apply {targets:[{id,version}],changes:[{path:[string...],value:any,delete?:bool}]}
→ {job}; версии каждой цели берутся её собственным чтением, не от исходного домена.
- GET /api/jobs?id=id → {job}; без id → {jobs}; POST /api/jobs/retry {id,targets:[id...]} → {job}.
job={id,status,results:[{target,status,message,version?}],...}; статусы queued,running,
success,error,conflict,unknown; статус задания queued,running,completed.
- GET /api/backups?target=id → {backups:[{id,created_at}]};
POST /api/restore {target,id,version} → {job}.
- GET /api/sets?target=id → {sets:[{number,name,exists,default,in_use}],version};
POST /api/sets/upload multipart file,number,name,targets(JSON-массив id) → {job};
POST /api/sets/check {targets:[id],number} → {job}; POST /api/sets/register аналогично +name;
POST /api/sets/name {target,number,name}; POST /api/sets/delete {target,number};
default задаётся /api/apply по path utm/indpic/default и текущей version.
Технические расширения контракта допустимы с синхронизацией агентов и записью в #22.
## Задача 1 (#22): серверная логика и файловый контракт
Владелец: backend (Yii2 senior/PHP senior, security/transport).
Файлы: admin/src/*, admin/public/index.php, admin/bootstrap.php,
admin/bin/*, admin/composer.json/lock, admin/tests/backend*.php; не менять UI/Docker/CI.
- [ ] RED: выбранный patch сохраняет поля/типы, чужая версия не затирается,
массовая ошибка одной цели не откатывает другие, резерв восстанавливается по версии.
- [ ] Реализовать auth/CSRF, защищённый реестр соединений, транспорт local и SFTP/SSH,
серверные задания/worker, конфиги/валидацию/историю и HTTP-контракт выше.
- [ ] Перенести IndpicSetStorage с атрибуцией; проверять ZIP до доставки, затем хеши,
публикацию/откат; регистрацию SFTP-набора, имена/default/удаление из реестра.
- [ ] GREEN в Docker, реальные подключения через локальную SFTP-цель, auth/error/race/ZIP tests.
## Задача 2 (#22): интерфейс на основе CPanel
Владелец: frontend senior, дизайнер, верстальщик.
Файлы: admin/views/dashboard.php, admin/public/assets/*, admin/tests/ui*.cjs.
Вход public/index.php рендерит dashboard.php для обычных URL; API выше.
- [ ] Форма входа, домены, редактор всех config-полей и UTM, один/несколько выбранных
доменов и только изменённые поля; версии до редактирования, без silent overwrite.
- [ ] Таблица/действия indpic копируют CPanel, включая ZIP/SFTP и понятное окно проверки.
- [ ] Подключения/история/резерв, задания/частичные ошибки/повтор, loading/error/recovery;
безопасный текстовый вывод любых серверных полей, desktop/mobile/keyboard.
- [ ] Реальные browser сценарии login→чтение→ввод→выбор2целей→применить→reload;
ошибка/восстановление/ZIP; сохранить screenshot и console evidence вне repo.
## Задача 3 (#22): runtime guard и доставка
Владелец: DevOps/QA (независимо от admin/).
Файлы: bin/release-assets.php, новый bin/check-runtime-sets.php, tests/check-runtime-sets.php,
.gitea/workflows/deploy.yml, AGENTS.md/CLAUDE.md/README.md только относящиеся части.
- [ ] RED: корректно заменённый runtime-набор не требует Git; повреждённый активный
набор/невалидный default выявляется; общая проверка статики не ослабевает.
- [ ] Изменить --stage-sets/--remote на контрактную проверку runtime /N/ и manifest
только кода/поставляемых ресурсов; admin/ и приватные файлы не входят в preland releaseFiles.
- [ ] GREEN в Docker + старые preland tests/manifest; документация обновляет #11 явно.
## Задача 4 (#22): интеграция, инфраструктура и приёмка
Владелец: ведущий агент (Senior DevOps/Linux/Windows, QA и release).
Файлы: admin/Dockerfile, admin/compose.yaml, admin/deploy/*, admin/README.md,
локальные output/preland22/*; KB Спеки/Планы/Инфраструктура и индекс.
- [ ] Создать образ/локальный стенд и SSH/SFTP fixture, запустить backend/guard тесты.
- [ ] Реальный UI-клик в Docker Playwright 1440/390: все общие состояния и контракты
из спецификации, с отдельными read/save/conflict/partial/retry/ZIP свидетельствами.
- [ ] Независимое review всего diff; исправить существенные замечания, повторить
затронутые сценарии, подготовить rollback и неизменяемую версию образа.
- [ ] Настроить admin.zoomstage.online DNS/TLS/nginx, ограниченные local mount/SFTP
доступы, секреты в приватном состоянии, отдельный worker; текущий трафик не прерывать.
- [ ] Проверить на установленной админке вход/чтение всех4целей, реальный безопасный
write/read/ZIP/error/recovery с возвратом исходных runtime; никаких ложных PASS.
- [ ] Опубликовать код и KB, занести итог/проверки/версии/ограничения в #22, закрыть
только после завершения. Неисполненные обязательные сценарии оставить открытыми.
## Особое внимание review
1. Разные версии/типы/legacy-alias четырёх файлов и потеря неизвестных полей — тест задачи1.
2. Таймаут после фактической записи/обновление страницы — задания/повтор задачи1+2.
3. Повреждённый ZIP/размер/traversal/лимит/ошибка активации — задача1 и UI задачи2.
4. Подмена пути/host key, утечка пароля, CSRF/сессия и незавершённые задания — задача1/4.
5. Runtime-изменения не должны блокировать выпуск или публиковать admin-секреты — задача3/4.
@@ -0,0 +1,185 @@
# Единая админка прелендингов — preland#22
Дата: 25.10.2026. Владелец поручил выполнение до production без повторных подтверждений;
реализация и приёмка ведутся по preland#22. Фактический выпуск отражается в задаче и KB.
Реестр: http://git.dira89kv.beget.tech:3000/anton/preland/issues/22.
Размещение и доступы: preland#21. Секреты в документ не входят.
## Задача владельца
Одна защищённая админка на поддомене stage управляет фактическими config.json и
наборами indpic четырёх независимых прелендингов. Редактирование и публикация контента
проходят без Git, ручной работы с JSON и разноса файлов через FTP-клиент.
Домены и хостинги можно добавлять, менять и отключать через эту же админку.
Начальные цели: zoomila.ru, zoomika.ru, zoompixel.ru, zoomstage.online.
Отключённые .online не подключаются. Рабочее имя админки: admin.zoomstage.online.
Размещение на отдельном поддомене и повторное использование редактора indpic CPanel
поручены владельцем; конкретное имя admin и технические решения ниже предложены агентом.
## Подход
Предлагается отдельное PHP-приложение в репозитории preland, с собственным Docker-образом
и отдельной поставкой. Оно использует SFTP/SSH, а для каталогов на том же сервере —
ограниченный файловый адаптер. Работа с конфигом не требует запуска деплоя.
Управление не публикуется внутри поставки обычных прелендингов.
Перенос всей Yii2 CPanel потребовал бы базы и зависимостей основного сайта; его API
и релиз стали бы зависимостью обычного изменения настроек. Поэтому переносится
конкретный редактор наборов, его внешний вид, поведение и файловый валидатор,
а зависимые от БД операции заменяются файловым реестром админки.
Клиентский FTP из браузера не подходит для серверных проверок и хранения доступов.
Источник переиспользования: api origin/master 591a67cc8302,
components/indpic/IndpicSetStorage.php и indpic-блок
modules/cpanel/views/settings/index.php, соответствующие действия SettingsController.
Копия снабжается ссылкой на источник и совместимыми контрактными тестами.
Бизнес-логика основного API, платежи, PIN и маршруты основного сайта не меняются.
## Экран настроек
Список доменов показывает stage/production, состояние соединения и последнее чтение.
Открытие домена читает текущий удалённый файл; локальная копия не считается источником
истины. При недоступности хоста сохранение в него недоступно, другие цели работают.
Вместо текстового JSON — поля адреса основного сайта, строки реквизитов и восемь
групп UTM: indpic, theme, degr, reg, banch, tp, cons, focus. Для каждой группы —
вариант по умолчанию, таблица вариантов и неотрицательные веса, пояснения значений.
Поддерживаются добавление и удаление строк весов. Реквизиты редактируются строками.
Дополнительные уже существующие поля показываются типизированными вложенными полями
(текст, число, переключатель, массив, объект), без редактора JSON. Отсутствующие
неизвестные поля не создаются автоматически. Секреты не выводятся в диагностику.
Текущий zoompixel.ru может содержать старые semantic-ключи/значения. Они отображаются
понятными вариантами через действующий utm-codec; сохранение меняет только выбранные
поля. Неизменённые поля, их типы и значения сохраняются. Конфликтующие старый и новый
ключи нельзя молча объединять: показывается понятная ошибка до записи.
После редактирования пользователь отмечает один или несколько доменов и нажимает
«Применить к выбранным». Перед кнопкой видны выбранные поля и домены. По умолчанию
отмечен только открытый домен; нет скрытого автоматического выбора всех целей.
Распространяются только изменённые/явно выбранные поля, остальные настройки каждой
цели сохраняются. Можно явно выбрать целую группу UTM или все поля формы.
Если значения между доменами отличаются, показывается «Разные значения»; без явного
ввода пользователя это состояние не превращается в новое значение.
До публикации для каждой цели проверяются типы/структура, допустимые UTM 1–9,
варианты theme/reg/banch/focus, веса, существование indpic для default и ненулевых
весов. Для degr/tp/cons используются номера групп, проверяемые по доступным данным
основного сайта. Если каталог доступен, неизвестный номер блокируется. Если каталог
недоступен, разрешён ручной выбор номера 1–9 с явной пометкой «Существование группы
на основном сайте не проверено» в форме и результате; сохранение не выдаётся за
проверку API. Это решение исполнителя сохраняет независимость редактора от API;
соответствующие состояния включены в приёмку. Выдуманного списка групп нет.
Сумма весов может быть нулевой: действующий generic rnd выбирает первый ключ weights
по порядку config.json (при пустой таблице — default); cons отдельно использует default.
Назначаемый default indpic/degr/tp должен присутствовать в таблице весов, в том числе
с нулевым весом. Для indpic проверяются все явно настроенные номера: нулевой вес не
отменяет доступность номера по прямому UTM. Адрес сайта — допустимый HTTPS-хост без пути.
## Сохранение и массовые операции
Каждая форма хранит версию прочитанного файла (SHA-256). Непосредственно перед записью
проверяется текущая версия под блокировкой публикации. Если файл изменился снаружи,
цель получает конфликт и предложение перечитать/повторно применить изменения.
Чужая версия не затирается. Успешная запись: резерв вне webroot, временный файл,
проверка JSON, атомарная замена и повторное чтение/сверка. Протокол и адаптер должны
поддерживать этот контракт, иначе соединение нельзя включить для записи.
SSH доступен на начальных REG.RU-хостах и позволяет выполнить проверку/замену под
удалённой блокировкой; одной локальной блокировки админки недостаточно.
Массовое применение — независимые операции с отдельными статусами: ожидание,
выполняется, сохранено и проверено, конфликт, ошибка, неизвестный результат.
Сбой одной цели не отменяет уже успешные и не мешает остальным. Общего обещания
«всё сохранено» при частичном успехе нет. Повтор выполняется только для выбранных
неуспешных целей; после таймаута сначала перечитывается фактическое состояние.
Задание и результаты сохраняются на сервере, обновление страницы их не теряет.
Есть журнал с доменом, временем, изменёнными полями и исходом без паролей.
Восстановление выбранной резервной версии проходит ту же защиту от конфликта.
## Наборы indpic
Переиспользуется таблица CPanel: номер/название, default, проверка, ZIP, действия;
добавление ZIP либо проверка набора, уже загруженного по SFTP, модальное окно результата.
Номер для прелендинга ограничен 1–9 действующим внешним UTM-контрактом.
Название хранится в приватном реестре админки, default/веса — в config.json цели.
Один архив можно отправить на один или несколько выбранных доменов. Проверка до
доставки: каталог номера набора и ровно 90 обязательных изображений, отсутствие
лишних/повторных/вложенных/опасных путей, чтение каждого изображения и размеры
из существующего IndpicSetStorage. Лимиты ZIP, распаковки и времени задаются сервером.
Проверенный набор передаётся в непубличную подготовительную папку на целевом хосте,
проверяются все файлы/SHA-256, затем переключается каталог с резервом и откатом при
ошибке. Для exact local bind mount подготовка находится в скрытой папке того же mount
с правами0700, публичный Apache работает другим UID; обязательны прямой и внешний
HTTP403 probe. Резерв находится в приватном state вне webroot; remote SFTP использует
sibling-папку. Папки подготовки/резерва никогда не обслуживаются веб-сервером.
Переключение имеет два rename и durable journal: живой supervisor восстанавливает
каталог после аварии child, перезапуск — при следующей операции транспорта. Это
восстановление после прерывания с кратким окном, а не Linux atomic exchange.
Для уже загруженного по SFTP набора файлы проверяются тем же валидатором.
Загрузка не меняет default и веса автоматически. Назначение default — явная операция
сохранения config.json. Нельзя активировать неполный набор. Замена существующего
набора предварительно предупреждает о текущем использовании. Удаление из реестра,
как в CPanel, оставляет файлы; нельзя убрать набор, используемый default или весом>0.
Удаление физического контента не входит в исходное поручение.
## Подключения и защита
Отдельный вход по логину/паролю, хеш пароля, HTTPS, HttpOnly/SameSite session,
CSRF, ограничение попыток. Админка использует отдельные доступы ограниченного объёма;
приватный ключ root основного сервера приложению не передаётся.
Список целей, их имена, окружение, хост/порт, пользователь, корневая папка,
проверенный ключ SFTP и секрет редактируются на странице подключений.
Секрет можно заменить, но нельзя прочитать обратно в интерфейсе.
Проверка соединения должна подтвердить права и реальные пути до включения записи.
Приватное состояние, ключи, резервные копии, архивы и журнал — вне webroot/Git,
с ограниченными правами и персистентными Docker-томами. Удаление подключения отключает
управление и убирает его из списка целей, не удаляя сайт и контент хостинга.
Одновременные операции на одном конфиге/наборе сериализуются. Приложение не выполняет
произвольные команды, пути ограничены каталогом выбранной цели, host key проверяется.
## Независимость контента от Git и выпуск
Текущий workflow не заливает /N/, но --stage-sets сравнивает их с Git, а --remote
также включает их в общее сравнение. После замены существующего набора это блокировало
бы последующий выпуск кода. В #22 разделить проверки: поставляемый код/статические
ресурсы проверяются по manifest Git, runtime-наборы — по фактическому контракту 90
изображений и действующим ссылкам config, без требования импорта в Git.
Проверки не выключаются; тестами доказывается, что корректная замена набора пропускается,
повреждённый активный набор блокирует проверку, кодовой deploy не меняет config и /N/.
Устаревшие требования обязательной синхронизации наборов в Git обновляются явно по #22.
Админка выпускается отдельно от прелендов, с TLS на admin.zoomstage.online и четырьмя
подключениями. Изменение UTM пользователем не вызывает commit/push/CI. Реальные
production-настройки/наборы при установке не меняются. Проверочные записи — в выделенной
тестовой цели/наборе с последующим контролируемым восстановлением; отсутствие доступа
или непроверенный сценарий оставляет #22 открытой.
## Приёмка
В Docker: контракт валидатора CPanel, JSON-типы/semantic-совместимость, патч только
выбранных полей, конфликт и два одновременных сохранения, резерв/откат, разрыв связи,
таймаут после записи, повтор задания. Локальная SFTP-цель проверяет настоящий протокол.
ZIP: корректный набор, отсутствующий файл, размер/формат, лишний/повторный путь,
traversal, лимиты, ошибка публикации и сохранение старого рабочего набора.
В штатном браузере: вход, открыть/изменить/сохранить/перезагрузить конфиг; разные
значения, выбор одного/двух доменов и только нужных полей; частичный отказ, повтор,
конфликт и восстановление; ZIP и ошибка с последующей успешной загрузкой; управление
подключениями. Desktop/mobile и матрица всех общих состояний/четырёх целей.
На установленной админке: TLS/авторизация, чтение всех четырёх целей, безопасный
реальный цикл записи/повторного чтения и загрузки на тестовых данных. Итог, версия,
проверки и ограничения записываются в #22 и KB до закрытия задачи.
## Решения исполнителя и незавершённое
На усмотрение исполнителя: PHP-библиотека SFTP, детали приватного файлового реестра,
лимиты и хранение заданий, распараллеливание в пределах ресурсов. Выбор подтверждается
тестами и совместимостью с существующим хостингом; установки на Windows-хост нет.
Согласование выполнения получено25.10. Код и проверки фиксируются в задачах плана;
неполученный доступ, непроверенный обязательный сценарий и незавершённый выпуск
не скрываются закрытием задачи. Исторический успешный SFTP-вход не доказывает
готовность центральной админки. Runtime/секреты проверяются на фактической среде.
+1 -1
View File
@@ -17,7 +17,7 @@ Yii::init($config['default_language'] ?? 'ru');
$config = prelandNumericUtmConfig($config);
$resolvedUtm = prelandResolveUtm($_GET, $config);
$UTM = $resolvedUtm['context'];
$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','registration','bchoice','chob','chb','key'], true));
$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part','registration','bchoice','chob','chb','key','azv'], true));
// Числа — внутренний snapshot для recovery. Публичная оболочка создаётся при навигации.
$redirect_digits = $resolvedUtm['digits'];
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
+83
View File
@@ -0,0 +1,83 @@
/* api#353 / Preland: согласованный светлый канон PDF/guest-quota.
* Геометрию, состояния и тёмную тему задают существующие компоненты.
* Этот файл совпадает с popup-theme.css прелендингов. */
body:not(.theme-dark) {
--popup-surface: linear-gradient(180deg, #edf4fd, #faf0fa);
--popup-title: #202334;
--popup-text: #4b4f61;
--popup-focus: #6439aa;
--popup-shadow: 0 24px 80px rgb(25 31 49 / 18%);
--popup-border: rgb(25 31 49 / 12%);
}
body:not(.theme-dark) .input-choice-popup,
body:not(.theme-dark) .upload-error-popup,
body:not(.theme-dark) .dp-overlay {
background: rgb(25 31 49 / 35%);
}
body:not(.theme-dark) .modal-backdrop { background: #191f31; }
body:not(.theme-dark) .modal-backdrop.show { opacity: .35; }
body:not(.theme-dark) .input-choice-popup .input-choice-card,
body:not(.theme-dark) .upload-error-popup .upload-error-card,
body:not(.theme-dark) .modal-payment .modal-content,
body:not(.theme-dark) .dp-dialog {
background: var(--popup-surface);
color: var(--popup-title);
color-scheme: light;
border-color: var(--popup-border);
box-shadow: var(--popup-shadow);
}
body:not(.theme-dark) .input-choice-popup h2,
body:not(.theme-dark) .input-choice-popup .input-choice-option,
body:not(.theme-dark) .modal-payment .close,
body:not(.theme-dark) .dp-close,
body:not(.theme-dark) .dp-retry {
color: var(--popup-title);
text-shadow: none;
}
body:not(.theme-dark) .input-choice-popup .input-choice-lead,
body:not(.theme-dark) .input-choice-popup .input-choice-or,
body:not(.theme-dark) .upload-error-popup p,
body:not(.theme-dark) .upload-error-popup .upload-error-close,
body:not(.theme-dark) .payment-modal-message-text span {
color: var(--popup-text);
}
body:not(.theme-dark) .input-choice-popup .input-choice-other {
color: #007bff;
}
body:not(.theme-dark) .input-choice-popup .input-choice-error {
color: #c8322b;
}
body:not(.theme-dark) .payment-modal-link {
color: var(--popup-title);
background: rgb(255 255 255 / 80%);
}
body:not(.theme-dark) .payment-modal-link:hover {
color: var(--popup-title);
background: #fff;
}
body:not(.theme-dark) .input-choice-popup button:focus-visible,
body:not(.theme-dark) .input-choice-popup input:focus-visible,
body:not(.theme-dark) .upload-error-popup button:focus-visible,
body:not(.theme-dark) .upload-error-popup a:focus-visible,
body:not(.theme-dark) .modal-payment button:focus-visible,
body:not(.theme-dark) .modal-payment a:focus-visible,
body:not(.theme-dark) .dp-dialog button:focus-visible {
outline: 2px solid var(--popup-focus);
outline-offset: 3px;
}
body:not(.theme-dark) .dp-input {
color: var(--popup-title);
background: #fff;
border-color: #ced4da;
}
body:not(.theme-dark) .dp-input::placeholder { color: #6c757d; }
body:not(.theme-dark) .dp-input:focus { border-color: #3d87e3; }
body:not(.theme-dark) .dp-input:focus-visible {
outline: 2px solid var(--popup-focus);
outline-offset: 2px;
}
body:not(.theme-dark) .dp-input.dp-invalid { border-color: #c8322b; }
+4
View File
@@ -3,6 +3,10 @@
"captured": "2026-10-21",
"hashing": "SHA-256; SVG/CSS/HTML/webmanifest CRLF normalized to LF",
"files": {
"popup-theme.css": {
"sha256": "4d8ed6e142dacf34da6d61394c8dee0297309a8eb570000cd8549bc0c920df4c",
"source": "api#353: approved popup light theme"
},
"1/01_desk_1-source.avif": {
"sha256": "d81af6d3757d0e932af04d956cd4083e7c7c547c9fbac290d27f5ce8bbaede1c",
"source": "https://zoomstage.online/1/01_desk_1-source.avif"
+2
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<?php require __DIR__ . '/components/theme-style.php'; ?>
<?php require __DIR__ . '/components/theme-client.php'; ?>
<script>window.ZoomzillaAnalyticsConfig={endpoint:<?= json_encode($apiHost) ?>};<?= require __DIR__.'/components/analytics-client.php' ?></script>
<title><?= $page_title ?? $default_title ?></title>
@@ -50,6 +51,7 @@
<link rel="preload" as="image" href="<?= $urlResOrig ?>" media="(min-width: 768px)" type="image/avif" fetchpriority="high">
<style><?= file_get_contents(__DIR__ . '/style.min.css'); ?></style>
<style><?= file_get_contents(__DIR__ . '/popup-theme.css'); ?></style>
<style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style>
<?php require_once __DIR__ . '/components/browser-upload.php'; ?>
</head>
+11 -3
View File
@@ -50,9 +50,9 @@ foreach ([
$workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
foreach ([
'"$PRELAND_URL/1/01_desk_1-source.avif"',
'"$PRELAND_URL/2/01_desk_1-source.avif"',
'2/01_mob_1-source.avif',
'php bin/release-assets.php --stage-sets "$PRELAND_URL"',
'php bin/release-assets.php --remote "$PRELAND_URL"',
'php-imagick',
'Remove obsolete demo directories',
] as $requiredStageSmokeCheck) {
if (!str_contains($workflow, $requiredStageSmokeCheck)) {
@@ -60,6 +60,14 @@ foreach ([
}
}
// #22: реальный runtime config выбирает наборы; удаление неиспользуемого /2/
// не должно блокировать выпуск. Обязательные файлы/размеры проверяет guard.
require_once $root . '/bin/release-assets.php';
foreach (releaseFiles($root) as $file) {
if (preg_match('~^(admin/|config\.json$|[0-9]+/)~', $file)) $failures[] = 'Runtime/приватный файл в поставке: ' . $file;
}
if (runtimeSetNumbers(['utm' => ['indpic' => ['default' => '3', 'weights' => ['3' => 1]]]]) !== [3]) $failures[] = 'Наборы должны определяться фактическим config';
if (!str_contains($workflow, '|[0-9]+/)')) {
$failures[] = 'Stage-деплой не должен перезаписывать корневые наборы /N/: stage владеет их содержимым.';
}
+17
View File
@@ -42,5 +42,22 @@ try{
exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file).' 2>/dev/null',$out,$code);
if($code!==2||file_get_contents($file)!==$conflict)throw new RuntimeException('api#327: конфликт aliases должен блокировать patch без записи');
}
// preland#24: публикация старого chb запрещена даже при enum migration.
foreach([
'{"utm":{"theme":{"weights":{"dark":1},"default":"dark"},"chb":{"default":"1"}}}',
'{"utm":{"theme":{"weights":{"1":1},"default":"1"},"chb":null}}',
'{"utm":{"c\u0068b":null}}',
'{"utm":{"chb":{},"chb":null}}',
'{"utm":{"chb":null},"utm":{"theme":{"weights":{"dark":1},"default":"dark"}}}',
] as $obsolete){
file_put_contents($file,$obsolete);$stderr=$file.'.stderr';
exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file).' 2>'.escapeshellarg($stderr),$out,$code);
$message=file_get_contents($stderr);unlink($stderr);
if($code!==2||file_get_contents($file)!==$obsolete||!str_contains($message,'utm.chb'))throw new RuntimeException('preland#24: chb должен явно блокировать публикацию без изменения байтов');
}
$nested='{ "chb":null,"utm":{"custom":{"chb":null},"theme":{"weights":{"dark":1.0},"default":"dark"}} }';
file_put_contents($file,$nested);exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file),$out,$code);
$expectedNested=str_replace(['"dark":','"default":"dark"'],['"1":','"default":"1"'],$nested);
if($code!==10||file_get_contents($file)!==$expectedNested||property_exists(json_decode(file_get_contents($file))->utm,'cons'))throw new RuntimeException('preland#24: соседние chb/unknown сохраняются, отсутствующий cons не добавляется');
echo "OK: numeric config migration preserves weights/default/foreign JSON types\n";
}finally{unlink($file);}
+19 -3
View File
@@ -17,13 +17,29 @@ foreach(['2','rnd','dark','abcdx','abc',[],null,'abcd0'] as $value) {
if(prelandResolveUtm(["theme"=>"абвг2"],$config)["digits"]["theme"]!=="2")throw new RuntimeException("Unicode fifth symbol");
for($i=0;$i<50;$i++) if(prelandResolveUtm(['indpic'=>'abcd0'],$config)['digits']['indpic']!=='2')throw new RuntimeException('0 нарушил веса');
$settings=json_decode(file_get_contents(dirname(__DIR__).'/config.json'),true);
$defaults=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'2','cons'=>'2','focus'=>'1'];
// Ожидаем настройки владельца, а не прежний Git-снимок. Отсутствующая группа использует продуктовый fallback.
$defaults=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1','part'=>'1'];
foreach ($defaults as $key=>$fallback) $defaults[$key]=(string)($settings['utm'][$key]['default']??$fallback);
// Контрольные конфиги независимо закрепляют оба default и не зависят от текущего host config.
foreach (['1','2'] as $default) {
foreach (array_keys($defaults) as $key) {
$fixture=['utm'=>[$key=>['default'=>$default,'weights'=>['1'=>0,'2'=>100]]]];
foreach (['1','2','rnd','abcdx','abc',[],null,"\xffabcd2"] as $invalid) {
if(prelandResolveUtm([$key=>$invalid],$fixture)['digits'][$key]!==$default)throw new RuntimeException('Контрольный invalid URL изменил default '.$key.'='.$default);
}
foreach (['1','2'] as $digit) if(prelandResolveUtm([$key=>'abcd'.$digit],$fixture)['digits'][$key]!==$digit)throw new RuntimeException('Контрольный concrete URL изменил '.$key.'='.$digit);
}
foreach (['abcd1','1','b',[]] as $legacy) {
$resolved=prelandResolveUtm(['chob'=>$legacy,'bchoice'=>'b','chb'=>'a'],['utm'=>['banch'=>['default'=>$default,'weights'=>['1'=>0,'2'=>100]]]]);
if($resolved['digits']['banch']!==$default||isset($resolved['context']['chob'])||isset($resolved['context']['chb']))throw new RuntimeException('Legacy URL изменил контрольный banch default '.$default);
}
}
foreach(array_keys($defaults) as $key) foreach(['1','rnd','abcdx','abc',[],null,"\xffabcd2"] as $invalid) {
$resolved=prelandResolveUtm([$key=>$invalid,'registration'=>'before','bchoice'=>'b','chb'=>'a'],$settings);
if($resolved['digits'][$key]!==$defaults[$key])throw new RuntimeException('Открытый/invalid URL изменил '.$key);
if(isset($resolved['context']['chb']))throw new RuntimeException('Legacy chb прошёл как foreign');
}
if(prelandResolveUtm(['tp'=>'abcd9'],$settings)['digits']['tp']!=='2')throw new RuntimeException('Ненастроенная группа tp должна fallback');
if(prelandResolveUtm(['tp'=>'abcd9'],['utm'=>['tp'=>['default'=>'2','weights'=>['1'=>100,'2'=>0]]]])['digits']['tp']!=='2')throw new RuntimeException('Ненастроенная группа tp должна fallback');
if(prelandResolveUtm(['cons'=>'abcd9'],$settings)['digits']['cons']!=='9')throw new RuntimeException('Cons concrete не зависит от random weights');
$zero=['utm'=>['theme'=>['weights'=>['2'=>0,'1'=>0],'default'=>'1'],'cons'=>['weights'=>['1'=>0,'2'=>0],'default'=>'2']]];
if(prelandResolveUtm(['theme'=>'abcd0'],$zero)['digits']['theme']!=='2')throw new RuntimeException('Generic zero weights обязаны выбрать первый ключ, как до #326');
@@ -31,7 +47,7 @@ if(prelandResolveUtm(['cons'=>'abcd0'],$zero)['digits']['cons']!=='2')throw new
echo "OK: numeric public boundary, defaults, weighted0, foreign values\n";
foreach (['abcd1','1','b',[]] as $old) {
$resolved=prelandResolveUtm(['chob'=>$old,'bchoice'=>'b','chb'=>'a'],$settings);
if($resolved['digits']['banch']!=='2'||isset($resolved['context']['chob']))throw new RuntimeException('api#327: внешний chob должен игнорироваться и удаляться');
if($resolved['digits']['banch']!==$defaults['banch']||isset($resolved['context']['chob']))throw new RuntimeException('api#327: внешний chob должен игнорироваться и удаляться');
}
$oldConfig=['utm'=>['chob'=>['weights'=>['1'=>5,'2'=>0],'default'=>'1']]];
if(prelandResolveUtm([],$oldConfig)['digits']['banch']!=='1')throw new RuntimeException('api#327: старый host config обязан работать до миграции');
+22
View File
@@ -0,0 +1,22 @@
<?php
/** api#347: part задаёт передаваемый вариант, но никогда не добавляет ссылку на Preland. */
declare(strict_types=1);
require dirname(__DIR__) . '/components/utm-codec.php';
function partCheck(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); }
foreach (['abcd1'=>'1','abcd2'=>'2','абвг2'=>'2','abcd0'=>'1','abcd9'=>'1','2'=>'1','abcdx'=>'1','abc'=>'1'] as $raw=>$expected) {
$value=prelandResolveUtm(['part'=>$raw,'utm_source'=>'keep'],[]);
partCheck(($value['digits']['part']??null)===$expected && ($value['context']['part']??null)===$expected, 'part: fifth symbol / default1: '.$raw);
partCheck($value['context']['utm_source']==='keep','foreign query preserved');
}
foreach ([[],null, false, "\xffabcd2"] as $invalid) partCheck((prelandResolveUtm(['part'=>$invalid],[])['digits']['part']??null)==='1','invalid fallback');
$config=['utm'=>['part'=>['default'=>'2','weights'=>['1'=>0,'2'=>100]]]];
for($i=0;$i<200;$i++) partCheck(prelandResolveUtm(['part'=>'abcd0'],$config)['digits']['part']==='2','random follows configured weights');
partCheck(prelandResolveUtm(['part'=>'abcd1'],$config)['digits']['part']==='1','concrete independent of weights');
partCheck(prelandResolveUtm([],['utm'=>['part'=>['default'=>'2']]])['digits']['part']==='2','configured default');
partCheck(prelandResolveUtm(['part'=>'abcd0'],['utm'=>['part'=>['default'=>'1','weights'=>['2'=>0,'1'=>0]]]])['digits']['part']==='2','existing generic zero weights order');
$_GET=['part'=>'abcd2','utm_source'=>'keep'];$_SERVER['HTTP_HOST']='zoomstage.online';
ob_start();require dirname(__DIR__).'/index.php';$html=ob_get_clean();
partCheck(!array_key_exists('part',$get_params),'part is not foreign query');
partCheck($redirect_digits['part']==='2','handoff snapshot part');
echo "OK: part numeric/default/random/context/foreign boundary\n";
+3 -3
View File
@@ -1,16 +1,16 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
foreach (['light', 'dark'] as $theme) foreach ([
foreach (['abcd1','abcd2','abcd0','invalid'] as $part) foreach (['light', 'dark'] as $theme) foreach ([
'zoomstage.online' => 'zoomstage.online',
'New-Preland.example:8080' => 'new-preland.example',
'www.example.test.' => 'www.example.test',
'<script>alert(1)</script>' => 'fixture.local',
] as $host => $expected) {
$html = shell_exec(PHP_BINARY . ' ' . escapeshellarg(__DIR__ . '/render-preland.php') . ' ' . escapeshellarg($host) . ' ' . escapeshellarg($theme));
$html = shell_exec(PHP_BINARY . ' ' . escapeshellarg(__DIR__ . '/render-preland.php') . ' ' . escapeshellarg($host) . ' ' . escapeshellarg($theme) . ' ' . escapeshellarg($part));
if (!preg_match('~<footer\b.*?</footer>~s', $html, $match)) throw new RuntimeException('Подвал не найден');
$footer = $match[0];
foreach (['Наши реквизиты', 'payment.svg', 'ИНН:', 'ОГРНИП:', 'ИП Иванов', 'ИП Кардаполова'] as $forbidden) {
foreach (['Наши реквизиты', 'payment.svg', 'ИНН:', 'ОГРНИП:', 'ИП Иванов', 'ИП Кардаполова', 'Для бизнеса', '/public/api-pricing'] as $forbidden) {
if (str_contains($footer, $forbidden)) throw new RuntimeException('В подвале остался ' . $forbidden);
}
if (!str_contains($footer, date('Y') . '© ' . $expected)) throw new RuntimeException('Неправильный домен копирайта: ' . $host);
+54
View File
@@ -0,0 +1,54 @@
<?php
/** preland#22: runtime-контент независим от Git, контракт изображений обязателен. */
declare(strict_types=1);
require_once dirname(__DIR__) . '/bin/check-runtime-sets.php';
require_once dirname(__DIR__) . '/bin/release-assets.php';
$root = dirname(__DIR__);
$fixture = sys_get_temp_dir() . '/preland22-' . bin2hex(random_bytes(6));
mkdir($fixture);
mkdir($fixture . '/1');
$assertFails = static function (callable $operation): void {
try { $operation(); } catch (RuntimeException $expected) { return; }
throw new LogicException('Повреждённый runtime-контракт принят');
};
try {
foreach (runtimeSetDimensions() as $name => $dimensions) copy($root . '/1/' . $name, $fixture . '/1/' . $name);
$config = ['utm' => ['indpic' => ['default' => '1', 'weights' => ['1' => 1]]]];
// Замена изображения корректным новым изображением не требует Git SHA.
$image = new Imagick($fixture . '/1/01_desk_1-source.jpg');
$image->setImageProperty('comment', 'runtime replacement preland#22');
$image->writeImage($fixture . '/1/01_desk_1-source.jpg');
$image->clear();
if (hash_file('sha256', $fixture . '/1/01_desk_1-source.jpg') === hash_file('sha256', $root . '/1/01_desk_1-source.jpg')) throw new LogicException('Fixture не изменён');
checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture);
$requests = [];
checkRuntimeSets($config, static function (string $path) use ($fixture, &$requests): string { $requests[] = $path; return runtimeLocalReader($fixture)($path); });
if (count($requests) !== 90) throw new LogicException('HTTP-путь обязан прочитать все 90 файлов');
$bytes = file_get_contents($fixture . '/1/01_desk_1-source.jpg');
file_put_contents($fixture . '/1/01_desk_1-source.jpg', '<html>HTTP 200</html>');
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
file_put_contents($fixture . '/1/01_desk_1-source.jpg', $bytes);
$wrong = new Imagick(); $wrong->newImage(1, 1, 'red', 'jpeg'); $wrong->writeImage($fixture . '/1/01_desk_1-source.jpg'); $wrong->clear();
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
file_put_contents($fixture . '/1/01_desk_1-source.jpg', $bytes);
file_put_contents($fixture . '/1/extra.txt', 'extra');
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
unlink($fixture . '/1/extra.txt');
unlink($fixture . '/1/05_mob_3-thumb.avif');
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
copy($root . '/1/05_mob_3-thumb.avif', $fixture . '/1/05_mob_3-thumb.avif');
foreach (['0', '10', '../1', false, null] as $default) {
$bad = $config; $bad['utm']['indpic']['default'] = $default;
$assertFails(fn() => checkRuntimeSets($bad, runtimeLocalReader($fixture), $fixture));
}
$bad = $config; $bad['utm']['indpic']['weights']['2'] = 1;
$assertFails(fn() => checkRuntimeSets($bad, runtimeLocalReader($fixture), $fixture));
$bad = $config; $bad['utm']['indpic']['default'] = '2';
$assertFails(fn() => checkRuntimeSets($bad, runtimeLocalReader($fixture), $fixture));
checkRuntimeSets([], runtimeLocalReader($fixture), $fixture); // штатный fallback /1/
foreach (releaseFiles($root) as $file) if (preg_match('~^(admin/|config\.json$|[0-9]+/)~', $file)) throw new LogicException('Приватный/runtime файл в поставке');
echo "OK: runtime replacement, decode, dimensions, missing/extra files, default, active links, release isolation\n";
} finally {
foreach (glob($fixture . '/1/*') as $file) unlink($file);
rmdir($fixture . '/1'); rmdir($fixture);
}
+4 -1
View File
@@ -45,7 +45,10 @@ if ($params !== $expected) {
exit(1);
}
if ($redirect_digits !== ['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'2','cons'=>'2','focus'=>'1']) {
// Type-in следует текущим default владельца; фиксируем полный состав, не исторический набор значений.
$expectedDigits=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1','part'=>'1'];
foreach ($expectedDigits as $key=>$fallback) $expectedDigits[$key]=(string)($config['utm'][$key]['default']??$fallback);
if ($redirect_digits !== $expectedDigits) {
throw new RuntimeException('Type-in обязан сохранить полный resolved numeric snapshot для handoff/recovery.');
}
+2 -2
View File
@@ -22,9 +22,9 @@ if (!is_array($config)) {
}
$focus = $config['utm']['focus'] ?? null;
if (($focus['weights'] ?? null) !== ['2' => 0, '1' => 1]
if (($focus['weights'] ?? null) !== ['2' => 0, '1' => 100]
|| ($focus['default'] ?? null) !== '1') {
$failures[] = 'Рабочий config.json должен фиксировать явно согласованный focus=face.';
$failures[] = 'Рабочий config.json должен фиксировать focus=face в процентном формате весов (#28).';
}
foreach (['indpic', 'theme', 'degr', 'reg', 'banch', 'cons', 'tp'] as $key) {
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more