Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c4a2ca27 | ||
|
|
ba33a76a34 | ||
|
|
791761a782 | ||
|
|
4a6683f335 | ||
|
|
2970f990e0 | ||
|
|
875549a5af | ||
|
|
01f83375a7 | ||
|
|
469bec1595 | ||
|
|
f7d1792e23 | ||
|
|
1993f7b4b1 | ||
|
|
0d350ed14b | ||
|
|
94619432a3 | ||
|
|
941a4f01aa | ||
|
|
47405b27d8 | ||
|
|
a22519e754 | ||
|
|
00fbef9ede | ||
|
|
dd6d221dd7 | ||
|
|
84f70b1c71 | ||
|
|
f2097d4800 | ||
|
|
4fc4fd8162 | ||
|
|
6ec813bc99 | ||
|
|
522aaf8554 | ||
|
|
d04b40519e | ||
|
|
2cfa2e2d2e | ||
|
|
48c71f3300 | ||
|
|
9515c1c62b | ||
|
|
5b6b2d2053 | ||
|
|
b3b14ec193 |
No files matched your search
@@ -20,7 +20,7 @@ jobs:
|
|||||||
REMOTE_DIR: domains/zoomstage.online
|
REMOTE_DIR: domains/zoomstage.online
|
||||||
steps:
|
steps:
|
||||||
- name: Install tools
|
- 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
|
- name: Checkout staging branch
|
||||||
env:
|
env:
|
||||||
@@ -38,8 +38,8 @@ jobs:
|
|||||||
php bin/release-assets.php --check
|
php bin/release-assets.php --check
|
||||||
for test in tests/check-*.php; do php "$test"; done
|
for test in tests/check-*.php; do php "$test"; done
|
||||||
php bin/release-assets.php --primary
|
php bin/release-assets.php --primary
|
||||||
# /N/ по-прежнему принадлежит stage. Любое расхождение требует
|
# preland#22: /N/ — runtime. Проверяем config и декодируем все 90
|
||||||
# сначала перенести актуальный снимок в Git по задаче.
|
# обязательных изображений используемых наборов, не сверяем с Git SHA.
|
||||||
php bin/release-assets.php --stage-sets "$PRELAND_URL"
|
php bin/release-assets.php --stage-sets "$PRELAND_URL"
|
||||||
|
|
||||||
- name: Apply approved domain brand only if needed
|
- name: Apply approved domain brand only if needed
|
||||||
@@ -85,9 +85,8 @@ jobs:
|
|||||||
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
|
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
|
||||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||||
# Наборы `/N/` на stage — редактируемый контент. Кодовая доставка их
|
# Наборы `/N/` на stage — редактируемый контент. Кодовая доставка их
|
||||||
# намеренно не перезаписывает: актуальное содержимое stage фиксируют в Git
|
# намеренно не перезаписывает. Обновление набора через админку не требует Git.
|
||||||
# отдельной синхронизацией по задаче.
|
# Положительный список: admin/tests/bin/планы/секреты не публикуются.
|
||||||
# Положительный список: tests/bin/планы не публикуются.
|
|
||||||
php bin/release-assets.php --files | grep -vE '^(config\.json$|[0-9]+/)' > "$files"
|
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
|
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"
|
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
||||||
@@ -141,10 +140,8 @@ jobs:
|
|||||||
echo "$api"
|
echo "$api"
|
||||||
case "$api" in *'zoomzilla.ru\/api\/images\/public\/create'*) ;; *) echo "на странице нет адреса загрузки Zoomzilla"; exit 1 ;; esac
|
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; }
|
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; }
|
# Список наборов берётся из runtime config, а не фиксируется как /1/ и /2/.
|
||||||
curl -fsS --max-time 30 "$PRELAND_URL/2/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован второй набор примеров"; exit 1; }
|
# Полная проверка изображений ниже; одиночный HTTP200 недостаточен.
|
||||||
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; }
|
|
||||||
php bin/release-assets.php --remote "$PRELAND_URL"
|
php bin/release-assets.php --remote "$PRELAND_URL"
|
||||||
php bin/release-brand.php --status "$PRELAND_URL"
|
php bin/release-brand.php --status "$PRELAND_URL"
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@
|
|||||||
создавать target-specific конфиги или `config.json.example`.
|
создавать target-specific конфиги или `config.json.example`.
|
||||||
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
||||||
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
||||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
|
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
|
||||||
Git отдельной синхронизацией по задаче.
|
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
|
||||||
- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert`
|
- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert`
|
||||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
- Проверки кода и браузера проводить в 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 ради выката.
|
- 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 зависит от содержимого.
|
- --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 в памяти, не сохранять потенциальные секреты в логи.
|
- По прямому выбору владельца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ресурса.
|
- 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.
|
- 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 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
|
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 продуктовый код не меняется.
|
Перед выпуском: девять 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 ради выпуска.
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
создавать target-specific конфиги или `config.json.example`.
|
создавать target-specific конфиги или `config.json.example`.
|
||||||
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
||||||
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
||||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
|
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
|
||||||
Git отдельной синхронизацией по задаче.
|
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
|
||||||
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
||||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
- Проверки кода и браузера проводить в 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 ради выката.
|
- 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 зависит от содержимого.
|
- --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 в памяти, не сохранять потенциальные секреты в логи.
|
- По прямому выбору владельца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ресурса.
|
- 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.
|
- 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 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
|
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 продуктовый код не меняется.
|
Перед выпуском: девять 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 ради выпуска.
|
||||||
@@ -65,12 +65,13 @@ HTTP→HTTPS, проверка двух тем/ширин, примеров, upl
|
|||||||
| `tp` | номер настроенной тарифной группы1–9 |
|
| `tp` | номер настроенной тарифной группы1–9 |
|
||||||
| `cons` | номер группы согласий1–9; существование проверяет API |
|
| `cons` | номер группы согласий1–9; существование проверяет API |
|
||||||
| `focus` |1 face,2 center |
|
| `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-скрипты больше не вызываются доставкой.
|
`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`.
|
В 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` берётся из фактически показанной темы непосредственно при переходе.
|
Чужие параметры (`utm_source`, `keyword`, `subid` и любые другие) сохраняют значения после обычного URL-кодирования. Технические `key/id/stage/shown_theme` не маскируются; внешний `key` не может подменить одноразовый ключ загрузки. `shown_theme` берётся из фактически показанной темы непосредственно при переходе.
|
||||||
|
|
||||||
@@ -144,9 +145,9 @@ N/05_...
|
|||||||
|
|
||||||
Для `zoomstage.online` обычный deploy ветки `staging` намеренно **не загружает**
|
Для `zoomstage.online` обычный deploy ветки `staging` намеренно **не загружает**
|
||||||
корневые числовые каталоги `/N/`: это редактируемый контент stage, а не артефакт
|
корневые числовые каталоги `/N/`: это редактируемый контент stage, а не артефакт
|
||||||
кодовой доставки. Workflow продолжает проверять их доступность HTTP-smoke. Чтобы
|
кодовой доставки. По #22 workflow проверяет их полный контракт по фактическому
|
||||||
зафиксировать обновлённый набор, сначала синхронизируйте его со stage в Git по
|
config через runtime guard. Импорт изменённого набора в Git не требуется;
|
||||||
отдельной задаче; следующая заливка не перетрёт его старой ревизией.
|
следующая заливка не перетрёт его старой ревизией.
|
||||||
|
|
||||||
## Деплой на произвольный домен
|
## Деплой на произвольный домен
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ N/05_...
|
|||||||
1. **Домен.** Запись A (или CNAME) на хостинг, сертификат (Let's Encrypt или от хостинга); открыть `https://<домен>/`.
|
1. **Домен.** Запись A (или CNAME) на хостинг, сертификат (Let's Encrypt или от хостинга); открыть `https://<домен>/`.
|
||||||
2. **Файлы.** Содержимое репозитория — в корень сайта, без `.git` и служебного (`README.md`, `.gitea/`):
|
2. **Файлы.** Содержимое репозитория — в корень сайта, без `.git` и служебного (`README.md`, `.gitea/`):
|
||||||
```bash
|
```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-клиент
|
# дальше — загрузка на хостинг: rsync/scp по SSH или FTP-клиент
|
||||||
```
|
```
|
||||||
3. **Конфиг и реквизиты.** Рабочий `config.json` на хостинге — источник истины. При первичном создании домена его настраивают отдельно; обычный deploy не заменяет его репозиторным снимком и не перезаписывает `requisites.html`. Менять можно только явно согласованные ключи после чтения текущего файла.
|
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 ради выката.
|
- 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 зависит от содержимого.
|
- --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 в памяти, не сохранять потенциальные секреты в логи.
|
- По прямому выбору владельца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ресурса.
|
- 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.
|
- 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 продуктовый код не меняется.
|
Перед выпуском: девять 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, который рамочный тест не замечал.
|
Дополнение приёмки #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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
tests
|
||||||
|
vendor
|
||||||
|
state
|
||||||
|
secret.env
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/vendor/
|
||||||
|
/state/
|
||||||
|
/secret.env
|
||||||
|
/.env
|
||||||
|
/*.log
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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";
|
||||||
@@ -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);}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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"}
|
||||||
|
}
|
||||||
Generated
+254
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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;}});
|
||||||
|
})();
|
||||||
@@ -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);}
|
||||||
@@ -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);}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
namespace PrelandAdmin;
|
||||||
|
final class Conflict extends Problem { public function __construct() { parent::__construct('Файл изменился. Перечитайте настройки и повторите изменение.',409); } }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
namespace PrelandAdmin;
|
||||||
|
class Problem extends \RuntimeException { public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); } }
|
||||||
@@ -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);}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);});}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)&µtime(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)&µtime(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);}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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"]
|
||||||
@@ -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";
|
||||||
@@ -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);}
|
||||||
@@ -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";
|
||||||
@@ -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);}
|
||||||
@@ -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";
|
||||||
@@ -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');
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -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');
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);});
|
||||||
@@ -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)});
|
||||||
@@ -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);});
|
||||||
@@ -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);});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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');
|
||||||
@@ -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);});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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);});
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -56,6 +56,12 @@ try {
|
|||||||
// Валидация всей грамматики без последующей сериализации decoded данных.
|
// Валидация всей грамматики без последующей сериализации decoded данных.
|
||||||
json_decode($raw,false,512,JSON_THROW_ON_ERROR|JSON_BIGINT_AS_STRING);
|
json_decode($raw,false,512,JSON_THROW_ON_ERROR|JSON_BIGINT_AS_STRING);
|
||||||
$offset=0; $root=utmConfigNode($raw,$offset);
|
$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;
|
$utm=utmConfigProperty($root,'utm')['value'] ?? null;
|
||||||
if (($root['type'] ?? null)!=='object' || ($utm['type'] ?? null)!=='object') throw new RuntimeException('Отсутствует объект utm');
|
if (($root['type'] ?? null)!=='object' || ($utm['type'] ?? null)!=='object') throw new RuntimeException('Отсутствует объект utm');
|
||||||
$replacements=[];
|
$replacements=[];
|
||||||
|
|||||||
+11
-4
@@ -2,6 +2,7 @@
|
|||||||
/** preland#11: проверенный состав ресурсов; исполняется до SFTP. */
|
/** preland#11: проверенный состав ресурсов; исполняется до SFTP. */
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require_once __DIR__ . '/brand-contract.php';
|
require_once __DIR__ . '/brand-contract.php';
|
||||||
|
require_once __DIR__ . '/check-runtime-sets.php';
|
||||||
|
|
||||||
function releaseAssetBytes(string $path, string $bytes): string
|
function releaseAssetBytes(string $path, string $bytes): string
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,8 @@ function releaseStaticFiles(string $root): array
|
|||||||
function validateReleaseAssets(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);
|
$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);
|
$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);
|
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'];
|
$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);
|
if (!isset($files[$path])) throw new RuntimeException('В manifest отсутствует обязательный ресурс ' . $path);
|
||||||
}
|
}
|
||||||
$names = array_keys($files); sort($names, SORT_STRING);
|
$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) {
|
foreach ($files as $path => $record) {
|
||||||
if (!preg_match('~^[A-Za-z0-9._/-]+$~', $path) || str_contains($path, '..')) throw new RuntimeException('Недопустимый путь ресурса');
|
if (!preg_match('~^[A-Za-z0-9._/-]+$~', $path) || str_contains($path, '..')) throw new RuntimeException('Недопустимый путь ресурса');
|
||||||
$bytes = releaseAssetBytes($path, (string) file_get_contents($root . '/' . $path));
|
$bytes = releaseAssetBytes($path, (string) file_get_contents($root . '/' . $path));
|
||||||
@@ -55,7 +58,9 @@ function validateReleaseAssets(string $root): array
|
|||||||
|
|
||||||
function releaseFiles(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 (['.htaccess', 'index.php', 'i18n.php', 'template.php'] as $path) $files[] = $path;
|
||||||
foreach (['components', 'messages'] as $directory) {
|
foreach (['components', 'messages'] as $directory) {
|
||||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/' . $directory, FilesystemIterator::SKIP_DOTS));
|
$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
|
function verifyRemoteReleaseAssets(array $files, string $base, bool $setsOnly): void
|
||||||
{
|
{
|
||||||
if (!preg_match('~^https://[A-Za-z0-9.-]+$~', $base)) throw new RuntimeException('Требуется HTTPS origin');
|
if (!preg_match('~^https://[A-Za-z0-9.-]+$~', $base)) throw new RuntimeException('Требуется HTTPS origin');
|
||||||
|
verifyRemoteRuntimeSets($base);
|
||||||
|
if ($setsOnly) return;
|
||||||
if (!$setsOnly) {
|
if (!$setsOnly) {
|
||||||
$brand = prelandBrand(dirname(__DIR__), (string) parse_url($base, PHP_URL_HOST));
|
$brand = prelandBrand(dirname(__DIR__), (string) parse_url($base, PHP_URL_HOST));
|
||||||
foreach ($brand['logos'] as $path => $record) $files[$path]['sha256'] = $record['sha256'];
|
foreach ($brand['logos'] as $path => $record) $files[$path]['sha256'] = $record['sha256'];
|
||||||
}
|
}
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($files as $path => $record) {
|
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]]);
|
$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);
|
$bytes = @file_get_contents($base . '/' . $path . '?release_sha=' . $record['sha256'], false, $context);
|
||||||
if ($bytes === false || hash('sha256', releaseAssetBytes($path, $bytes)) !== $record['sha256']) {
|
if ($bytes === false || hash('sha256', releaseAssetBytes($path, $bytes)) !== $record['sha256']) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (form.querySelector('.dropzone-form-button')?.disabled) return;
|
if (form.querySelector('.dropzone-form-button')?.disabled) return;
|
||||||
|
if(event.isTrusted&&window.ZoomzillaAnalyticsConfig?.guestUpload!==false)window.ZoomzillaAnalytics?.emit('upload_click');
|
||||||
other.value = '';
|
other.value = '';
|
||||||
other.click();
|
other.click();
|
||||||
});
|
});
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const timer = window.setTimeout(() => reader.abort(), 30000);
|
const timer = window.setTimeout(() => reader.abort(), 30000);
|
||||||
const failed = error => {
|
const failed = error => {
|
||||||
|
if(window.ZoomzillaAnalyticsConfig?.guestUpload!==false)window.ZoomzillaAnalytics?.emit('photo_error',{stage:'read',reason_code:'file_read_failed'});
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
|
window.UploadDiagnostics?.record('buffer_error', { error_name: error.name, message: error.message });
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|||||||
+20
-11
@@ -108,7 +108,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
let leavingUploadPage = false;
|
let leavingUploadPage = false;
|
||||||
let uploadAttempt = 0;
|
let uploadAttempt = 0;
|
||||||
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
|
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(() => {});
|
const clearRecovery = () => recovery.clear().catch(() => {});
|
||||||
let uploadDots = 1;
|
let uploadDots = 1;
|
||||||
let uploadDotsTimer = null;
|
let uploadDotsTimer = null;
|
||||||
@@ -157,6 +157,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.target !== fileInput) {
|
if (e.target !== fileInput) {
|
||||||
|
if(e.isTrusted)window.ZoomzillaAnalytics?.emit('upload_click');
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -193,6 +194,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSelectedFile(file) {
|
function handleSelectedFile(file) {
|
||||||
|
window.ZoomzillaAnalytics?.begin();
|
||||||
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
|
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
|
||||||
rejectOversizedFile(file);
|
rejectOversizedFile(file);
|
||||||
return;
|
return;
|
||||||
@@ -202,9 +204,11 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redirectToTour(record) {
|
function redirectToTour(record) {
|
||||||
|
const azv=window.ZoomzillaAnalytics?.handoff(record.meta.analytics);
|
||||||
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
||||||
const redirectQuery = window.ZoomzillaUtmHandoff.query(record.meta, utmDigits);
|
const redirectQuery = window.ZoomzillaUtmHandoff.query(record.meta, utmDigits);
|
||||||
if (redirectQuery) targetUrl += '&' + redirectQuery;
|
if (redirectQuery) targetUrl += '&' + redirectQuery;
|
||||||
|
if(azv)targetUrl+='&azv='+encodeURIComponent(azv);
|
||||||
const shownTheme = window.ZoomzillaTheme.current();
|
const shownTheme = window.ZoomzillaTheme.current();
|
||||||
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
|
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
|
||||||
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
|
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_name', file.name || 'upload');
|
||||||
formData.append('original_size', String(file.size || 0));
|
formData.append('original_size', String(file.size || 0));
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
|
window.ZoomzillaAnalytics?.append(formData,record.meta.analytics);
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', record.meta.actionUrl, true);
|
xhr.open('POST', record.meta.actionUrl, true);
|
||||||
xhr.withCredentials = 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 (attempt !== uploadAttempt || leavingUploadPage) return;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
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); }
|
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;
|
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);
|
xhr.send(formData);
|
||||||
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
|
} 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());
|
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);
|
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) {
|
function uploadPreparedFile(file, bytes, record) {
|
||||||
const attempt = uploadAttempt;
|
const attempt = uploadAttempt;
|
||||||
@@ -260,6 +265,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
const clientKey = record.key;
|
const clientKey = record.key;
|
||||||
formData.append('file', bytes, file.name || 'upload');
|
formData.append('file', bytes, file.name || 'upload');
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
|
window.ZoomzillaAnalytics?.append(formData,record.meta.analytics);
|
||||||
|
|
||||||
const actionUrl = record.meta.actionUrl;
|
const actionUrl = record.meta.actionUrl;
|
||||||
xhr.open('POST', actionUrl, true);
|
xhr.open('POST', actionUrl, true);
|
||||||
@@ -280,13 +286,13 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
try {
|
try {
|
||||||
response = JSON.parse(xhr.responseText);
|
response = JSON.parse(xhr.responseText);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg, false, false, true, xhr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageId = response?.id || response?.data?.id || response?.image?.id || response?.image_id;
|
const imageId = response?.id || response?.data?.id || response?.image?.id || response?.image_id;
|
||||||
if (!imageId) {
|
if (!imageId) {
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg, false, false, true, xhr);
|
||||||
return;
|
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 : '')
|
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
||||||
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
||||||
catch (_) { showUploadError(genericErrorMsg); }
|
catch (_) { showUploadError(genericErrorMsg, false, false, true, xhr); }
|
||||||
} else {
|
} else {
|
||||||
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500);
|
showUploadError(genericErrorMsg, false, xhr.status === 408 || xhr.status >= 500, true, xhr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true);
|
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true, true, true, xhr);
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUploadError(msg, keepRecovery = false, connection = false) {
|
function showUploadError(msg, keepRecovery = false, connection = false, recordAnalytics = true, analyticsRequest = null) {
|
||||||
if (leavingUploadPage) return;
|
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++;
|
uploadAttempt++;
|
||||||
if (!keepRecovery) clearRecovery();
|
if (!keepRecovery) clearRecovery();
|
||||||
resetUploadPending();
|
resetUploadPending();
|
||||||
@@ -333,6 +341,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const saved = await recovery.load();
|
const saved = await recovery.load();
|
||||||
|
window.ZoomzillaAnalytics?.restore(saved?.meta?.analytics);
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); 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); }
|
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
function prelandUtmEnums(): array
|
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 при доставке кода до точечной миграции. */
|
/** Совместимость host-config при доставке кода до точечной миграции. */
|
||||||
@@ -36,7 +36,7 @@ function prelandNumericUtmConfig(array $config): array
|
|||||||
function prelandResolveUtm(array $query, array $config): array
|
function prelandResolveUtm(array $query, array $config): array
|
||||||
{
|
{
|
||||||
$config=prelandNumericUtmConfig($config);
|
$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;
|
$enums=prelandUtmEnums(); $digits=[]; $context=$query;
|
||||||
foreach (['registration','bchoice','chob','chb'] as $old) unset($context[$old]);
|
foreach (['registration','bchoice','chob','chb'] as $old) unset($context[$old]);
|
||||||
foreach ($defaults as $key=>$fallback) {
|
foreach ($defaults as $key=>$fallback) {
|
||||||
@@ -44,7 +44,7 @@ function prelandResolveUtm(array $query, array $config): array
|
|||||||
$settings=$config['utm'][$key] ?? [];
|
$settings=$config['utm'][$key] ?? [];
|
||||||
$valid=static fn($digit): bool => preg_match('/^[1-9]$/D',(string)$digit)===1
|
$valid=static fn($digit): bool => preg_match('/^[1-9]$/D',(string)$digit)===1
|
||||||
&& (!isset($enums[$key]) || isset($enums[$key][$digit]))
|
&& (!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);
|
$default=(string)($settings['default'] ?? $fallback);
|
||||||
if (!$valid($default)) $default=$fallback;
|
if (!$valid($default)) $default=$fallback;
|
||||||
$value=$query[$key] ?? null;
|
$value=$query[$key] ?? null;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
/** api#326: новый шум при каждой навигации; recovery хранит выбранные цифры. */
|
/** api#326: новый шум при каждой навигации; recovery хранит выбранные цифры. */
|
||||||
return <<<'JS'
|
return <<<'JS'
|
||||||
(function () {
|
(function () {
|
||||||
const keys = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
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'}};
|
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 legacy = {reg:'registration',banch:'bchoice'};
|
||||||
const valid = (key, value) => /^[1-9]$/.test(value || '') && (!enums[key] || Object.values(enums[key]).includes(value));
|
const valid = (key, value) => /^[1-9]$/.test(value || '') && (!enums[key] || Object.values(enums[key]).includes(value));
|
||||||
function restore(meta, defaults) {
|
function restore(meta, defaults) {
|
||||||
|
|||||||
+13
-13
@@ -10,7 +10,7 @@
|
|||||||
"utm": {
|
"utm": {
|
||||||
"indpic": {
|
"indpic": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 1,
|
"1": 100,
|
||||||
"2": 0
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "1"
|
"default": "1"
|
||||||
@@ -18,42 +18,42 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"2": 0,
|
"2": 0,
|
||||||
"1": 1
|
"1": 100
|
||||||
},
|
},
|
||||||
"default": "1"
|
"default": "1"
|
||||||
},
|
},
|
||||||
"degr": {
|
"degr": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 1,
|
"1": 100,
|
||||||
"2": 0
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "1"
|
"default": "1"
|
||||||
},
|
},
|
||||||
"reg": {
|
"reg": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 0,
|
"1": 100,
|
||||||
"2": 1
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "2"
|
"default": "1"
|
||||||
},
|
},
|
||||||
"banch": {
|
"banch": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"2": 1,
|
"1": 100,
|
||||||
"1": 0
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "2"
|
"default": "2"
|
||||||
},
|
},
|
||||||
"tp": {
|
"tp": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 0,
|
"1": 100,
|
||||||
"2": 1
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "2"
|
"default": "1"
|
||||||
},
|
},
|
||||||
"focus": {
|
"focus": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"2": 0,
|
"2": 0,
|
||||||
"1": 1
|
"1": 100
|
||||||
},
|
},
|
||||||
"default": "1"
|
"default": "1"
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"1": 100,
|
"1": 100,
|
||||||
"2": 0
|
"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/секреты проверяются на фактической среде.
|
||||||
@@ -17,7 +17,7 @@ Yii::init($config['default_language'] ?? 'ru');
|
|||||||
$config = prelandNumericUtmConfig($config);
|
$config = prelandNumericUtmConfig($config);
|
||||||
$resolvedUtm = prelandResolveUtm($_GET, $config);
|
$resolvedUtm = prelandResolveUtm($_GET, $config);
|
||||||
$UTM = $resolvedUtm['context'];
|
$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. Публичная оболочка создаётся при навигации.
|
// Числа — внутренний snapshot для recovery. Публичная оболочка создаётся при навигации.
|
||||||
$redirect_digits = $resolvedUtm['digits'];
|
$redirect_digits = $resolvedUtm['digits'];
|
||||||
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
|
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
"captured": "2026-10-21",
|
"captured": "2026-10-21",
|
||||||
"hashing": "SHA-256; SVG/CSS/HTML/webmanifest CRLF normalized to LF",
|
"hashing": "SHA-256; SVG/CSS/HTML/webmanifest CRLF normalized to LF",
|
||||||
"files": {
|
"files": {
|
||||||
|
"popup-theme.css": {
|
||||||
|
"sha256": "4d8ed6e142dacf34da6d61394c8dee0297309a8eb570000cd8549bc0c920df4c",
|
||||||
|
"source": "api#353: approved popup light theme"
|
||||||
|
},
|
||||||
"1/01_desk_1-source.avif": {
|
"1/01_desk_1-source.avif": {
|
||||||
"sha256": "d81af6d3757d0e932af04d956cd4083e7c7c547c9fbac290d27f5ce8bbaede1c",
|
"sha256": "d81af6d3757d0e932af04d956cd4083e7c7c547c9fbac290d27f5ce8bbaede1c",
|
||||||
"source": "https://zoomstage.online/1/01_desk_1-source.avif"
|
"source": "https://zoomstage.online/1/01_desk_1-source.avif"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<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-style.php'; ?>
|
||||||
<?php require __DIR__ . '/components/theme-client.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>
|
<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">
|
<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__ . '/style.min.css'); ?></style>
|
||||||
|
<style><?= file_get_contents(__DIR__ . '/popup-theme.css'); ?></style>
|
||||||
<style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style>
|
<style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style>
|
||||||
<?php require_once __DIR__ . '/components/browser-upload.php'; ?>
|
<?php require_once __DIR__ . '/components/browser-upload.php'; ?>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ foreach ([
|
|||||||
|
|
||||||
$workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
|
$workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
|
||||||
foreach ([
|
foreach ([
|
||||||
'"$PRELAND_URL/1/01_desk_1-source.avif"',
|
'php bin/release-assets.php --stage-sets "$PRELAND_URL"',
|
||||||
'"$PRELAND_URL/2/01_desk_1-source.avif"',
|
'php bin/release-assets.php --remote "$PRELAND_URL"',
|
||||||
'2/01_mob_1-source.avif',
|
'php-imagick',
|
||||||
'Remove obsolete demo directories',
|
'Remove obsolete demo directories',
|
||||||
] as $requiredStageSmokeCheck) {
|
] as $requiredStageSmokeCheck) {
|
||||||
if (!str_contains($workflow, $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]+/)')) {
|
if (!str_contains($workflow, '|[0-9]+/)')) {
|
||||||
$failures[] = 'Stage-деплой не должен перезаписывать корневые наборы /N/: stage владеет их содержимым.';
|
$failures[] = 'Stage-деплой не должен перезаписывать корневые наборы /N/: stage владеет их содержимым.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,5 +42,22 @@ try{
|
|||||||
exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file).' 2>/dev/null',$out,$code);
|
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 без записи');
|
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";
|
echo "OK: numeric config migration preserves weights/default/foreign JSON types\n";
|
||||||
}finally{unlink($file);}
|
}finally{unlink($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");
|
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 нарушил веса');
|
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);
|
$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) {
|
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);
|
$resolved=prelandResolveUtm([$key=>$invalid,'registration'=>'before','bchoice'=>'b','chb'=>'a'],$settings);
|
||||||
if($resolved['digits'][$key]!==$defaults[$key])throw new RuntimeException('Открытый/invalid URL изменил '.$key);
|
if($resolved['digits'][$key]!==$defaults[$key])throw new RuntimeException('Открытый/invalid URL изменил '.$key);
|
||||||
if(isset($resolved['context']['chb']))throw new RuntimeException('Legacy chb прошёл как foreign');
|
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');
|
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']]];
|
$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');
|
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";
|
echo "OK: numeric public boundary, defaults, weighted0, foreign values\n";
|
||||||
foreach (['abcd1','1','b',[]] as $old) {
|
foreach (['abcd1','1','b',[]] as $old) {
|
||||||
$resolved=prelandResolveUtm(['chob'=>$old,'bchoice'=>'b','chb'=>'a'],$settings);
|
$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']]];
|
$oldConfig=['utm'=>['chob'=>['weights'=>['1'=>5,'2'=>0],'default'=>'1']]];
|
||||||
if(prelandResolveUtm([],$oldConfig)['digits']['banch']!=='1')throw new RuntimeException('api#327: старый host config обязан работать до миграции');
|
if(prelandResolveUtm([],$oldConfig)['digits']['banch']!=='1')throw new RuntimeException('api#327: старый host config обязан работать до миграции');
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
$root = dirname(__DIR__);
|
$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',
|
'zoomstage.online' => 'zoomstage.online',
|
||||||
'New-Preland.example:8080' => 'new-preland.example',
|
'New-Preland.example:8080' => 'new-preland.example',
|
||||||
'www.example.test.' => 'www.example.test',
|
'www.example.test.' => 'www.example.test',
|
||||||
'<script>alert(1)</script>' => 'fixture.local',
|
'<script>alert(1)</script>' => 'fixture.local',
|
||||||
] as $host => $expected) {
|
] 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('Подвал не найден');
|
if (!preg_match('~<footer\b.*?</footer>~s', $html, $match)) throw new RuntimeException('Подвал не найден');
|
||||||
$footer = $match[0];
|
$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, $forbidden)) throw new RuntimeException('В подвале остался ' . $forbidden);
|
||||||
}
|
}
|
||||||
if (!str_contains($footer, date('Y') . '© ' . $expected)) throw new RuntimeException('Неправильный домен копирайта: ' . $host);
|
if (!str_contains($footer, date('Y') . '© ' . $expected)) throw new RuntimeException('Неправильный домен копирайта: ' . $host);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -45,7 +45,10 @@ if ($params !== $expected) {
|
|||||||
exit(1);
|
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.');
|
throw new RuntimeException('Type-in обязан сохранить полный resolved numeric snapshot для handoff/recovery.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ if (!is_array($config)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$focus = $config['utm']['focus'] ?? null;
|
$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') {
|
|| ($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) {
|
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
Reference in new issue
Block a user